ai-cc-router 0.4.1 → 0.4.3

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.
@@ -122,7 +122,7 @@ export function registerClient(program) {
122
122
  });
123
123
  }
124
124
  if (wantsDesktop) {
125
- await setupDesktopInterception(url);
125
+ await setupDesktopInterception(url, secret);
126
126
  cfg.client.desktopEnabled = true;
127
127
  writeConfig(cfg);
128
128
  }
@@ -277,7 +277,7 @@ export function registerClient(program) {
277
277
  process.exit(1);
278
278
  }
279
279
  if (!cfg.client.desktopEnabled) {
280
- await setupDesktopInterception(cfg.client.remoteUrl);
280
+ await setupDesktopInterception(cfg.client.remoteUrl, cfg.client.remoteSecret);
281
281
  cfg.client.desktopEnabled = true;
282
282
  writeConfig(cfg);
283
283
  }
@@ -305,11 +305,12 @@ export function registerClient(program) {
305
305
  }
306
306
  }
307
307
  const target = cfg.client.remoteUrl;
308
+ const secret = cfg.client.remoteSecret;
308
309
  const processName = getProcessName();
309
310
  console.log(chalk.cyan(`\nStarting mitmproxy interceptor for "${processName}"...`));
310
311
  console.log(chalk.gray(` Redirecting api.anthropic.com/v1/messages → ${target}`));
311
312
  try {
312
- await startInterceptor(target);
313
+ await startInterceptor(target, secret);
313
314
  }
314
315
  catch (e) {
315
316
  console.error(chalk.red(`\n✗ Failed to start interceptor:\n`));
@@ -325,7 +326,7 @@ export function registerClient(program) {
325
326
  default: true,
326
327
  });
