@timo972/cc-router 0.7.0
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 +96 -0
- package/Dockerfile +42 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/accounts.example.json +25 -0
- package/dist/cli/cmd-accounts.js +248 -0
- package/dist/cli/cmd-client.js +612 -0
- package/dist/cli/cmd-configure.js +145 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-logs.js +85 -0
- package/dist/cli/cmd-models.js +125 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +501 -0
- package/dist/cli/cmd-start.js +318 -0
- package/dist/cli/cmd-status.js +177 -0
- package/dist/cli/cmd-stop.js +100 -0
- package/dist/cli/cmd-telemetry.js +58 -0
- package/dist/cli/cmd-update.js +37 -0
- package/dist/cli/index.js +59 -0
- package/dist/config/manager.js +262 -0
- package/dist/config/paths.js +21 -0
- package/dist/config/telemetry.js +64 -0
- package/dist/daemon/launcher.js +163 -0
- package/dist/daemon/pid.js +98 -0
- package/dist/daemon/service.js +260 -0
- package/dist/interceptor/mitmproxy-manager.js +616 -0
- package/dist/protocol/anthropic-to-openai.js +51 -0
- package/dist/protocol/anthropic-types.js +1 -0
- package/dist/protocol/model-ref.js +36 -0
- package/dist/protocol/model-routing-config.js +30 -0
- package/dist/protocol/openai-response-to-anthropic.js +20 -0
- package/dist/protocol/openai-responses-types.js +1 -0
- package/dist/protocol/openai-stream-to-anthropic.js +75 -0
- package/dist/protocol/openai-to-anthropic.js +61 -0
- package/dist/protocol/sse.js +17 -0
- package/dist/providers/model-discovery.js +71 -0
- package/dist/providers/openai/account-pool.js +11 -0
- package/dist/providers/openai/account-record.js +33 -0
- package/dist/providers/openai/codex-transport.js +36 -0
- package/dist/providers/openai/device-oauth.js +116 -0
- package/dist/providers/openai/token-refresher.js +56 -0
- package/dist/providers/route-selector.js +8 -0
- package/dist/providers/types.js +1 -0
- package/dist/proxy/account-deletion.js +44 -0
- package/dist/proxy/anthropic-proxy.js +26 -0
- package/dist/proxy/anthropic-routing.js +90 -0
- package/dist/proxy/lease-lifecycle.js +68 -0
- package/dist/proxy/logger.js +39 -0
- package/dist/proxy/messages-cross-route.js +179 -0
- package/dist/proxy/models-server.js +150 -0
- package/dist/proxy/provider-routing.js +14 -0
- package/dist/proxy/responses-server.js +91 -0
- package/dist/proxy/server.js +875 -0
- package/dist/proxy/session-router.js +171 -0
- package/dist/proxy/stats.js +25 -0
- package/dist/proxy/stream-lifecycle.js +83 -0
- package/dist/proxy/token-pool.js +407 -0
- package/dist/proxy/token-refresher.js +209 -0
- package/dist/proxy/types.js +29 -0
- package/dist/ui/Dashboard.js +640 -0
- package/dist/ui/accountsApi.js +48 -0
- package/dist/ui/modelsApi.js +47 -0
- package/dist/utils/claude-config.js +185 -0
- package/dist/utils/codex-config.js +62 -0
- package/dist/utils/network.js +16 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/self-update.js +239 -0
- package/dist/utils/telemetry.js +88 -0
- package/dist/utils/token-extractor.js +95 -0
- package/dist/utils/token-validator.js +26 -0
- package/docker-compose.yml +63 -0
- package/litellm-config.yaml +44 -0
- package/package.json +69 -0
- package/src/interceptor/addon.py +78 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages the mitmproxy lifecycle for Claude Desktop interception.
|
|
3
|
+
*
|
|
4
|
+
* mitmproxy "local mode" uses OS-level network extensions to intercept traffic
|
|
5
|
+
* from a specific process (Claude Desktop) and redirect it through a proxy addon
|
|
6
|
+
* that rewrites api.anthropic.com → CC-Router.
|
|
7
|
+
*
|
|
8
|
+
* Platform mechanisms:
|
|
9
|
+
* macOS → Network Extension (App Proxy Provider API)
|
|
10
|
+
* Windows → WinDivert (WFP kernel driver)
|
|
11
|
+
* Linux → eBPF (requires kernel ≥ 6.8)
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import { execFile, execFileSync, spawn } from "child_process";
|
|
17
|
+
import { promisify } from "util";
|
|
18
|
+
import { isMacos, isWindows, detectPlatform } from "../utils/platform.js";
|
|
19
|
+
import { CONFIG_DIR } from "../config/paths.js";
|
|
20
|
+
const execFileP = promisify(execFile);
|
|
21
|
+
// ─── Paths ────────────────────────────────────────────────────────────────────
|
|
22
|
+
const ADDON_DIR = join(CONFIG_DIR, "interceptor");
|
|
23
|
+
const ADDON_PATH = join(ADDON_DIR, "addon.py");
|
|
24
|
+
const PID_PATH = join(ADDON_DIR, "mitmdump.pid");
|
|
25
|
+
const LOG_PATH = join(ADDON_DIR, "mitmdump.log");
|
|
26
|
+
const CA_PATH = join(os.homedir(), ".mitmproxy", "mitmproxy-ca-cert.pem");
|
|
27
|
+
// ─── Service paths ────────────────────────────────────────────────────────────
|
|
28
|
+
const LAUNCHD_LABEL = "com.cc-router.interceptor";
|
|
29
|
+
const LAUNCHD_PLIST = join(os.homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
30
|
+
const SYSTEMD_DIR = join(os.homedir(), ".config", "systemd", "user");
|
|
31
|
+
const SYSTEMD_SERVICE = join(SYSTEMD_DIR, "cc-router-interceptor.service");
|
|
32
|
+
const WINDOWS_REG_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
|
33
|
+
const WINDOWS_REG_NAME = "CC-Router-Interceptor";
|
|
34
|
+
// Bundled addon template lives next to this module in src/interceptor/addon.py;
|
|
35
|
+
// at runtime (dist/) it's NOT guaranteed to exist because the .py file is only
|
|
36
|
+
// included if package.json "files" lists it. We write a copy to ~/.cc-router/
|
|
37
|
+
// on first desktop setup so the user always has a stable file to point at.
|
|
38
|
+
function addonSourcePath() {
|
|
39
|
+
// __dirname in ESM is not available; use import.meta.url
|
|
40
|
+
const thisFile = new URL(import.meta.url).pathname;
|
|
41
|
+
return join(thisFile, "..", "..", "interceptor", "addon.py");
|
|
42
|
+
}
|
|
43
|
+
// ─── Process name ─────────────────────────────────────────────────────────────
|
|
44
|
+
export function getProcessName() {
|
|
45
|
+
if (isMacos())
|
|
46
|
+
return "Claude";
|
|
47
|
+
if (isWindows())
|
|
48
|
+
return "Claude.exe";
|
|
49
|
+
return "claude"; // Linux (truncated to 16 chars by kernel)
|
|
50
|
+
}
|
|
51
|
+
// ─── mitmproxy detection ──────────────────────────────────────────────────────
|
|
52
|
+
export async function checkMitmproxyInstalled() {
|
|
53
|
+
try {
|
|
54
|
+
await execFileP("which", ["mitmdump"]);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// On Windows, "which" doesn't exist — try "where"
|
|
59
|
+
if (isWindows()) {
|
|
60
|
+
try {
|
|
61
|
+
await execFileP("where", ["mitmdump"]);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Check the approval status of the mitmproxy macOS Network Extension.
|
|
73
|
+
* No-op on Windows/Linux (returns "enabled").
|
|
74
|
+
*
|
|
75
|
+
* Parses `systemextensionsctl list` output, looking for the mitmproxy entry.
|
|
76
|
+
* Status comes from the flags column:
|
|
77
|
+
* "* *" → enabled + active
|
|
78
|
+
* " *" → active but waiting for user approval
|
|
79
|
+
*/
|
|
80
|
+
export async function getNetworkExtensionStatus() {
|
|
81
|
+
if (!isMacos())
|
|
82
|
+
return "enabled"; // Only macOS needs this check
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await execFileP("systemextensionsctl", ["list"]);
|
|
85
|
+
const mitmLine = stdout
|
|
86
|
+
.split("\n")
|
|
87
|
+
.find((l) => l.toLowerCase().includes("mitmproxy"));
|
|
88
|
+
if (!mitmLine)
|
|
89
|
+
return "not_installed";
|
|
90
|
+
// systemextensionsctl flags are the first two columns; "*" means set.
|
|
91
|
+
// Order is "enabled active" — both must be "*" for the extension to work.
|
|
92
|
+
// Example strings seen in the wild:
|
|
93
|
+
// "*\t*\tS8XHQB96PW\torg.mitmproxy.macos-redirector..." → enabled
|
|
94
|
+
// "\t*\tS8XHQB96PW\torg.mitmproxy.macos-redirector..." → waiting
|
|
95
|
+
//
|
|
96
|
+
// We also accept the human-readable "[activated enabled]" / "[activated waiting for user]"
|
|
97
|
+
// suffix that newer macOS versions append.
|
|
98
|
+
if (mitmLine.includes("[activated enabled]"))
|
|
99
|
+
return "enabled";
|
|
100
|
+
if (mitmLine.includes("waiting for user"))
|
|
101
|
+
return "waiting";
|
|
102
|
+
const cols = mitmLine.split("\t").map((s) => s.trim());
|
|
103
|
+
const enabled = cols[0] === "*";
|
|
104
|
+
const active = cols[1] === "*";
|
|
105
|
+
if (enabled && active)
|
|
106
|
+
return "enabled";
|
|
107
|
+
if (!enabled && active)
|
|
108
|
+
return "waiting";
|
|
109
|
+
return "not_installed";
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return "unknown";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Open the macOS "Login Items & Extensions" settings pane. Best-effort. */
|
|
116
|
+
export async function openNetworkExtensionSettings() {
|
|
117
|
+
if (!isMacos())
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
// The x-apple.systempreferences URL opens the right pane in System Settings.
|
|
121
|
+
// Extensions pane is not directly deep-linkable, so we open the closest one.
|
|
122
|
+
await execFileP("open", ["x-apple.systempreferences:com.apple.LoginItems-Settings.extension"]);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// If that fails, fall back to opening plain System Settings
|
|
126
|
+
await execFileP("open", ["/System/Applications/System Settings.app"]).catch(() => { });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ─── CA certificate ───────────────────────────────────────────────────────────
|
|
130
|
+
export function isCaCertInstalled() {
|
|
131
|
+
return existsSync(CA_PATH);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Run mitmdump briefly to generate the CA certificate at ~/.mitmproxy/.
|
|
135
|
+
* It auto-generates on first launch.
|
|
136
|
+
*/
|
|
137
|
+
export async function generateCaCert() {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const child = spawn("mitmdump", ["--mode", "regular", "--set", "listen_port=0"], {
|
|
140
|
+
stdio: "ignore",
|
|
141
|
+
});
|
|
142
|
+
// Give it 3 seconds to generate the cert, then kill
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
child.kill("SIGTERM");
|
|
145
|
+
if (existsSync(CA_PATH))
|
|
146
|
+
resolve();
|
|
147
|
+
else
|
|
148
|
+
reject(new Error("CA certificate was not generated"));
|
|
149
|
+
}, 3_000);
|
|
150
|
+
child.on("error", reject);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Install the mitmproxy CA certificate into the OS trust store.
|
|
155
|
+
* Requires elevated privileges (sudo on macOS/Linux, admin on Windows).
|
|
156
|
+
* Returns true on success.
|
|
157
|
+
*/
|
|
158
|
+
export async function installCaCert() {
|
|
159
|
+
if (!existsSync(CA_PATH)) {
|
|
160
|
+
await generateCaCert();
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
if (isMacos()) {
|
|
164
|
+
// Uses `security` CLI — requires password via sudo
|
|
165
|
+
await execFileP("sudo", [
|
|
166
|
+
"security", "add-trusted-cert",
|
|
167
|
+
"-d", "-r", "trustRoot",
|
|
168
|
+
"-k", "/Library/Keychains/System.keychain",
|
|
169
|
+
CA_PATH,
|
|
170
|
+
]);
|
|
171
|
+
}
|
|
172
|
+
else if (isWindows()) {
|
|
173
|
+
// certutil on Windows
|
|
174
|
+
await execFileP("certutil", ["-addstore", "root", CA_PATH]);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Linux (Debian/Ubuntu)
|
|
178
|
+
const destDir = "/usr/local/share/ca-certificates";
|
|
179
|
+
const destFile = join(destDir, "mitmproxy.crt");
|
|
180
|
+
await execFileP("sudo", ["cp", CA_PATH, destFile]);
|
|
181
|
+
await execFileP("sudo", ["update-ca-certificates"]);
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
console.error(`CA install error: ${e.message}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Remove the mitmproxy CA certificate from the OS trust store — the reverse of
|
|
192
|
+
* installCaCert. Requires elevated privileges. Best-effort: returns false if the
|
|
193
|
+
* platform tool fails (e.g. the cert was already removed). Call this on full
|
|
194
|
+
* teardown/uninstall so a system-wide trusted root (whose private key sits in
|
|
195
|
+
* ~/.mitmproxy) is not left behind.
|
|
196
|
+
*/
|
|
197
|
+
export async function removeCaCert() {
|
|
198
|
+
try {
|
|
199
|
+
if (isMacos()) {
|
|
200
|
+
// remove-trusted-cert needs the cert file to reference.
|
|
201
|
+
if (!existsSync(CA_PATH))
|
|
202
|
+
return false;
|
|
203
|
+
await execFileP("sudo", ["security", "remove-trusted-cert", "-d", CA_PATH]);
|
|
204
|
+
}
|
|
205
|
+
else if (isWindows()) {
|
|
206
|
+
// Delete by subject CN ("mitmproxy") from the root store.
|
|
207
|
+
await execFileP("certutil", ["-delstore", "root", "mitmproxy"]);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const destFile = join("/usr/local/share/ca-certificates", "mitmproxy.crt");
|
|
211
|
+
await execFileP("sudo", ["rm", "-f", destFile]);
|
|
212
|
+
await execFileP("sudo", ["update-ca-certificates", "--fresh"]);
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
console.error(`CA removal error: ${e.message}`);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// ─── Addon script ─────────────────────────────────────────────────────────────
|
|
222
|
+
/**
|
|
223
|
+
* Write the redirect addon to ~/.cc-router/interceptor/addon.py.
|
|
224
|
+
* Uses the bundled template as source.
|
|
225
|
+
*/
|
|
226
|
+
export function writeAddonScript(target, secret) {
|
|
227
|
+
if (!existsSync(ADDON_DIR))
|
|
228
|
+
mkdirSync(ADDON_DIR, { recursive: true });
|
|
229
|
+
// Try to use the bundled addon as template; fall back to a minimal inline version.
|
|
230
|
+
// In BOTH cases we inject the actual target URL (and secret) so the addon is
|
|
231
|
+
// self-contained and doesn't depend on the CC_ROUTER_TARGET / CC_ROUTER_SECRET
|
|
232
|
+
// env vars being present at runtime.
|
|
233
|
+
const bundled = addonSourcePath();
|
|
234
|
+
let src;
|
|
235
|
+
if (existsSync(bundled)) {
|
|
236
|
+
src = readFileSync(bundled, "utf-8");
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Inline fallback — minimal addon (handles /v1/messages and /v1/models
|
|
240
|
+
// for both api.anthropic.com traffic and requests already pointed at the
|
|
241
|
+
// CC-Router target, injecting the secret in both cases).
|
|
242
|
+
src = `
|
|
243
|
+
import os
|
|
244
|
+
from mitmproxy import http
|
|
245
|
+
from urllib.parse import urlparse
|
|
246
|
+
|
|
247
|
+
_target_raw = os.environ.get("CC_ROUTER_TARGET", "http://localhost:3456")
|
|
248
|
+
_target = _target_raw.rstrip("/")
|
|
249
|
+
_target_parsed = urlparse(_target)
|
|
250
|
+
|
|
251
|
+
if not _target_parsed.scheme or not _target_parsed.netloc:
|
|
252
|
+
raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
|
|
253
|
+
|
|
254
|
+
_target_host = (_target_parsed.hostname or "").lower()
|
|
255
|
+
_target_port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
|
|
256
|
+
|
|
257
|
+
_secret = os.environ.get("CC_ROUTER_SECRET", "")
|
|
258
|
+
|
|
259
|
+
_REDIRECT_PREFIXES = ("/v1/messages", "/v1/models")
|
|
260
|
+
|
|
261
|
+
def request(flow: http.HTTPFlow) -> None:
|
|
262
|
+
host = (flow.request.pretty_host or "").lower()
|
|
263
|
+
port = flow.request.port
|
|
264
|
+
is_anthropic = host == "api.anthropic.com"
|
|
265
|
+
is_target = host == _target_host and port == _target_port
|
|
266
|
+
if not is_anthropic and not is_target:
|
|
267
|
+
return
|
|
268
|
+
if not flow.request.path.startswith(_REDIRECT_PREFIXES):
|
|
269
|
+
return
|
|
270
|
+
if is_anthropic:
|
|
271
|
+
flow.request.scheme = _target_parsed.scheme
|
|
272
|
+
flow.request.host = _target_host or "localhost"
|
|
273
|
+
flow.request.port = _target_port
|
|
274
|
+
flow.request.headers["host"] = flow.request.host + (f":{flow.request.port}" if flow.request.port not in (80, 443) else "")
|
|
275
|
+
if _secret:
|
|
276
|
+
flow.request.headers["x-api-key"] = _secret
|
|
277
|
+
`.trimStart();
|
|
278
|
+
}
|
|
279
|
+
// Inject the target URL and (optionally) secret into the default values so
|
|
280
|
+
// the addon works even without the CC_ROUTER_TARGET / CC_ROUTER_SECRET env
|
|
281
|
+
// vars being present at runtime (manual mitmdump restarts, launchd, etc.).
|
|
282
|
+
src = src.replace('"http://localhost:3456"', JSON.stringify(target));
|
|
283
|
+
if (secret) {
|
|
284
|
+
src = src.replace('os.environ.get("CC_ROUTER_SECRET", "")', `os.environ.get("CC_ROUTER_SECRET", ${JSON.stringify(secret)})`);
|
|
285
|
+
}
|
|
286
|
+
writeFileSync(ADDON_PATH, src, "utf-8");
|
|
287
|
+
}
|
|
288
|
+
// ─── Interceptor lifecycle ────────────────────────────────────────────────────
|
|
289
|
+
/**
|
|
290
|
+
* Start mitmdump in local mode, intercepting the Claude process and redirecting
|
|
291
|
+
* api.anthropic.com traffic to CC-Router via the addon script.
|
|
292
|
+
*/
|
|
293
|
+
export async function startInterceptor(target, secret) {
|
|
294
|
+
// On macOS, verify the Network Extension is enabled before attempting to start.
|
|
295
|
+
// If it's "waiting", mitmdump starts silently but captures zero traffic.
|
|
296
|
+
if (isMacos()) {
|
|
297
|
+
const status = await getNetworkExtensionStatus();
|
|
298
|
+
if (status === "waiting") {
|
|
299
|
+
throw new Error("Mitmproxy Network Extension is installed but not yet approved.\n" +
|
|
300
|
+
" Open: System Settings → General → Login Items & Extensions → Network Extensions\n" +
|
|
301
|
+
' Toggle "Mitmproxy Redirector" ON and enter your admin password.\n' +
|
|
302
|
+
" Then re-run this command.");
|
|
303
|
+
}
|
|
304
|
+
if (status === "not_installed") {
|
|
305
|
+
throw new Error("Mitmproxy Network Extension is not installed.\n" +
|
|
306
|
+
" Run mitmdump once manually to trigger the installation:\n" +
|
|
307
|
+
' mitmdump --mode "local:Claude" --set connection_strategy=lazy\n' +
|
|
308
|
+
" macOS will prompt you to approve it in System Settings.\n" +
|
|
309
|
+
" Then re-run this command.");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Always (re)write the addon script so package updates and target-URL
|
|
313
|
+
// changes are picked up automatically without requiring a fresh setup.
|
|
314
|
+
writeAddonScript(target, secret);
|
|
315
|
+
const processName = getProcessName();
|
|
316
|
+
const args = [
|
|
317
|
+
"--mode", `local:${processName}`,
|
|
318
|
+
"-s", ADDON_PATH,
|
|
319
|
+
"--set", "connection_strategy=lazy",
|
|
320
|
+
"--quiet",
|
|
321
|
+
];
|
|
322
|
+
const env = { ...process.env, CC_ROUTER_TARGET: target };
|
|
323
|
+
if (secret)
|
|
324
|
+
env["CC_ROUTER_SECRET"] = secret;
|
|
325
|
+
const child = spawn("mitmdump", args, {
|
|
326
|
+
detached: true,
|
|
327
|
+
stdio: "ignore",
|
|
328
|
+
env,
|
|
329
|
+
});
|
|
330
|
+
child.unref();
|
|
331
|
+
if (child.pid) {
|
|
332
|
+
if (!existsSync(ADDON_DIR))
|
|
333
|
+
mkdirSync(ADDON_DIR, { recursive: true });
|
|
334
|
+
writeFileSync(PID_PATH, String(child.pid), "utf-8");
|
|
335
|
+
}
|
|
336
|
+
// Give it a moment to start and verify it's running
|
|
337
|
+
await new Promise(r => setTimeout(r, 2_000));
|
|
338
|
+
if (!await isInterceptorRunning()) {
|
|
339
|
+
throw new Error("mitmdump started but exited immediately. Check mitmproxy installation and Network Extension approval.");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** Stop the running mitmdump interceptor. */
|
|
343
|
+
export async function stopInterceptor() {
|
|
344
|
+
const pid = readPid();
|
|
345
|
+
if (!pid)
|
|
346
|
+
return;
|
|
347
|
+
try {
|
|
348
|
+
process.kill(pid, "SIGTERM");
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Already dead
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const { unlinkSync } = await import("fs");
|
|
355
|
+
unlinkSync(PID_PATH);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// ignore
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Check if the mitmproxy interceptor is currently running. */
|
|
362
|
+
export async function isInterceptorRunning() {
|
|
363
|
+
const pid = readPid();
|
|
364
|
+
if (!pid)
|
|
365
|
+
return false;
|
|
366
|
+
try {
|
|
367
|
+
process.kill(pid, 0); // signal 0 = existence check
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function readPid() {
|
|
375
|
+
if (!existsSync(PID_PATH))
|
|
376
|
+
return null;
|
|
377
|
+
const raw = readFileSync(PID_PATH, "utf-8").trim();
|
|
378
|
+
const pid = parseInt(raw, 10);
|
|
379
|
+
return Number.isNaN(pid) ? null : pid;
|
|
380
|
+
}
|
|
381
|
+
// ─── Interceptor OS service (auto-start on boot) ────────────────────────────
|
|
382
|
+
/** Resolve the absolute path to mitmdump so launchd/systemd can find it. */
|
|
383
|
+
async function resolveMitmdumpPath() {
|
|
384
|
+
try {
|
|
385
|
+
const cmd = isWindows() ? "where" : "which";
|
|
386
|
+
const { stdout } = await execFileP(cmd, ["mitmdump"]);
|
|
387
|
+
return stdout.trim().split("\n")[0];
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return "mitmdump"; // fallback — hope it's on PATH at boot time
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function buildInterceptorPlist(mitmdumpPath, target, secret) {
|
|
394
|
+
const processName = getProcessName();
|
|
395
|
+
const secretEntry = secret
|
|
396
|
+
? ` <key>CC_ROUTER_SECRET</key>
|
|
397
|
+
<string>${secret}</string>`
|
|
398
|
+
: "";
|
|
399
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
400
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
401
|
+
<plist version="1.0">
|
|
402
|
+
<dict>
|
|
403
|
+
<key>Label</key>
|
|
404
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
405
|
+
<key>ProgramArguments</key>
|
|
406
|
+
<array>
|
|
407
|
+
<string>${mitmdumpPath}</string>
|
|
408
|
+
<string>--mode</string>
|
|
409
|
+
<string>local:${processName}</string>
|
|
410
|
+
<string>-s</string>
|
|
411
|
+
<string>${ADDON_PATH}</string>
|
|
412
|
+
<string>--set</string>
|
|
413
|
+
<string>connection_strategy=lazy</string>
|
|
414
|
+
<string>--quiet</string>
|
|
415
|
+
</array>
|
|
416
|
+
<key>RunAtLoad</key>
|
|
417
|
+
<true/>
|
|
418
|
+
<key>KeepAlive</key>
|
|
419
|
+
<dict>
|
|
420
|
+
<key>SuccessfulExit</key>
|
|
421
|
+
<false/>
|
|
422
|
+
</dict>
|
|
423
|
+
<key>StandardOutPath</key>
|
|
424
|
+
<string>${LOG_PATH}</string>
|
|
425
|
+
<key>StandardErrorPath</key>
|
|
426
|
+
<string>${LOG_PATH}</string>
|
|
427
|
+
<key>WorkingDirectory</key>
|
|
428
|
+
<string>${os.homedir()}</string>
|
|
429
|
+
<key>EnvironmentVariables</key>
|
|
430
|
+
<dict>
|
|
431
|
+
<key>PATH</key>
|
|
432
|
+
<string>${process.env["PATH"] ?? "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"}</string>
|
|
433
|
+
<key>CC_ROUTER_TARGET</key>
|
|
434
|
+
<string>${target}</string>
|
|
435
|
+
${secretEntry}
|
|
436
|
+
</dict>
|
|
437
|
+
</dict>
|
|
438
|
+
</plist>
|
|
439
|
+
`;
|
|
440
|
+
}
|
|
441
|
+
function buildInterceptorSystemdUnit(mitmdumpPath, target, secret) {
|
|
442
|
+
const processName = getProcessName();
|
|
443
|
+
const secretLine = secret ? `\nEnvironment=CC_ROUTER_SECRET=${secret}` : "";
|
|
444
|
+
return `[Unit]
|
|
445
|
+
Description=CC-Router Interceptor — mitmproxy for Claude Desktop
|
|
446
|
+
After=network-online.target
|
|
447
|
+
Wants=network-online.target
|
|
448
|
+
|
|
449
|
+
[Service]
|
|
450
|
+
Type=simple
|
|
451
|
+
ExecStart=${mitmdumpPath} --mode local:${processName} -s ${ADDON_PATH} --set connection_strategy=lazy --quiet
|
|
452
|
+
Restart=on-failure
|
|
453
|
+
RestartSec=5
|
|
454
|
+
StartLimitIntervalSec=60
|
|
455
|
+
StartLimitBurst=5
|
|
456
|
+
Environment=PATH=${process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}
|
|
457
|
+
Environment=CC_ROUTER_TARGET=${target}${secretLine}
|
|
458
|
+
|
|
459
|
+
[Install]
|
|
460
|
+
WantedBy=default.target
|
|
461
|
+
`;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Install the mitmproxy interceptor as an OS service so it starts on boot.
|
|
465
|
+
* Stops any existing detached mitmdump process first — the OS service takes over.
|
|
466
|
+
*/
|
|
467
|
+
export async function installInterceptorService(target, secret) {
|
|
468
|
+
// Ensure the addon script is up-to-date with the target URL and secret
|
|
469
|
+
writeAddonScript(target, secret);
|
|
470
|
+
// Stop any manually-spawned mitmdump — OS service will manage it now
|
|
471
|
+
await stopInterceptor();
|
|
472
|
+
const mitmdumpPath = await resolveMitmdumpPath();
|
|
473
|
+
const platform = detectPlatform();
|
|
474
|
+
switch (platform) {
|
|
475
|
+
case "macos": return installInterceptorMacOS(mitmdumpPath, target, secret);
|
|
476
|
+
case "linux": return installInterceptorLinux(mitmdumpPath, target, secret);
|
|
477
|
+
case "windows": return installInterceptorWindows(mitmdumpPath, target, secret);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
export async function uninstallInterceptorService() {
|
|
481
|
+
const platform = detectPlatform();
|
|
482
|
+
switch (platform) {
|
|
483
|
+
case "macos": return uninstallInterceptorMacOS();
|
|
484
|
+
case "linux": return uninstallInterceptorLinux();
|
|
485
|
+
case "windows": return uninstallInterceptorWindows();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
export function isInterceptorServiceInstalled() {
|
|
489
|
+
const platform = detectPlatform();
|
|
490
|
+
switch (platform) {
|
|
491
|
+
case "macos": return existsSync(LAUNCHD_PLIST);
|
|
492
|
+
case "linux": return existsSync(SYSTEMD_SERVICE);
|
|
493
|
+
case "windows": return isInterceptorWindowsServiceInstalled();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// ─── macOS LaunchAgent ──────────────────────────────────────────────────────
|
|
497
|
+
async function installInterceptorMacOS(mitmdumpPath, target, secret) {
|
|
498
|
+
const launchAgentsDir = dirname(LAUNCHD_PLIST);
|
|
499
|
+
if (!existsSync(launchAgentsDir))
|
|
500
|
+
mkdirSync(launchAgentsDir, { recursive: true });
|
|
501
|
+
// Unload existing if present
|
|
502
|
+
if (existsSync(LAUNCHD_PLIST)) {
|
|
503
|
+
await interceptorLaunchctlUnload();
|
|
504
|
+
}
|
|
505
|
+
writeFileSync(LAUNCHD_PLIST, buildInterceptorPlist(mitmdumpPath, target, secret), "utf-8");
|
|
506
|
+
// Load — try modern `bootstrap` first, fallback to legacy `load`
|
|
507
|
+
const uid = String(process.getuid?.() ?? 501);
|
|
508
|
+
try {
|
|
509
|
+
await execFileP("launchctl", ["bootstrap", `gui/${uid}`, LAUNCHD_PLIST]);
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
try {
|
|
513
|
+
await execFileP("launchctl", ["load", LAUNCHD_PLIST]);
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
console.log(`⚠ Could not auto-load the interceptor LaunchAgent: ${err.message}`);
|
|
517
|
+
console.log(` Load manually: launchctl load ${LAUNCHD_PLIST}`);
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
async function uninstallInterceptorMacOS() {
|
|
524
|
+
if (!existsSync(LAUNCHD_PLIST))
|
|
525
|
+
return;
|
|
526
|
+
await interceptorLaunchctlUnload();
|
|
527
|
+
try {
|
|
528
|
+
unlinkSync(LAUNCHD_PLIST);
|
|
529
|
+
}
|
|
530
|
+
catch { /* ok */ }
|
|
531
|
+
}
|
|
532
|
+
async function interceptorLaunchctlUnload() {
|
|
533
|
+
const uid = String(process.getuid?.() ?? 501);
|
|
534
|
+
try {
|
|
535
|
+
await execFileP("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`]);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
try {
|
|
539
|
+
await execFileP("launchctl", ["unload", LAUNCHD_PLIST]);
|
|
540
|
+
}
|
|
541
|
+
catch { /* already unloaded */ }
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// ─── Linux systemd user service ─────────────────────────────────────────────
|
|
545
|
+
async function installInterceptorLinux(mitmdumpPath, target, secret) {
|
|
546
|
+
if (!existsSync(SYSTEMD_DIR))
|
|
547
|
+
mkdirSync(SYSTEMD_DIR, { recursive: true });
|
|
548
|
+
writeFileSync(SYSTEMD_SERVICE, buildInterceptorSystemdUnit(mitmdumpPath, target, secret), "utf-8");
|
|
549
|
+
try {
|
|
550
|
+
await execFileP("systemctl", ["--user", "daemon-reload"]);
|
|
551
|
+
await execFileP("systemctl", ["--user", "enable", "cc-router-interceptor"]);
|
|
552
|
+
await execFileP("systemctl", ["--user", "start", "cc-router-interceptor"]);
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
console.log(`⚠ systemd setup issue: ${err.message}`);
|
|
557
|
+
console.log(" Enable manually: systemctl --user enable --now cc-router-interceptor");
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function uninstallInterceptorLinux() {
|
|
562
|
+
if (!existsSync(SYSTEMD_SERVICE))
|
|
563
|
+
return;
|
|
564
|
+
try {
|
|
565
|
+
await execFileP("systemctl", ["--user", "stop", "cc-router-interceptor"]);
|
|
566
|
+
await execFileP("systemctl", ["--user", "disable", "cc-router-interceptor"]);
|
|
567
|
+
}
|
|
568
|
+
catch { /* may already be stopped */ }
|
|
569
|
+
try {
|
|
570
|
+
unlinkSync(SYSTEMD_SERVICE);
|
|
571
|
+
}
|
|
572
|
+
catch { /* ok */ }
|
|
573
|
+
try {
|
|
574
|
+
await execFileP("systemctl", ["--user", "daemon-reload"]);
|
|
575
|
+
}
|
|
576
|
+
catch { /* ok */ }
|
|
577
|
+
}
|
|
578
|
+
// ─── Windows Registry ───────────────────────────────────────────────────────
|
|
579
|
+
async function installInterceptorWindows(mitmdumpPath, target, secret) {
|
|
580
|
+
const processName = getProcessName();
|
|
581
|
+
const secretEnv = secret ? `set CC_ROUTER_SECRET=${secret} && ` : "";
|
|
582
|
+
const cmd = `cmd /c "set CC_ROUTER_TARGET=${target} && ${secretEnv}"${mitmdumpPath}" --mode local:${processName} -s "${ADDON_PATH}" --set connection_strategy=lazy --quiet"`;
|
|
583
|
+
try {
|
|
584
|
+
await execFileP("reg", [
|
|
585
|
+
"add", WINDOWS_REG_KEY,
|
|
586
|
+
"/v", WINDOWS_REG_NAME,
|
|
587
|
+
"/t", "REG_SZ",
|
|
588
|
+
"/d", cmd,
|
|
589
|
+
"/f",
|
|
590
|
+
]);
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
console.log(`⚠ Registry write failed: ${err.message}`);
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async function uninstallInterceptorWindows() {
|
|
599
|
+
try {
|
|
600
|
+
await execFileP("reg", [
|
|
601
|
+
"delete", WINDOWS_REG_KEY,
|
|
602
|
+
"/v", WINDOWS_REG_NAME,
|
|
603
|
+
"/f",
|
|
604
|
+
]);
|
|
605
|
+
}
|
|
606
|
+
catch { /* not installed */ }
|
|
607
|
+
}
|
|
608
|
+
function isInterceptorWindowsServiceInstalled() {
|
|
609
|
+
try {
|
|
610
|
+
execFileSync("reg", ["query", WINDOWS_REG_KEY, "/v", WINDOWS_REG_NAME]);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { parseModelRef } from "./model-ref.js";
|
|
2
|
+
function stringifySystem(system) {
|
|
3
|
+
if (system === undefined)
|
|
4
|
+
return undefined;
|
|
5
|
+
if (typeof system === "string")
|
|
6
|
+
return system;
|
|
7
|
+
return system.map(block => block.text).join("\n");
|
|
8
|
+
}
|
|
9
|
+
function contentToOpenAI(content) {
|
|
10
|
+
if (typeof content === "string")
|
|
11
|
+
return [{ type: "input_text", text: content }];
|
|
12
|
+
return content.map(block => {
|
|
13
|
+
if (block.type === "text")
|
|
14
|
+
return { type: "input_text", text: block.text };
|
|
15
|
+
if (block.type === "tool_use") {
|
|
16
|
+
return {
|
|
17
|
+
type: "function_call",
|
|
18
|
+
call_id: block.id,
|
|
19
|
+
name: block.name,
|
|
20
|
+
arguments: JSON.stringify(block.input ?? {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const output = typeof block.content === "string"
|
|
24
|
+
? block.content
|
|
25
|
+
: block.content.map(item => item.text).join("\n");
|
|
26
|
+
return {
|
|
27
|
+
type: "function_call_output",
|
|
28
|
+
call_id: block.tool_use_id,
|
|
29
|
+
output,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function anthropicToOpenAIResponses(req, modelRouting = {}) {
|
|
34
|
+
const parsed = parseModelRef(req.model, modelRouting);
|
|
35
|
+
return {
|
|
36
|
+
model: parsed.upstreamModel,
|
|
37
|
+
instructions: stringifySystem(req.system),
|
|
38
|
+
input: req.messages.map(message => ({
|
|
39
|
+
role: message.role,
|
|
40
|
+
content: contentToOpenAI(message.content),
|
|
41
|
+
})),
|
|
42
|
+
tools: req.tools?.map(tool => ({
|
|
43
|
+
type: "function",
|
|
44
|
+
name: tool.name,
|
|
45
|
+
description: tool.description,
|
|
46
|
+
parameters: tool.input_schema,
|
|
47
|
+
})),
|
|
48
|
+
max_output_tokens: req.max_tokens,
|
|
49
|
+
stream: req.stream,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|