ai-cc-router 0.4.0 → 0.4.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.
@@ -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,12 +192,13 @@ 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)) {
@@ -217,6 +218,8 @@ _target_parsed = urlparse(_target)
217
218
  if not _target_parsed.scheme or not _target_parsed.netloc:
218
219
  raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
219
220
 
221
+ _secret = os.environ.get("CC_ROUTER_SECRET", "")
222
+
220
223
  _REDIRECT_PREFIXES = ("/v1/messages", "/v1/models")
221
224
 
222
225
  def request(flow: http.HTTPFlow) -> None:
@@ -228,11 +231,17 @@ def request(flow: http.HTTPFlow) -> None:
228
231
  flow.request.host = _target_parsed.hostname or "localhost"
229
232
  flow.request.port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
230
233
  flow.request.headers["host"] = flow.request.host + (f":{flow.request.port}" if flow.request.port not in (80, 443) else "")
234
+ if _secret:
235
+ flow.request.headers["x-api-key"] = _secret
231
236
  `.trimStart();
232
237
  }
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).
238
+ // Inject the target URL and (optionally) secret into the default values so
239
+ // the addon works even without the CC_ROUTER_TARGET / CC_ROUTER_SECRET env
240
+ // vars being present at runtime (manual mitmdump restarts, launchd, etc.).
235
241
  src = src.replace('"http://localhost:3456"', JSON.stringify(target));
242
+ if (secret) {
243
+ src = src.replace('os.environ.get("CC_ROUTER_SECRET", "")', `os.environ.get("CC_ROUTER_SECRET", ${JSON.stringify(secret)})`);
244
+ }
236
245
  writeFileSync(ADDON_PATH, src, "utf-8");
237
246
  }
238
247
  // ─── Interceptor lifecycle ────────────────────────────────────────────────────
@@ -240,7 +249,7 @@ def request(flow: http.HTTPFlow) -> None:
240
249
  * Start mitmdump in local mode, intercepting the Claude process and redirecting
241
250
  * api.anthropic.com traffic to CC-Router via the addon script.
242
251
  */
243
- export async function startInterceptor(target) {
252
+ export async function startInterceptor(target, secret) {
244
253
  // On macOS, verify the Network Extension is enabled before attempting to start.
245
254
  // If it's "waiting", mitmdump starts silently but captures zero traffic.
246
255
  if (isMacos()) {
@@ -259,9 +268,9 @@ export async function startInterceptor(target) {
259
268
  " Then re-run this command.");
260
269
  }
261
270
  }
262
- // Ensure addon exists
263
- if (!existsSync(ADDON_PATH))
264
- writeAddonScript(target);
271
+ // Always (re)write the addon script so package updates and target-URL
272
+ // changes are picked up automatically without requiring a fresh setup.
273
+ writeAddonScript(target, secret);
265
274
  const processName = getProcessName();
266
275
  const args = [
267
276
  "--mode", `local:${processName}`,
@@ -269,10 +278,13 @@ export async function startInterceptor(target) {
269
278
  "--set", "connection_strategy=lazy",
270
279
  "--quiet",
271
280
  ];
281
+ const env = { ...process.env, CC_ROUTER_TARGET: target };
282
+ if (secret)
283
+ env["CC_ROUTER_SECRET"] = secret;
272
284
  const child = spawn("mitmdump", args, {
273
285
  detached: true,
274
286
  stdio: "ignore",
275
- env: { ...process.env, CC_ROUTER_TARGET: target },
287
+ env,
276
288
  });
277
289
  child.unref();
278
290
  if (child.pid) {
@@ -337,8 +349,12 @@ async function resolveMitmdumpPath() {
337
349
  return "mitmdump"; // fallback — hope it's on PATH at boot time
338
350
  }
339
351
  }
340
- function buildInterceptorPlist(mitmdumpPath, target) {
352
+ function buildInterceptorPlist(mitmdumpPath, target, secret) {
341
353
  const processName = getProcessName();
354
+ const secretEntry = secret
355
+ ? ` <key>CC_ROUTER_SECRET</key>
356
+ <string>${secret}</string>`
357
+ : "";
342
358
  return `<?xml version="1.0" encoding="UTF-8"?>
343
359
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
344
360
  <plist version="1.0">
@@ -375,13 +391,15 @@ function buildInterceptorPlist(mitmdumpPath, target) {
375
391
  <string>${process.env["PATH"] ?? "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"}</string>
376
392
  <key>CC_ROUTER_TARGET</key>
377
393
  <string>${target}</string>
394
+ ${secretEntry}
378
395
  </dict>
379
396
  </dict>
380
397
  </plist>
381
398
  `;
382
399
  }
383
- function buildInterceptorSystemdUnit(mitmdumpPath, target) {
400
+ function buildInterceptorSystemdUnit(mitmdumpPath, target, secret) {
384
401
  const processName = getProcessName();
402
+ const secretLine = secret ? `\nEnvironment=CC_ROUTER_SECRET=${secret}` : "";
385
403
  return `[Unit]
386
404
  Description=CC-Router Interceptor — mitmproxy for Claude Desktop
387
405
  After=network-online.target
@@ -395,7 +413,7 @@ RestartSec=5
395
413
  StartLimitIntervalSec=60
396
414
  StartLimitBurst=5
397
415
  Environment=PATH=${process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}
398
- Environment=CC_ROUTER_TARGET=${target}
416
+ Environment=CC_ROUTER_TARGET=${target}${secretLine}
399
417
 
400
418
  [Install]
401
419
  WantedBy=default.target
@@ -405,17 +423,17 @@ WantedBy=default.target
405
423
  * Install the mitmproxy interceptor as an OS service so it starts on boot.
406
424
  * Stops any existing detached mitmdump process first — the OS service takes over.
407
425
  */
408
- export async function installInterceptorService(target) {
409
- // Ensure the addon script is up-to-date with the target URL
410
- writeAddonScript(target);
426
+ export async function installInterceptorService(target, secret) {
427
+ // Ensure the addon script is up-to-date with the target URL and secret
428
+ writeAddonScript(target, secret);
411
429
  // Stop any manually-spawned mitmdump — OS service will manage it now
412
430
  await stopInterceptor();
413
431
  const mitmdumpPath = await resolveMitmdumpPath();
414
432
  const platform = detectPlatform();
415
433
  switch (platform) {
416
- case "macos": return installInterceptorMacOS(mitmdumpPath, target);
417
- case "linux": return installInterceptorLinux(mitmdumpPath, target);
418
- case "windows": return installInterceptorWindows(mitmdumpPath, target);
434
+ case "macos": return installInterceptorMacOS(mitmdumpPath, target, secret);
435
+ case "linux": return installInterceptorLinux(mitmdumpPath, target, secret);
436
+ case "windows": return installInterceptorWindows(mitmdumpPath, target, secret);
419
437
  }
420
438
  }
421
439
  export async function uninstallInterceptorService() {
@@ -435,7 +453,7 @@ export function isInterceptorServiceInstalled() {
435
453
  }
436
454
  }
437
455
  // ─── macOS LaunchAgent ──────────────────────────────────────────────────────
438
- async function installInterceptorMacOS(mitmdumpPath, target) {
456
+ async function installInterceptorMacOS(mitmdumpPath, target, secret) {
439
457
  const launchAgentsDir = dirname(LAUNCHD_PLIST);
440
458
  if (!existsSync(launchAgentsDir))
441
459
  mkdirSync(launchAgentsDir, { recursive: true });
@@ -443,7 +461,7 @@ async function installInterceptorMacOS(mitmdumpPath, target) {
443
461
  if (existsSync(LAUNCHD_PLIST)) {
444
462
  await interceptorLaunchctlUnload();
445
463
  }
446
- writeFileSync(LAUNCHD_PLIST, buildInterceptorPlist(mitmdumpPath, target), "utf-8");
464
+ writeFileSync(LAUNCHD_PLIST, buildInterceptorPlist(mitmdumpPath, target, secret), "utf-8");
447
465
  // Load — try modern `bootstrap` first, fallback to legacy `load`
448
466
  const uid = String(process.getuid?.() ?? 501);
449
467
  try {
@@ -483,10 +501,10 @@ async function interceptorLaunchctlUnload() {
483
501
  }
484
502
  }
485
503
  // ─── Linux systemd user service ─────────────────────────────────────────────
486
- async function installInterceptorLinux(mitmdumpPath, target) {
504
+ async function installInterceptorLinux(mitmdumpPath, target, secret) {
487
505
  if (!existsSync(SYSTEMD_DIR))
488
506
  mkdirSync(SYSTEMD_DIR, { recursive: true });
489
- writeFileSync(SYSTEMD_SERVICE, buildInterceptorSystemdUnit(mitmdumpPath, target), "utf-8");
507
+ writeFileSync(SYSTEMD_SERVICE, buildInterceptorSystemdUnit(mitmdumpPath, target, secret), "utf-8");
490
508
  try {
491
509
  await execFileP("systemctl", ["--user", "daemon-reload"]);
492
510
  await execFileP("systemctl", ["--user", "enable", "cc-router-interceptor"]);
@@ -517,9 +535,10 @@ async function uninstallInterceptorLinux() {
517
535
  catch { /* ok */ }
518
536
  }
519
537
  // ─── Windows Registry ───────────────────────────────────────────────────────
520
- async function installInterceptorWindows(mitmdumpPath, target) {
538
+ async function installInterceptorWindows(mitmdumpPath, target, secret) {
521
539
  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"`;
540
+ const secretEnv = secret ? `set CC_ROUTER_SECRET=${secret} && ` : "";
541
+ const cmd = `cmd /c "set CC_ROUTER_TARGET=${target} && ${secretEnv}"${mitmdumpPath}" --mode local:${processName} -s "${ADDON_PATH}" --set connection_strategy=lazy --quiet"`;
523
542
  try {
524
543
  await execFileP("reg", [
525
544
  "add", WINDOWS_REG_KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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": {
@@ -24,6 +24,9 @@ _target_parsed = urlparse(_target)
24
24
  if not _target_parsed.scheme or not _target_parsed.netloc:
25
25
  raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
26
26
 
27
+ # Optional proxy secret — when set, injected as x-api-key on redirected requests
28
+ _secret = os.environ.get("CC_ROUTER_SECRET", "")
29
+
27
30
  # Paths that CC-Router can handle (it injects its own OAuth token)
28
31
  _REDIRECT_PREFIXES = (
29
32
  "/v1/messages",
@@ -47,3 +50,7 @@ def request(flow: http.HTTPFlow) -> None:
47
50
  if flow.request.port not in (80, 443)
48
51
  else ""
49
52
  )
53
+
54
+ # Authenticate against the proxy if a secret is configured
55
+ if _secret:
56
+ flow.request.headers["x-api-key"] = _secret