327
328
  if (autoStart) {
328
- const ok = await installInterceptorService(target);
329
+ const ok = await installInterceptorService(target, secret);
329
330
  if (ok) {
330
331
  cfg.client.desktopAutoStart = true;
331
332
  writeConfig(cfg);
@@ -408,7 +409,7 @@ export function printNetworkExtensionInstructions() {
408
409
  console.log(" " + chalk.cyan("5.") + " Enter your Mac admin password when prompted\n");
409
410
  console.log(chalk.gray(" You only need to do this ONCE per machine.\n"));
410
411
  }
411
- async function setupDesktopInterception(target) {
412
+ async function setupDesktopInterception(target, secret) {
412
413
  console.log(chalk.bold("\n🖥 Claude Desktop Setup\n"));
413
414
  // 0. Explain what actually works before anything else
414
415
  printDesktopSupportExplainer();
@@ -474,8 +475,8 @@ async function setupDesktopInterception(target) {
474
475
  console.log(chalk.gray(" ~/.mitmproxy/mitmproxy-ca-cert.pem"));
475
476
  }
476
477
  }
477
- // 4. Write addon script
478
- writeAddonScript(target);
478
+ // 4. Write addon script (with secret so intercepted requests authenticate)
479
+ writeAddonScript(target, secret);
479
480
  console.log(chalk.green("✓ Redirect addon configured"));
480
481
  // 5. macOS Network Extension — THIS is the step people miss
481
482
  if (isMacos()) {
@@ -400,7 +400,7 @@ async function runClientSetupFromWizard() {
400
400
  default: false,
401
401
  });
402
402
  if (wantsDesktop) {
403
- await setupDesktopFromWizard(url);
403
+ await setupDesktopFromWizard(url, secret);
404
404
  cfg.client.desktopEnabled = true;
405
405
  writeConfig(cfg);
406
406
  }
@@ -413,7 +413,7 @@ async function runClientSetupFromWizard() {
413
413
  }
414
414
  console.log();
415
415
  }
416
- async function setupDesktopFromWizard(target) {
416
+ async function setupDesktopFromWizard(target, secret) {
417
417
  console.log(chalk.bold("\n🖥 Claude Desktop — Cowork / Agent Setup\n"));
418
418
  // 1. Check mitmproxy
419
419
  if (!(await checkMitmproxyInstalled())) {
@@ -459,8 +459,8 @@ async function setupDesktopFromWizard(target) {
459
459
  console.log(chalk.gray(" -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem"));
460
460
  }
461
461
  }
462
- // 3. Addon
463
- writeAddonScript(target);
462
+ // 3. Addon (with secret so intercepted requests authenticate against the proxy)
463
+ writeAddonScript(target, secret);
464
464
  console.log(chalk.green("✓ Redirect addon configured"));
465
465
  // 4. Network Extension walkthrough (macOS)
466
466
  if (isMacos()) {
@@ -192,19 +192,22 @@ export async function installCaCert() {
192
192
  * Write the redirect addon to ~/.cc-router/interceptor/addon.py.
193
193
  * Uses the bundled template as source.
194
194
  */
195
- export function writeAddonScript(target) {
195
+ export function writeAddonScript(target, secret) {
196
196
  if (!existsSync(ADDON_DIR))
197
197
  mkdirSync(ADDON_DIR, { recursive: true });
198
198
  // Try to use the bundled addon as template; fall back to a minimal inline version.
199
- // In BOTH cases we inject the actual target URL so the addon is self-contained
200
- // and doesn't depend on the CC_ROUTER_TARGET env var being present at runtime.
199
+ // In BOTH cases we inject the actual target URL (and secret) so the addon is
200
+ // self-contained and doesn't depend on the CC_ROUTER_TARGET / CC_ROUTER_SECRET
201
+ // env vars being present at runtime.
201
202
  const bundled = addonSourcePath();
202
203
  let src;
203
204
  if (existsSync(bundled)) {
204
205
  src = readFileSync(bundled, "utf-8");
205
206
  }
206
207
  else {
207
- // Inline fallback — minimal addon (only redirects /v1/messages and /v1/models)
208
+ // Inline fallback — minimal addon (handles /v1/messages and /v1/models
209
+ // for both api.anthropic.com traffic and requests already pointed at the
210
+ // CC-Router target, injecting the secret in both cases).
208
211
  src = `
209
212
  import os
210
213
  from mitmproxy import http
@@ -217,22 +220,38 @@ _target_parsed = urlparse(_target)
217
220
  if not _target_parsed.scheme or not _target_parsed.netloc:
218
221
  raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
219
222
 
223
+ _target_host = (_target_parsed.hostname or "").lower()
224
+ _target_port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
225
+
226
+ _secret = os.environ.get("CC_ROUTER_SECRET", "")
227
+
220
228
  _REDIRECT_PREFIXES = ("/v1/messages", "/v1/models")
221
229
 
222
230
  def request(flow: http.HTTPFlow) -> None:
223
- if flow.request.pretty_host != "api.anthropic.com":
231
+ host = (flow.request.pretty_host or "").lower()
232
+ port = flow.request.port
233
+ is_anthropic = host == "api.anthropic.com"
234
+ is_target = host == _target_host and port == _target_port
235
+ if not is_anthropic and not is_target:
224
236
  return
225
237
  if not flow.request.path.startswith(_REDIRECT_PREFIXES):
226
238
  return
227
- flow.request.scheme = _target_parsed.scheme
228
- flow.request.host = _target_parsed.hostname or "localhost"
229
- flow.request.port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
230
- flow.request.headers["host"] = flow.request.host + (f":{flow.request.port}" if flow.request.port not in (80, 443) else "")
239
+ if is_anthropic:
240
+ flow.request.scheme = _target_parsed.scheme
241
+ flow.request.host = _target_host or "localhost"
242
+ flow.request.port = _target_port
243
+ flow.request.headers["host"] = flow.request.host + (f":{flow.request.port}" if flow.request.port not in (80, 443) else "")
244
+ if _secret:
245
+ flow.request.headers["x-api-key"] = _secret
231
246
  `.trimStart();
232
247
  }
233
- // Inject the actual target URL into the default so the addon works even
234
- // without the CC_ROUTER_TARGET env var (e.g. manual mitmdump restarts).
248
+ // Inject the target URL and (optionally) secret into the default values so
249
+ // the addon works even without the CC_ROUTER_TARGET / CC_ROUTER_SECRET env
250
+ // vars being present at runtime (manual mitmdump restarts, launchd, etc.).
235
251
  src = src.replace('"http://localhost:3456"', JSON.stringify(target));
252
+ if (secret) {
253
+ src = src.replace('os.environ.get("CC_ROUTER_SECRET", "")', `os.environ.get("CC_ROUTER_SECRET", ${JSON.stringify(secret)})`);
254
+ }
236
255
  writeFileSync(ADDON_PATH, src, "utf-8");
237
256
  }
238
257
  // ─── Interceptor lifecycle ────────────────────────────────────────────────────
@@ -240,7 +259,7 @@ def request(flow: http.HTTPFlow) -> None:
240
259
  * Start mitmdump in local mode, intercepting the Claude process and redirecting
241
260
  * api.anthropic.com traffic to CC-Router via the addon script.
242
261
  */
243
- export async function startInterceptor(target) {
262
+ export async function startInterceptor(target, secret) {
244
263
  // On macOS, verify the Network Extension is enabled before attempting to start.
245
264
  // If it's "waiting", mitmdump starts silently but captures zero traffic.
246
265
  if (isMacos()) {
@@ -261,7 +280,7 @@ export async function startInterceptor(target) {
261
280
  }
262
281
  // Always (re)write the addon script so package updates and target-URL
263
282
  // changes are picked up automatically without requiring a fresh setup.
264
- writeAddonScript(target);
283
+ writeAddonScript(target, secret);
265
284
  const processName = getProcessName();
266
285
  const args = [
267
286
  "--mode", `local:${processName}`,
@@ -269,10 +288,13 @@ export async function startInterceptor(target) {
269
288
  "--set", "connection_strategy=lazy",
270
289
  "--quiet",
271
290
  ];
291
+ const env = { ...process.env, CC_ROUTER_TARGET: target };
292
+ if (secret)
293
+ env["CC_ROUTER_SECRET"] = secret;
272
294
  const child = spawn("mitmdump", args, {
273
295
  detached: true,
274
296
  stdio: "ignore",
275
- env: { ...process.env, CC_ROUTER_TARGET: target },
297
+ env,
276
298
  });
277
299
  child.unref();
278
300
  if (child.pid) {
@@ -337,8 +359,12 @@ async function resolveMitmdumpPath() {
337
359
  return "mitmdump"; // fallback — hope it's on PATH at boot time
338
360
  }
339
361
  }
340
- function buildInterceptorPlist(mitmdumpPath, target) {
362
+ function buildInterceptorPlist(mitmdumpPath, target, secret) {
341
363
  const processName = getProcessName();
364
+ const secretEntry = secret
365
+ ? ` <key>CC_ROUTER_SECRET</key>
366
+ <string>${secret}</string>`
367
+ : "";
342
368
  return `<?xml version="1.0" encoding="UTF-8"?>
343
369
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
344
370
  <plist version="1.0">
@@ -375,13 +401,15 @@ function buildInterceptorPlist(mitmdumpPath, target) {
375
401
  <string>${process.env["PATH"] ?? "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"}</string>
376
402
  <key>CC_ROUTER_TARGET</key>
377
403
  <string>${target}</string>
404
+ ${secretEntry}
378
405
  </dict>
379
406
  </dict>
380
407
  </plist>
381
408
  `;
382
409
  }
383
- function buildInterceptorSystemdUnit(mitmdumpPath, target) {
410
+ function buildInterceptorSystemdUnit(mitmdumpPath, target, secret) {
384
411
  const processName = getProcessName();
412
+ const secretLine = secret ? `\nEnvironment=CC_ROUTER_SECRET=${secret}` : "";
385
413
  return `[Unit]
386
414
  Description=CC-Router Interceptor — mitmproxy for Claude Desktop
387
415
  After=network-online.target
@@ -395,7 +423,7 @@ RestartSec=5
395
423
  StartLimitIntervalSec=60
396
424
  StartLimitBurst=5
397
425
  Environment=PATH=${process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}
398
- Environment=CC_ROUTER_TARGET=${target}
426
+ Environment=CC_ROUTER_TARGET=${target}${secretLine}
399
427
 
400
428
  [Install]
401
429
  WantedBy=default.target
@@ -405,17 +433,17 @@ WantedBy=default.target
405
433
  * Install the mitmproxy interceptor as an OS service so it starts on boot.
406
434
  * Stops any existing detached mitmdump process first — the OS service takes over.
407
435
  */
408
- export async function installInterceptorService(target) {
409
- // Ensure the addon script is up-to-date with the target URL
410
- writeAddonScript(target);
436
+ export async function installInterceptorService(target, secret) {
437
+ // Ensure the addon script is up-to-date with the target URL and secret
438
+ writeAddonScript(target, secret);
411
439
  // Stop any manually-spawned mitmdump — OS service will manage it now
412
440
  await stopInterceptor();
413
441
  const mitmdumpPath = await resolveMitmdumpPath();
414
442
  const platform = detectPlatform();
415
443
  switch (platform) {
416
- case "macos": return installInterceptorMacOS(mitmdumpPath, target);
417
- case "linux": return installInterceptorLinux(mitmdumpPath, target);
418
- case "windows": return installInterceptorWindows(mitmdumpPath, target);
444
+ case "macos": return installInterceptorMacOS(mitmdumpPath, target, secret);
445
+ case "linux": return installInterceptorLinux(mitmdumpPath, target, secret);
446
+ case "windows": return installInterceptorWindows(mitmdumpPath, target, secret);
419
447
  }
420
448
  }
421
449
  export async function uninstallInterceptorService() {
@@ -435,7 +463,7 @@ export function isInterceptorServiceInstalled() {
435
463
  }
436
464
  }
437
465
  // ─── macOS LaunchAgent ──────────────────────────────────────────────────────
438
- async function installInterceptorMacOS(mitmdumpPath, target) {
466
+ async function installInterceptorMacOS(mitmdumpPath, target, secret) {
439
467
  const launchAgentsDir = dirname(LAUNCHD_PLIST);
440
468
  if (!existsSync(launchAgentsDir))
441
469
  mkdirSync(launchAgentsDir, { recursive: true });
@@ -443,7 +471,7 @@ async function installInterceptorMacOS(mitmdumpPath, target) {
443
471
  if (existsSync(LAUNCHD_PLIST)) {
444
472
  await interceptorLaunchctlUnload();
445
473
  }
446
- writeFileSync(LAUNCHD_PLIST, buildInterceptorPlist(mitmdumpPath, target), "utf-8");
474
+ writeFileSync(LAUNCHD_PLIST, buildInterceptorPlist(mitmdumpPath, target, secret), "utf-8");
447
475
  // Load — try modern `bootstrap` first, fallback to legacy `load`
448
476
  const uid = String(process.getuid?.() ?? 501);
449
477
  try {
@@ -483,10 +511,10 @@ async function interceptorLaunchctlUnload() {
483
511
  }
484
512
  }
485
513
  // ─── Linux systemd user service ─────────────────────────────────────────────
486
- async function installInterceptorLinux(mitmdumpPath, target) {
514
+ async function installInterceptorLinux(mitmdumpPath, target, secret) {
487
515
  if (!existsSync(SYSTEMD_DIR))
488
516
  mkdirSync(SYSTEMD_DIR, { recursive: true });
489
- writeFileSync(SYSTEMD_SERVICE, buildInterceptorSystemdUnit(mitmdumpPath, target), "utf-8");
517
+ writeFileSync(SYSTEMD_SERVICE, buildInterceptorSystemdUnit(mitmdumpPath, target, secret), "utf-8");
490
518
  try {
491
519
  await execFileP("systemctl", ["--user", "daemon-reload"]);
492
520
  await execFileP("systemctl", ["--user", "enable", "cc-router-interceptor"]);
@@ -517,9 +545,10 @@ async function uninstallInterceptorLinux() {
517
545
  catch { /* ok */ }
518
546
  }
519
547
  // ─── Windows Registry ───────────────────────────────────────────────────────
520
- async function installInterceptorWindows(mitmdumpPath, target) {
548
+ async function installInterceptorWindows(mitmdumpPath, target, secret) {
521
549
  const processName = getProcessName();
522
- const cmd = `cmd /c "set CC_ROUTER_TARGET=${target} && "${mitmdumpPath}" --mode local:${processName} -s "${ADDON_PATH}" --set connection_strategy=lazy --quiet"`;
550
+ const secretEnv = secret ? `set CC_ROUTER_SECRET=${secret} && ` : "";
551
+ const cmd = `cmd /c "set CC_ROUTER_TARGET=${target} && ${secretEnv}"${mitmdumpPath}" --mode local:${processName} -s "${ADDON_PATH}" --set connection_strategy=lazy --quiet"`;
523
552
  try {
524
553
  await execFileP("reg", [
525
554
  "add", WINDOWS_REG_KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,14 +1,25 @@
1
- # mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router.
1
+ # mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router and
2
+ # injects the proxy secret as an auth header.
2
3
  #
3
- # Claude Desktop sends many types of requests to api.anthropic.com:
4
- # /v1/messages → LLM inference (this is what we redirect)
5
- # /v1/messages/count_tokens → token counting (redirect too)
6
- # /v1/oauth/* → session auth (must NOT redirect)
7
- # /v1/environments/* → bridge/cowork (must NOT redirect)
8
- # /v1/models → model listing (redirect — CC-Router proxies this)
9
- # /api/* → desktop features (must NOT redirect)
4
+ # There are TWO cases to handle:
10
5
  #
11
- # Only /v1/messages* and /v1/models are safe to redirect because CC-Router
6
+ # 1. Requests to api.anthropic.com (Claude Desktop native features)
7
+ # → rewrite host/port to CC-Router target + inject x-api-key
8
+ #
9
+ # 2. Requests already pointed at the CC-Router target host (Claude Code
10
+ # inside Desktop Cowork/Agent mode, which reads ~/.claude/settings.json
11
+ # and goes direct to ANTHROPIC_BASE_URL)
12
+ # → inject x-api-key (no rewrite needed)
13
+ #
14
+ # Claude Desktop sends many types of requests:
15
+ # /v1/messages → LLM inference (redirect + auth)
16
+ # /v1/messages/count_tokens → token counting (redirect + auth)
17
+ # /v1/oauth/* → session auth (must NOT touch)
18
+ # /v1/environments/* → bridge/cowork (must NOT touch)
19
+ # /v1/models → model listing (redirect + auth)
20
+ # /api/* → desktop features (must NOT touch)
21
+ #
22
+ # Only /v1/messages* and /v1/models are safe to touch because CC-Router
12
23
  # injects its own OAuth token. Everything else carries the user's own
13
24
  # session token for features CC-Router doesn't handle.
14
25
 
@@ -24,6 +35,12 @@ _target_parsed = urlparse(_target)
24
35
  if not _target_parsed.scheme or not _target_parsed.netloc:
25
36
  raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
26
37
 
38
+ _target_host = (_target_parsed.hostname or "").lower()
39
+ _target_port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
40
+
41
+ # Optional proxy secret — when set, injected as x-api-key on routed requests
42
+ _secret = os.environ.get("CC_ROUTER_SECRET", "")
43
+
27
44
  # Paths that CC-Router can handle (it injects its own OAuth token)
28
45
  _REDIRECT_PREFIXES = (
29
46
  "/v1/messages",
@@ -32,18 +49,30 @@ _REDIRECT_PREFIXES = (
32
49
 
33
50
 
34
51
  def request(flow: http.HTTPFlow) -> None:
35
- if flow.request.pretty_host != "api.anthropic.com":
52
+ host = (flow.request.pretty_host or "").lower()
53
+ port = flow.request.port
54
+ is_anthropic = host == "api.anthropic.com"
55
+ is_target = host == _target_host and port == _target_port
56
+
57
+ # Not a host we care about — pass through untouched
58
+ if not is_anthropic and not is_target:
36
59
  return
37
60
 
38
- # Only redirect inference and model-listing paths
61
+ # Only touch inference and model-listing paths
39
62
  if not flow.request.path.startswith(_REDIRECT_PREFIXES):
40
63
  return
41
64
 
42
- flow.request.scheme = _target_parsed.scheme
43
- flow.request.host = _target_parsed.hostname or "localhost"
44
- flow.request.port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
45
- flow.request.headers["host"] = flow.request.host + (
46
- f":{flow.request.port}"
47
- if flow.request.port not in (80, 443)
48
- else ""
49
- )
65
+ # Case 1: rewrite api.anthropic.com CC-Router target
66
+ if is_anthropic:
67
+ flow.request.scheme = _target_parsed.scheme
68
+ flow.request.host = _target_host or "localhost"
69
+ flow.request.port = _target_port
70
+ flow.request.headers["host"] = flow.request.host + (
71
+ f":{flow.request.port}"
72
+ if flow.request.port not in (80, 443)
73
+ else ""
74
+ )
75
+
76
+ # Case 1 and 2: authenticate against the proxy if a secret is configured
77
+ if _secret:
78
+ flow.request.headers["x-api-key"] = _secret