responses-proxy 0.1.2 → 0.2.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/cli.js CHANGED
@@ -1,76 +1,307 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * responses-proxy CLI — starts the proxy server and opens the dashboard.
3
+ * responses-proxy CLI — full command interface for managing the proxy.
4
4
  *
5
5
  * Usage:
6
- * npx responses-proxy
7
- * responses-proxy --port 8318
8
- * responses-proxy --no-browser
6
+ * responses-proxy # Start foreground, open browser
7
+ * responses-proxy --background # Start as background daemon
8
+ * responses-proxy stop # Stop daemon
9
+ * responses-proxy status # Check running state
10
+ * responses-proxy restart # Restart daemon
11
+ * responses-proxy logs # Tail server logs
12
+ * responses-proxy config # Show current config
13
+ * responses-proxy config set KEY VAL # Set env config
14
+ * responses-proxy setup # Interactive first-time setup
15
+ * responses-proxy password <pass> # Set dashboard password
16
+ * responses-proxy info # Show connection details (endpoint + key)
9
17
  */
10
18
 
11
- const { spawn, exec } = require("child_process");
19
+ const { spawn, exec, execSync } = require("child_process");
12
20
  const path = require("path");
13
21
  const fs = require("fs");
14
22
  const os = require("os");
23
+ const readline = require("readline");
15
24
 
16
25
  const pkg = require("./package.json");
17
26
  const args = process.argv.slice(2);
18
27
 
19
- // Defaults
28
+ // ─── Paths ───────────────────────────────────────────────────────────────────
29
+
30
+ const dataDir = path.join(os.homedir(), ".responses-proxy");
31
+ fs.mkdirSync(dataDir, { recursive: true });
32
+ const PID_FILE = path.join(dataDir, "server.pid");
33
+ const LOG_FILE = path.join(dataDir, "server.log");
34
+ const ENV_FILE = path.join(dataDir, "config.env");
35
+ const serverPath = path.join(__dirname, "dist", "server.js");
36
+ const nodeModulesPath = path.join(__dirname, "dist", "node_modules");
37
+
38
+ // ─── Config ──────────────────────────────────────────────────────────────────
39
+
40
+ function loadEnvConfig() {
41
+ if (!fs.existsSync(ENV_FILE)) return {};
42
+ const lines = fs.readFileSync(ENV_FILE, "utf8").split("\n");
43
+ const env = {};
44
+ for (const line of lines) {
45
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
46
+ if (match) env[match[1]] = match[2];
47
+ }
48
+ return env;
49
+ }
50
+
51
+ function saveEnvConfig(env) {
52
+ const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
53
+ fs.writeFileSync(ENV_FILE, lines.join("\n") + "\n");
54
+ }
55
+
56
+ function getConfig(key) {
57
+ const env = loadEnvConfig();
58
+ return env[key] || process.env[key] || "";
59
+ }
60
+
61
+ function setConfig(key, value) {
62
+ const env = loadEnvConfig();
63
+ env[key] = value;
64
+ saveEnvConfig(env);
65
+ }
66
+
67
+ // ─── Parse args ──────────────────────────────────────────────────────────────
68
+
20
69
  const DEFAULT_PORT = 8318;
21
- let port = DEFAULT_PORT;
22
- let host = "0.0.0.0";
70
+ let port = parseInt(getConfig("PORT")) || DEFAULT_PORT;
71
+ let host = getConfig("HOST") || "0.0.0.0";
23
72
  let noBrowser = false;
73
+ let background = false;
74
+ let subcommand = null;
75
+ let subArgs = [];
24
76
 
25
- // Parse args
26
77
  for (let i = 0; i < args.length; i++) {
27
- if (args[i] === "--port" || args[i] === "-p") {
28
- port = parseInt(args[i + 1], 10) || DEFAULT_PORT;
29
- i++;
30
- } else if (args[i] === "--host" || args[i] === "-H") {
31
- host = args[i + 1] || "0.0.0.0";
32
- i++;
33
- } else if (args[i] === "--no-browser" || args[i] === "-n") {
34
- noBrowser = true;
35
- } else if (args[i] === "--help" || args[i] === "-h") {
36
- console.log(`
37
- responses-proxy v${pkg.version}
38
-
39
- Usage: responses-proxy [options]
78
+ const arg = args[i];
79
+ if (arg === "--port" || arg === "-p") { port = parseInt(args[++i], 10) || DEFAULT_PORT; }
80
+ else if (arg === "--host" || arg === "-H") { host = args[++i] || "0.0.0.0"; }
81
+ else if (arg === "--no-browser" || arg === "-n") { noBrowser = true; }
82
+ else if (arg === "--background" || arg === "--bg" || arg === "-b" || arg === "--daemon") { background = true; }
83
+ else if (arg === "--help" || arg === "-h") { printHelp(); process.exit(0); }
84
+ else if (arg === "--version" || arg === "-v") { console.log(pkg.version); process.exit(0); }
85
+ else if (!arg.startsWith("-")) {
86
+ subcommand = arg;
87
+ subArgs = args.slice(i + 1);
88
+ break;
89
+ }
90
+ }
91
+
92
+ function printHelp() {
93
+ console.log(`
94
+ responses-proxy v${pkg.version} — AI Routing Proxy
95
+
96
+ Usage: responses-proxy [command] [options]
97
+
98
+ Commands:
99
+ (none) Start the server (foreground)
100
+ stop Stop the background daemon
101
+ status Check if running
102
+ restart Restart background daemon
103
+ logs Show recent server logs
104
+ info Show endpoint URL + API key
105
+ config Show all config
106
+ config set KEY VALUE Set a config value
107
+ config get KEY Get a config value
108
+ setup Interactive first-time setup wizard
109
+ password <pass> Set dashboard admin password
110
+ open Open dashboard in browser
40
111
 
41
112
  Options:
42
- -p, --port <port> Port to run the server (default: ${DEFAULT_PORT})
43
- -H, --host <host> Host to bind (default: 0.0.0.0)
44
- -n, --no-browser Don't open browser automatically
45
- -h, --help Show this help message
46
- -v, --version Show version
113
+ -p, --port <port> Port (default: ${DEFAULT_PORT})
114
+ -H, --host <host> Host (default: 0.0.0.0)
115
+ -n, --no-browser Don't auto-open browser
116
+ -b, --background Run as background daemon
117
+ -h, --help Show help
118
+ -v, --version Show version
119
+
120
+ Data directory: ${dataDir}
121
+ Config file: ${ENV_FILE}
47
122
  `);
48
- process.exit(0);
49
- } else if (args[i] === "--version" || args[i] === "-v") {
50
- console.log(pkg.version);
51
- process.exit(0);
123
+ }
124
+
125
+ // ─── Subcommand dispatch ─────────────────────────────────────────────────────
126
+
127
+ if (subcommand === "stop") { stopDaemon(); process.exit(0); }
128
+ if (subcommand === "status") { checkStatus(); process.exit(0); }
129
+ if (subcommand === "restart") { stopDaemon(); background = true; }
130
+ if (subcommand === "logs") { showLogs(); process.exit(0); }
131
+ if (subcommand === "info") { showInfo(); process.exit(0); }
132
+ if (subcommand === "open") { openDashboard(); process.exit(0); }
133
+ if (subcommand === "config") { handleConfig(); process.exit(0); }
134
+ if (subcommand === "password") { handlePassword(); process.exit(0); }
135
+ if (subcommand === "setup") { runSetup().then(() => process.exit(0)); }
136
+ else if (subcommand && !["restart"].includes(subcommand)) {
137
+ console.error(`Unknown command: ${subcommand}\nRun 'responses-proxy --help' for usage.`);
138
+ process.exit(1);
139
+ }
140
+
141
+ // ─── Subcommand implementations ──────────────────────────────────────────────
142
+
143
+ function getDaemonPid() {
144
+ try {
145
+ if (!fs.existsSync(PID_FILE)) return null;
146
+ const pid = parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
147
+ if (!pid) return null;
148
+ process.kill(pid, 0);
149
+ return pid;
150
+ } catch {
151
+ try { fs.unlinkSync(PID_FILE); } catch {}
152
+ return null;
52
153
  }
53
154
  }
54
155
 
55
- // Resolve paths
56
- const serverPath = path.join(__dirname, "dist", "server.js");
156
+ function stopDaemon() {
157
+ const pid = getDaemonPid();
158
+ if (!pid) { console.log("⏹ Not running."); return; }
159
+ try {
160
+ process.kill(pid, "SIGTERM");
161
+ console.log(`⏹ Stopped (PID: ${pid})`);
162
+ try { fs.unlinkSync(PID_FILE); } catch {}
163
+ } catch (e) { console.error(`Failed: ${e.message}`); }
164
+ }
165
+
166
+ function checkStatus() {
167
+ const pid = getDaemonPid();
168
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
169
+ if (pid) {
170
+ console.log(`✅ Running (PID: ${pid})`);
171
+ console.log(` Endpoint: http://${displayHost}:${port}/v1`);
172
+ console.log(` Dashboard: http://${displayHost}:${port}/`);
173
+ console.log(` Data: ${dataDir}`);
174
+ } else {
175
+ console.log("⏹ Not running.");
176
+ }
177
+ }
178
+
179
+ function showLogs() {
180
+ if (!fs.existsSync(LOG_FILE)) { console.log("No logs yet."); return; }
181
+ const lines = fs.readFileSync(LOG_FILE, "utf8").split("\n");
182
+ console.log(lines.slice(-80).join("\n"));
183
+ }
184
+
185
+ function showInfo() {
186
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
187
+ const password = getConfig("DASHBOARD_PASSWORD") || "admin";
188
+ console.log(`
189
+ ┌─────────────────────────────────────────┐
190
+ │ responses-proxy v${pkg.version.padEnd(25)}│
191
+ ├─────────────────────────────────────────┤
192
+ │ Endpoint: http://${displayHost}:${port}/v1${" ".repeat(Math.max(0, 18 - String(port).length))}│
193
+ │ Dashboard: http://${displayHost}:${port}/${" ".repeat(Math.max(0, 20 - String(port).length))}│
194
+ │ Password: ${password.padEnd(27)}│
195
+ ├─────────────────────────────────────────┤
196
+ │ For Claude Code: │
197
+ │ export ANTHROPIC_BASE_URL=http://${displayHost}:${port}/v1
198
+ │ export ANTHROPIC_API_KEY=sk_anything │
199
+ ├─────────────────────────────────────────┤
200
+ │ For Codex / OpenAI SDK: │
201
+ │ base_url = http://${displayHost}:${port}/v1${" ".repeat(Math.max(0, 18 - String(port).length))}│
202
+ │ api_key = sk_anything │
203
+ └─────────────────────────────────────────┘
204
+ `);
205
+ }
206
+
207
+ function openDashboard() {
208
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
209
+ const url = `http://${displayHost}:${port}`;
210
+ const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
211
+ exec(cmd, { windowsHide: true }, () => {});
212
+ console.log(`Opening ${url}`);
213
+ }
214
+
215
+ function handleConfig() {
216
+ if (subArgs[0] === "set" && subArgs[1]) {
217
+ setConfig(subArgs[1], subArgs.slice(2).join(" "));
218
+ console.log(`✅ ${subArgs[1]} = ${subArgs.slice(2).join(" ")}`);
219
+ } else if (subArgs[0] === "get" && subArgs[1]) {
220
+ console.log(getConfig(subArgs[1]) || "(not set)");
221
+ } else {
222
+ const env = loadEnvConfig();
223
+ console.log(`Config file: ${ENV_FILE}\n`);
224
+ if (Object.keys(env).length === 0) {
225
+ console.log(" (empty — using defaults)");
226
+ console.log(`\n Default port: ${DEFAULT_PORT}`);
227
+ console.log(` Default password: admin`);
228
+ console.log(`\n Set values with: responses-proxy config set KEY VALUE`);
229
+ } else {
230
+ for (const [k, v] of Object.entries(env)) {
231
+ console.log(` ${k} = ${v}`);
232
+ }
233
+ }
234
+ console.log(`\nAvailable keys:`);
235
+ console.log(` PORT Server port (default: 8318)`);
236
+ console.log(` HOST Bind host (default: 0.0.0.0)`);
237
+ console.log(` DASHBOARD_PASSWORD Admin password (default: admin)`);
238
+ console.log(` UPSTREAM_BASE_URL Default upstream provider URL`);
239
+ console.log(` UPSTREAM_API_KEY Default upstream API key`);
240
+ console.log(` KIRO_ENABLED Enable Kiro provider (true/false)`);
241
+ console.log(` KIRO_DB_PATH Path to 9router kiro.sqlite`);
242
+ }
243
+ }
244
+
245
+ function handlePassword() {
246
+ const pass = subArgs[0];
247
+ if (!pass) {
248
+ console.log(`Current password: ${getConfig("DASHBOARD_PASSWORD") || "admin"}`);
249
+ console.log(`\nUsage: responses-proxy password <new-password>`);
250
+ return;
251
+ }
252
+ setConfig("DASHBOARD_PASSWORD", pass);
253
+ console.log(`✅ Dashboard password set to: ${pass}`);
254
+ console.log(` Restart the server for changes to take effect.`);
255
+ }
256
+
257
+ async function runSetup() {
258
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
259
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
260
+
261
+ console.log(`\n🚀 responses-proxy Setup Wizard\n`);
262
+
263
+ const portAnswer = await ask(`Port [${port}]: `);
264
+ if (portAnswer.trim()) setConfig("PORT", portAnswer.trim());
265
+
266
+ const passAnswer = await ask(`Dashboard password [admin]: `);
267
+ if (passAnswer.trim()) setConfig("DASHBOARD_PASSWORD", passAnswer.trim());
268
+
269
+ const kiroAnswer = await ask(`Enable Kiro provider? (y/n) [y]: `);
270
+ if (kiroAnswer.trim().toLowerCase() === "n") setConfig("KIRO_ENABLED", "false");
271
+ else setConfig("KIRO_ENABLED", "true");
272
+
273
+ const upstreamAnswer = await ask(`Default upstream URL (leave empty to skip): `);
274
+ if (upstreamAnswer.trim()) setConfig("UPSTREAM_BASE_URL", upstreamAnswer.trim());
275
+
276
+ const keyAnswer = await ask(`Default upstream API key (leave empty to skip): `);
277
+ if (keyAnswer.trim()) setConfig("UPSTREAM_API_KEY", keyAnswer.trim());
278
+
279
+ rl.close();
280
+
281
+ console.log(`\n✅ Config saved to ${ENV_FILE}`);
282
+ console.log(`\nStart the server with:`);
283
+ console.log(` responses-proxy`);
284
+ console.log(` responses-proxy --background`);
285
+ console.log(`\nView connection info:`);
286
+ console.log(` responses-proxy info\n`);
287
+ }
288
+
289
+ // ─── If subcommand was handled above (setup is async), bail ──────────────────
290
+ if (subcommand === "setup") { /* runSetup handles exit */ return; }
291
+ if (subcommand && subcommand !== "restart") process.exit(0);
292
+
293
+ // ─── Ensure deps ─────────────────────────────────────────────────────────────
57
294
 
58
295
  if (!fs.existsSync(serverPath)) {
59
- console.error("Error: Built server not found at", serverPath);
60
- console.error("Run 'npm run build' first, or reinstall the package.");
296
+ console.error("Error: Built server not found. Reinstall the package.");
61
297
  process.exit(1);
62
298
  }
63
299
 
64
- // Check if deps are installed
65
- const nodeModulesPath = path.join(__dirname, "dist", "node_modules");
66
300
  if (!fs.existsSync(path.join(nodeModulesPath, "fastify"))) {
67
- console.log("📦 Runtime deps missing. Installing...");
68
- const { execSync } = require("child_process");
301
+ console.log("📦 Installing runtime dependencies...");
69
302
  try {
70
303
  execSync("npm install --omit=dev --no-audit --no-fund --loglevel=error", {
71
- cwd: path.join(__dirname, "dist"),
72
- stdio: "inherit",
73
- timeout: 120000,
304
+ cwd: path.join(__dirname, "dist"), stdio: "inherit", timeout: 120000,
74
305
  });
75
306
  } catch {
76
307
  console.error("Failed to install deps. Run: cd " + path.join(__dirname, "dist") + " && npm install --omit=dev");
@@ -78,69 +309,112 @@ if (!fs.existsSync(path.join(nodeModulesPath, "fastify"))) {
78
309
  }
79
310
  }
80
311
 
81
- // Data directory
82
- const dataDir = path.join(os.homedir(), ".responses-proxy");
312
+ // ─── Kill existing ───────────────────────────────────────────────────────────
313
+
314
+ const existingPid = getDaemonPid();
315
+ if (existingPid) {
316
+ try { process.kill(existingPid, "SIGTERM"); } catch {}
317
+ try { fs.unlinkSync(PID_FILE); } catch {}
318
+ try { execSync("sleep 1", { stdio: "ignore" }); } catch {}
319
+ }
320
+
321
+ // ─── Build env ───────────────────────────────────────────────────────────────
322
+
83
323
  fs.mkdirSync(path.join(dataDir, "sessions"), { recursive: true });
324
+ const userConfig = loadEnvConfig();
325
+
326
+ const serverEnv = {
327
+ ...process.env,
328
+ ...userConfig,
329
+ NODE_PATH: nodeModulesPath,
330
+ PORT: String(port),
331
+ HOST: host,
332
+ UPSTREAM_BASE_URL: userConfig.UPSTREAM_BASE_URL || process.env.UPSTREAM_BASE_URL || "https://placeholder.invalid",
333
+ UPSTREAM_API_KEY: userConfig.UPSTREAM_API_KEY || process.env.UPSTREAM_API_KEY || "",
334
+ APP_DB_PATH: path.join(dataDir, "app.sqlite"),
335
+ SESSION_LOG_DIR: path.join(dataDir, "sessions"),
336
+ CUSTOMER_KEY_DB_PATH: path.join(dataDir, "telegram-bot.sqlite"),
337
+ KIRO_DB_PATH: userConfig.KIRO_DB_PATH || path.join(dataDir, "kiro.sqlite"),
338
+ KIRO_ENABLED: userConfig.KIRO_ENABLED || process.env.KIRO_ENABLED || "true",
339
+ QUICK_APPLY_HERMES_CONFIG_PATH: path.join(os.homedir(), ".hermes", "config.yaml"),
340
+ QUICK_APPLY_CODEX_CONFIG_PATH: path.join(os.homedir(), ".codex", "config.toml"),
341
+ QUICK_APPLY_CODEX_AUTH_PATH: path.join(os.homedir(), ".codex", "auth.json"),
342
+ TELEGRAM_BOT_TOKEN: userConfig.TELEGRAM_BOT_TOKEN || "",
343
+ TELEGRAM_OWNER_USER_IDS: userConfig.TELEGRAM_OWNER_USER_IDS || "",
344
+ TELEGRAM_ADMIN_USER_IDS: userConfig.TELEGRAM_ADMIN_USER_IDS || "",
345
+ DASHBOARD_PASSWORD: userConfig.DASHBOARD_PASSWORD || process.env.DASHBOARD_PASSWORD || "admin",
346
+ };
84
347
 
85
348
  const displayHost = host === "0.0.0.0" ? "localhost" : host;
86
349
  const url = `http://${displayHost}:${port}`;
87
350
 
88
- console.log(`
351
+ // ─── Background mode ─────────────────────────────────────────────────────────
352
+
353
+ if (background) {
354
+ const logFd = fs.openSync(LOG_FILE, "a");
355
+ const child = spawn(process.execPath, [serverPath], {
356
+ cwd: __dirname, env: serverEnv,
357
+ stdio: ["ignore", logFd, logFd],
358
+ detached: true,
359
+ });
360
+ child.unref();
361
+ fs.writeFileSync(PID_FILE, String(child.pid));
362
+ fs.closeSync(logFd);
363
+
364
+ console.log(`
89
365
  ┌─────────────────────────────────────────┐
90
366
  │ responses-proxy v${pkg.version.padEnd(25)}│
367
+ │ Running in background (PID: ${String(child.pid).padEnd(10)}│
91
368
  │ ${url.padEnd(39)}│
92
369
  │ Dashboard: ${(url + "/").padEnd(27)}│
93
370
  │ API: ${(url + "/v1").padEnd(33)}│
371
+ ├─────────────────────────────────────────┤
372
+ │ Stop: responses-proxy stop │
373
+ │ Status: responses-proxy status │
374
+ │ Logs: responses-proxy logs │
375
+ │ Config: responses-proxy config │
376
+ │ Info: responses-proxy info │
94
377
  └─────────────────────────────────────────┘
95
378
  `);
96
379
 
97
- // Spawn server
98
- const server = spawn(process.execPath, [serverPath], {
99
- cwd: __dirname,
100
- stdio: "inherit",
101
- env: {
102
- ...process.env,
103
- NODE_PATH: nodeModulesPath,
104
- PORT: String(port),
105
- HOST: host,
106
- UPSTREAM_BASE_URL: process.env.UPSTREAM_BASE_URL || "https://placeholder.invalid",
107
- UPSTREAM_API_KEY: process.env.UPSTREAM_API_KEY || "",
108
- APP_DB_PATH: path.join(dataDir, "app.sqlite"),
109
- SESSION_LOG_DIR: path.join(dataDir, "sessions"),
110
- CUSTOMER_KEY_DB_PATH: path.join(dataDir, "telegram-bot.sqlite"),
111
- KIRO_DB_PATH: path.join(dataDir, "kiro.sqlite"),
112
- KIRO_ENABLED: process.env.KIRO_ENABLED || "false",
113
- QUICK_APPLY_HERMES_CONFIG_PATH: path.join(os.homedir(), ".hermes", "config.yaml"),
114
- QUICK_APPLY_CODEX_CONFIG_PATH: path.join(os.homedir(), ".codex", "config.toml"),
115
- QUICK_APPLY_CODEX_AUTH_PATH: path.join(os.homedir(), ".codex", "auth.json"),
116
- TELEGRAM_BOT_TOKEN: "",
117
- TELEGRAM_OWNER_USER_IDS: "",
118
- TELEGRAM_ADMIN_USER_IDS: "",
119
- DASHBOARD_PASSWORD: process.env.DASHBOARD_PASSWORD || "admin",
120
- },
121
- });
122
-
123
- // Open browser after short delay
124
- if (!noBrowser) {
125
- setTimeout(() => {
126
- const openCmd =
127
- process.platform === "darwin" ? `open "${url}"` :
128
- process.platform === "win32" ? `start "" "${url}"` :
129
- `xdg-open "${url}"`;
130
- exec(openCmd, { windowsHide: true }, () => {});
131
- }, 2000);
132
- }
133
-
134
- // Handle exit
135
- function cleanup() {
136
- if (server.pid) {
137
- try { process.kill(server.pid, "SIGTERM"); } catch {}
380
+ if (!noBrowser) {
381
+ setTimeout(() => {
382
+ const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
383
+ exec(cmd, { windowsHide: true }, () => {});
384
+ }, 2000);
138
385
  }
139
- }
386
+ setTimeout(() => process.exit(0), 2500);
387
+
388
+ } else {
389
+ // ─── Foreground mode ───────────────────────────────────────────────────────
390
+
391
+ console.log(`
392
+ ┌─────────────────────────────────────────┐
393
+ │ responses-proxy v${pkg.version.padEnd(25)}│
394
+ │ ${url.padEnd(39)}│
395
+ │ Dashboard: ${(url + "/").padEnd(27)}│
396
+ │ API: ${(url + "/v1").padEnd(33)}│
397
+ │ Press Ctrl+C to stop │
398
+ └─────────────────────────────────────────┘
399
+ `);
400
+
401
+ const server = spawn(process.execPath, [serverPath], {
402
+ cwd: __dirname, stdio: "inherit", env: serverEnv,
403
+ });
404
+ fs.writeFileSync(PID_FILE, String(server.pid));
140
405
 
141
- process.on("SIGINT", () => { cleanup(); process.exit(0); });
142
- process.on("SIGTERM", () => { cleanup(); process.exit(0); });
406
+ if (!noBrowser) {
407
+ setTimeout(() => {
408
+ const cmd = process.platform === "darwin" ? `open "${url}"` : process.platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
409
+ exec(cmd, { windowsHide: true }, () => {});
410
+ }, 2000);
411
+ }
143
412
 
144
- server.on("close", (code) => {
145
- process.exit(code || 0);
146
- });
413
+ function cleanup() {
414
+ try { fs.unlinkSync(PID_FILE); } catch {}
415
+ if (server.pid) { try { process.kill(server.pid, "SIGTERM"); } catch {} }
416
+ }
417
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
418
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });
419
+ server.on("close", (code) => { try { fs.unlinkSync(PID_FILE); } catch {} process.exit(code || 0); });
420
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * MITM Certificate Management — generates root CA + per-domain leaf certs.
3
+ * Uses node-forge for certificate generation (no native deps).
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import path from "node:path";
8
+ import { randomBytes } from "node:crypto";
9
+ const MITM_DIR = path.join(process.env.HOME || "", ".responses-proxy", "mitm");
10
+ const ROOT_CA_KEY = path.join(MITM_DIR, "rootCA.key");
11
+ const ROOT_CA_CERT = path.join(MITM_DIR, "rootCA.crt");
12
+ export function getMitmDir() {
13
+ mkdirSync(MITM_DIR, { recursive: true });
14
+ return MITM_DIR;
15
+ }
16
+ export function rootCaExists() {
17
+ return existsSync(ROOT_CA_KEY) && existsSync(ROOT_CA_CERT);
18
+ }
19
+ export function isRootCaTrusted() {
20
+ if (process.platform !== "darwin")
21
+ return false;
22
+ try {
23
+ const result = execSync(`security verify-cert -c "${ROOT_CA_CERT}" 2>&1`, { encoding: "utf8", timeout: 5000 });
24
+ return result.includes("successful") || !result.includes("CSSMERR");
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Generate a self-signed Root CA using openssl CLI (available on macOS/Linux).
32
+ * Outputs rootCA.key + rootCA.crt to MITM_DIR.
33
+ */
34
+ export function generateRootCA() {
35
+ mkdirSync(MITM_DIR, { recursive: true });
36
+ if (rootCaExists()) {
37
+ return { keyPath: ROOT_CA_KEY, certPath: ROOT_CA_CERT };
38
+ }
39
+ // Generate RSA 2048 key
40
+ execSync(`openssl genrsa -out "${ROOT_CA_KEY}" 2048`, { stdio: "ignore", timeout: 10000 });
41
+ // Generate self-signed CA cert (valid 10 years)
42
+ execSync(`openssl req -x509 -new -nodes -key "${ROOT_CA_KEY}" -sha256 -days 3650 ` +
43
+ `-subj "/CN=responses-proxy MITM CA/O=responses-proxy/C=US" ` +
44
+ `-out "${ROOT_CA_CERT}"`, { stdio: "ignore", timeout: 10000 });
45
+ return { keyPath: ROOT_CA_KEY, certPath: ROOT_CA_CERT };
46
+ }
47
+ /**
48
+ * Trust the root CA in the macOS system keychain (requires sudo).
49
+ */
50
+ export async function trustRootCA(sudoPassword) {
51
+ if (process.platform !== "darwin") {
52
+ throw new Error("Trust cert is only supported on macOS");
53
+ }
54
+ if (!rootCaExists()) {
55
+ throw new Error("Root CA does not exist. Generate it first.");
56
+ }
57
+ const cmd = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${ROOT_CA_CERT}"`;
58
+ if (sudoPassword) {
59
+ const { execWithPassword } = await import("./dns.js");
60
+ await execWithPassword(cmd, sudoPassword);
61
+ }
62
+ else {
63
+ execSync(`sudo ${cmd}`, { stdio: "ignore", timeout: 15000 });
64
+ }
65
+ }
66
+ /**
67
+ * Generate a leaf certificate for a specific domain, signed by the root CA.
68
+ * Returns { key, cert } as PEM strings.
69
+ */
70
+ export function generateLeafCert(domain) {
71
+ if (!rootCaExists())
72
+ return null;
73
+ const tmpDir = path.join(MITM_DIR, "tmp");
74
+ mkdirSync(tmpDir, { recursive: true });
75
+ const serial = randomBytes(8).toString("hex");
76
+ const leafKey = path.join(tmpDir, `${serial}.key`);
77
+ const leafCsr = path.join(tmpDir, `${serial}.csr`);
78
+ const leafCert = path.join(tmpDir, `${serial}.crt`);
79
+ const extFile = path.join(tmpDir, `${serial}.ext`);
80
+ try {
81
+ // Generate leaf key
82
+ execSync(`openssl genrsa -out "${leafKey}" 2048`, { stdio: "ignore", timeout: 5000 });
83
+ // Generate CSR
84
+ execSync(`openssl req -new -key "${leafKey}" -subj "/CN=${domain}" -out "${leafCsr}"`, { stdio: "ignore", timeout: 5000 });
85
+ // Write SAN extension file
86
+ writeFileSync(extFile, `authorityKeyIdentifier=keyid,issuer\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,nonRepudiation,keyEncipherment,dataEncipherment\nsubjectAltName=DNS:${domain}\n`);
87
+ // Sign with root CA
88
+ execSync(`openssl x509 -req -in "${leafCsr}" -CA "${ROOT_CA_CERT}" -CAkey "${ROOT_CA_KEY}" ` +
89
+ `-CAcreateserial -out "${leafCert}" -days 825 -sha256 -extfile "${extFile}"`, { stdio: "ignore", timeout: 5000 });
90
+ const key = readFileSync(leafKey, "utf8");
91
+ const cert = readFileSync(leafCert, "utf8");
92
+ // Cleanup temp files
93
+ try {
94
+ for (const f of [leafKey, leafCsr, leafCert, extFile]) {
95
+ if (existsSync(f))
96
+ require("node:fs").unlinkSync(f);
97
+ }
98
+ }
99
+ catch { /* best effort */ }
100
+ return { key, cert };
101
+ }
102
+ catch (error) {
103
+ return null;
104
+ }
105
+ }
106
+ export function loadRootCA() {
107
+ return {
108
+ key: readFileSync(ROOT_CA_KEY, "utf8"),
109
+ cert: readFileSync(ROOT_CA_CERT, "utf8"),
110
+ };
111
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * MITM DNS Management — manages /etc/hosts entries to redirect tool domains to localhost.
3
+ */
4
+ import { execSync, spawn } from "node:child_process";
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ const IS_WIN = process.platform === "win32";
7
+ const IS_MAC = process.platform === "darwin";
8
+ const HOSTS_FILE = IS_WIN
9
+ ? `${process.env.SystemRoot || "C:\\Windows"}\\System32\\drivers\\etc\\hosts`
10
+ : "/etc/hosts";
11
+ // Tool domains to intercept
12
+ export const TOOL_HOSTS = {
13
+ antigravity: ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"],
14
+ copilot: ["api.individual.githubcopilot.com"],
15
+ kiro: ["q.us-east-1.amazonaws.com"],
16
+ };
17
+ export function isSudoAvailable() {
18
+ if (IS_WIN)
19
+ return false;
20
+ try {
21
+ execSync("command -v sudo", { stdio: "ignore", timeout: 3000 });
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ export function canRunSudoWithoutPassword() {
29
+ if (IS_WIN || !isSudoAvailable())
30
+ return true;
31
+ try {
32
+ execSync("sudo -n true", { stdio: "ignore", timeout: 3000 });
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ export function isSudoPasswordRequired() {
40
+ return !IS_WIN && isSudoAvailable() && !canRunSudoWithoutPassword();
41
+ }
42
+ export function execWithPassword(command, password) {
43
+ return new Promise((resolve, reject) => {
44
+ const useSudo = isSudoAvailable();
45
+ const child = useSudo
46
+ ? spawn("sudo", ["-S", "sh", "-c", command], { stdio: ["pipe", "pipe", "pipe"] })
47
+ : spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"] });
48
+ let stdout = "";
49
+ let stderr = "";
50
+ child.stdout?.on("data", (d) => { stdout += d; });
51
+ child.stderr?.on("data", (d) => { stderr += d; });
52
+ child.on("close", (code) => {
53
+ if (code === 0)
54
+ resolve(stdout);
55
+ else
56
+ reject(new Error(stderr || `Exit code ${code}`));
57
+ });
58
+ if (useSudo && child.stdin) {
59
+ child.stdin.write(`${password}\n`);
60
+ child.stdin.end();
61
+ }
62
+ });
63
+ }
64
+ function checkDNSEntry(host) {
65
+ try {
66
+ const content = readFileSync(HOSTS_FILE, "utf8");
67
+ return content.includes(host);
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ export function checkAllDNSStatus() {
74
+ try {
75
+ const content = readFileSync(HOSTS_FILE, "utf8");
76
+ const result = {};
77
+ for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) {
78
+ result[tool] = hosts.every((h) => content.includes(h));
79
+ }
80
+ return result;
81
+ }
82
+ catch {
83
+ return Object.fromEntries(Object.keys(TOOL_HOSTS).map((t) => [t, false]));
84
+ }
85
+ }
86
+ async function flushDNS(sudoPassword) {
87
+ if (IS_WIN)
88
+ return;
89
+ if (IS_MAC) {
90
+ await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword);
91
+ }
92
+ else {
93
+ await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword);
94
+ }
95
+ }
96
+ export async function addDNSEntry(tool, sudoPassword) {
97
+ const hosts = TOOL_HOSTS[tool];
98
+ if (!hosts)
99
+ throw new Error(`Unknown tool: ${tool}`);
100
+ const entriesToAdd = hosts.filter((h) => !checkDNSEntry(h));
101
+ if (entriesToAdd.length === 0)
102
+ return;
103
+ const current = readFileSync(HOSTS_FILE, "utf8");
104
+ const trimmed = current.replace(/[\r\n\s]+$/g, "");
105
+ const toAppend = entriesToAdd.map((h) => `127.0.0.1 ${h}`).join("\n");
106
+ const next = `${trimmed}\n${toAppend}\n`;
107
+ const escaped = next.replace(/'/g, "'\\''");
108
+ await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
109
+ await flushDNS(sudoPassword);
110
+ }
111
+ export async function removeDNSEntry(tool, sudoPassword) {
112
+ const hosts = TOOL_HOSTS[tool];
113
+ if (!hosts)
114
+ throw new Error(`Unknown tool: ${tool}`);
115
+ const entriesToRemove = hosts.filter((h) => checkDNSEntry(h));
116
+ if (entriesToRemove.length === 0)
117
+ return;
118
+ const current = readFileSync(HOSTS_FILE, "utf8");
119
+ const filtered = current
120
+ .split(/\r?\n/)
121
+ .filter((l) => !entriesToRemove.some((h) => l.includes(h)))
122
+ .join("\n");
123
+ const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n";
124
+ const escaped = next.replace(/'/g, "'\\''");
125
+ await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
126
+ await flushDNS(sudoPassword);
127
+ }
128
+ export function removeAllDNSEntriesSync() {
129
+ try {
130
+ if (!existsSync(HOSTS_FILE))
131
+ return;
132
+ const allHosts = Object.values(TOOL_HOSTS).flat();
133
+ const content = readFileSync(HOSTS_FILE, "utf8");
134
+ const filtered = content
135
+ .split(/\r?\n/)
136
+ .filter((l) => !allHosts.some((h) => l.includes(h)))
137
+ .join("\n");
138
+ const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n";
139
+ if (next === content)
140
+ return;
141
+ writeFileSync(HOSTS_FILE, next, "utf8");
142
+ if (IS_MAC) {
143
+ try {
144
+ execSync("dscacheutil -flushcache && killall -HUP mDNSResponder", { stdio: "ignore" });
145
+ }
146
+ catch { /* ignore */ }
147
+ }
148
+ }
149
+ catch { /* best effort during shutdown */ }
150
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * MITM Proxy Server — intercepts HTTPS on port 443 with dynamic SSL certs.
3
+ *
4
+ * Cloned from 9Router's src/mitm/server.js approach:
5
+ * - HTTPS server on :443 with SNI callback for per-domain certs
6
+ * - Intercepts requests to tool domains (Antigravity, Copilot, Kiro)
7
+ * - Rewrites model in request body to mapped model from proxy
8
+ * - Forwards to real upstream via HTTPS with custom DNS (bypass /etc/hosts loop)
9
+ */
10
+ import * as https from "node:https";
11
+ import * as tls from "node:tls";
12
+ import * as dns from "node:dns";
13
+ import { promisify } from "node:util";
14
+ import { readFileSync, existsSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import { execSync } from "node:child_process";
17
+ import { generateLeafCert, loadRootCA, rootCaExists, getMitmDir } from "./cert.js";
18
+ import { TOOL_HOSTS, removeAllDNSEntriesSync } from "./dns.js";
19
+ const LOCAL_PORT = 443;
20
+ const MITM_DIR = getMitmDir();
21
+ const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
22
+ // Tool detection from host header
23
+ const HOST_TO_TOOL = {};
24
+ for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) {
25
+ for (const h of hosts)
26
+ HOST_TO_TOOL[h] = tool;
27
+ }
28
+ // Proxy base URL — requests are forwarded to responses-proxy for model routing
29
+ const ROUTER_BASE_URL = process.env.MITM_ROUTER_BASE_URL || "http://localhost:8318";
30
+ // Internal header to prevent loop
31
+ const INTERNAL_HEADER = "x-request-source";
32
+ const INTERNAL_VALUE = "mitm-local";
33
+ // ─── SSL / SNI ───────────────────────────────────────────────────────────────
34
+ const certCache = new Map();
35
+ let rootCAPem;
36
+ function sniCallback(servername, cb) {
37
+ try {
38
+ if (certCache.has(servername))
39
+ return cb(null, certCache.get(servername));
40
+ const certData = generateLeafCert(servername);
41
+ if (!certData)
42
+ return cb(new Error(`Failed to generate cert for ${servername}`));
43
+ const ctx = tls.createSecureContext({
44
+ key: certData.key,
45
+ cert: `${certData.cert}\n${rootCAPem}`,
46
+ });
47
+ certCache.set(servername, ctx);
48
+ cb(null, ctx);
49
+ }
50
+ catch (e) {
51
+ cb(e);
52
+ }
53
+ }
54
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
55
+ const resolve4 = promisify((() => {
56
+ const resolver = new dns.Resolver();
57
+ resolver.setServers(["8.8.8.8", "1.1.1.1"]);
58
+ return resolver.resolve4.bind(resolver);
59
+ })());
60
+ const ipCache = {};
61
+ async function resolveTargetIP(hostname) {
62
+ const cached = ipCache[hostname];
63
+ if (cached && Date.now() - cached.ts < 5 * 60 * 1000)
64
+ return cached.ip;
65
+ const addresses = await resolve4(hostname);
66
+ ipCache[hostname] = { ip: addresses[0], ts: Date.now() };
67
+ return addresses[0];
68
+ }
69
+ function collectBody(req) {
70
+ return new Promise((resolve, reject) => {
71
+ const chunks = [];
72
+ req.on("data", (chunk) => chunks.push(chunk));
73
+ req.on("end", () => resolve(Buffer.concat(chunks)));
74
+ req.on("error", reject);
75
+ });
76
+ }
77
+ // ─── Request handler ─────────────────────────────────────────────────────────
78
+ async function handleRequest(req, res) {
79
+ try {
80
+ // Health check
81
+ if (req.url === "/_mitm_health") {
82
+ res.writeHead(200, { "Content-Type": "application/json" });
83
+ res.end(JSON.stringify({ ok: true, pid: process.pid }));
84
+ return;
85
+ }
86
+ const bodyBuffer = await collectBody(req);
87
+ // Anti-loop: skip our own requests
88
+ if (req.headers[INTERNAL_HEADER] === INTERNAL_VALUE) {
89
+ return passthrough(req, res, bodyBuffer);
90
+ }
91
+ const host = (req.headers.host || "").split(":")[0];
92
+ const tool = HOST_TO_TOOL[host];
93
+ // Not a tool domain — passthrough
94
+ if (!tool) {
95
+ return passthrough(req, res, bodyBuffer);
96
+ }
97
+ // Forward to our proxy router instead of upstream
98
+ // This lets the proxy handle model routing, RTK, combos, etc.
99
+ return forwardToRouter(req, res, bodyBuffer, tool);
100
+ }
101
+ catch (e) {
102
+ if (!res.headersSent)
103
+ res.writeHead(500, { "Content-Type": "application/json" });
104
+ res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } }));
105
+ }
106
+ }
107
+ /**
108
+ * Forward intercepted request to the local responses-proxy router.
109
+ * The proxy will handle model selection, provider routing, RTK, etc.
110
+ */
111
+ function forwardToRouter(req, res, bodyBuffer, tool) {
112
+ const url = new URL(ROUTER_BASE_URL);
113
+ const http = require("node:http");
114
+ const proxyReq = http.request({
115
+ hostname: url.hostname,
116
+ port: url.port || 8318,
117
+ path: "/v1/chat/completions",
118
+ method: "POST",
119
+ headers: {
120
+ "content-type": "application/json",
121
+ "content-length": String(bodyBuffer.length),
122
+ [INTERNAL_HEADER]: INTERNAL_VALUE,
123
+ "x-mitm-tool": tool,
124
+ "x-mitm-original-host": req.headers.host || "",
125
+ "x-mitm-original-path": req.url || "",
126
+ },
127
+ }, (proxyRes) => {
128
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
129
+ proxyRes.pipe(res);
130
+ });
131
+ proxyReq.on("error", (e) => {
132
+ // If proxy is unreachable, passthrough to real upstream
133
+ passthrough(req, res, bodyBuffer);
134
+ });
135
+ proxyReq.write(bodyBuffer);
136
+ proxyReq.end();
137
+ }
138
+ /**
139
+ * Forward request to the real upstream (bypass /etc/hosts via custom DNS).
140
+ */
141
+ async function passthrough(req, res, bodyBuffer) {
142
+ const originalHost = (req.headers.host || "").split(":")[0];
143
+ const targetIP = await resolveTargetIP(originalHost);
144
+ const forwardReq = https.request({
145
+ hostname: targetIP,
146
+ port: 443,
147
+ path: req.url,
148
+ method: req.method,
149
+ headers: { ...req.headers, host: originalHost },
150
+ servername: originalHost,
151
+ rejectUnauthorized: false,
152
+ }, (forwardRes) => {
153
+ res.writeHead(forwardRes.statusCode || 502, forwardRes.headers);
154
+ forwardRes.pipe(res);
155
+ });
156
+ forwardReq.on("error", (e) => {
157
+ if (!res.headersSent)
158
+ res.writeHead(502);
159
+ res.end("Bad Gateway");
160
+ });
161
+ if (bodyBuffer.length > 0)
162
+ forwardReq.write(bodyBuffer);
163
+ forwardReq.end();
164
+ }
165
+ // ─── Server startup ──────────────────────────────────────────────────────────
166
+ export function startMitmServer() {
167
+ if (!rootCaExists()) {
168
+ throw new Error("Root CA not found. Generate it first via the dashboard.");
169
+ }
170
+ const rootCA = loadRootCA();
171
+ rootCAPem = rootCA.cert;
172
+ const sslOptions = {
173
+ key: rootCA.key,
174
+ cert: rootCA.cert,
175
+ SNICallback: sniCallback,
176
+ };
177
+ // Kill existing process on port 443
178
+ try {
179
+ const pids = execSync(`lsof -nP -iTCP:${LOCAL_PORT} -sTCP:LISTEN -t`, { encoding: "utf8", timeout: 3000 }).trim();
180
+ if (pids) {
181
+ pids.split("\n").forEach((pid) => {
182
+ try {
183
+ process.kill(Number(pid), "SIGKILL");
184
+ }
185
+ catch { /* ignore */ }
186
+ });
187
+ }
188
+ }
189
+ catch { /* port is free */ }
190
+ const server = https.createServer(sslOptions, handleRequest);
191
+ server.listen(LOCAL_PORT, () => {
192
+ console.log(`[mitm] 🚀 MITM server running on :${LOCAL_PORT}`);
193
+ writeFileSync(PID_FILE, String(process.pid));
194
+ });
195
+ server.on("error", (e) => {
196
+ if (e.code === "EADDRINUSE") {
197
+ throw new Error(`Port ${LOCAL_PORT} already in use`);
198
+ }
199
+ if (e.code === "EACCES") {
200
+ throw new Error(`Permission denied for port ${LOCAL_PORT}. Run with sudo.`);
201
+ }
202
+ throw e;
203
+ });
204
+ // Graceful shutdown
205
+ const shutdown = () => {
206
+ removeAllDNSEntriesSync();
207
+ server.close(() => process.exit(0));
208
+ setTimeout(() => process.exit(0), 1500);
209
+ };
210
+ process.on("SIGTERM", shutdown);
211
+ process.on("SIGINT", shutdown);
212
+ return { pid: process.pid };
213
+ }
214
+ export function getMitmPid() {
215
+ try {
216
+ if (!existsSync(PID_FILE))
217
+ return null;
218
+ const pid = Number(readFileSync(PID_FILE, "utf8").trim());
219
+ // Check if process is alive
220
+ process.kill(pid, 0);
221
+ return pid;
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ }
227
+ export function isMitmRunning() {
228
+ return getMitmPid() !== null;
229
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * MITM Server entry point — spawned with sudo by the main proxy.
3
+ * Runs the HTTPS interception server on port 443.
4
+ */
5
+ import { startMitmServer } from "./server.js";
6
+ try {
7
+ const { pid } = startMitmServer();
8
+ console.log(`[mitm] Server started (PID: ${pid})`);
9
+ }
10
+ catch (error) {
11
+ console.error(`[mitm] Failed to start:`, error instanceof Error ? error.message : error);
12
+ process.exit(1);
13
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "responses-proxy-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
package/dist/server.js CHANGED
@@ -95,6 +95,20 @@ const deviceLoginService = new DeviceLoginService({
95
95
  kiroDbPath: config.KIRO_DB_PATH,
96
96
  });
97
97
  setInterval(() => deviceLoginService.pruneExpiredSessions(), 60_000).unref();
98
+ // Auto-disable Kiro provider if it has no accounts
99
+ if (kiroTokenStore) {
100
+ const kiroAccounts = kiroTokenStore.listAccounts();
101
+ if (kiroAccounts.length === 0) {
102
+ try {
103
+ const kiroProvider = providerRepository.getProvider("account-kiro");
104
+ if (kiroProvider && kiroProvider.enabled !== false) {
105
+ providerRepository.updateProvider("account-kiro", { ...kiroProvider, enabled: false });
106
+ console.log("[kiro] No accounts found — provider auto-disabled. Add accounts via dashboard to enable.");
107
+ }
108
+ }
109
+ catch { /* provider might not exist yet */ }
110
+ }
111
+ }
98
112
  // Initialize routing services after all stores are available
99
113
  const routingComboRepository = new RoutingComboRepository(providerRepository.getDatabase());
100
114
  const modelComboRepository = new ModelComboRepository(providerRepository.getDatabase());
@@ -1551,6 +1565,16 @@ app.post("/api/kiro/import", async (request, reply) => {
1551
1565
  destDbPath: destPath,
1552
1566
  provider: 'kiro',
1553
1567
  });
1568
+ // Auto-enable the Kiro provider after importing accounts
1569
+ if (result.imported > 0) {
1570
+ try {
1571
+ const kiroProvider = providerRepository.getProvider("account-kiro");
1572
+ if (kiroProvider && !kiroProvider.enabled) {
1573
+ providerRepository.updateProvider("account-kiro", { ...kiroProvider, enabled: true });
1574
+ }
1575
+ }
1576
+ catch { /* ignore */ }
1577
+ }
1554
1578
  return reply.send({
1555
1579
  ok: true,
1556
1580
  imported: result.imported,
@@ -1636,6 +1660,16 @@ app.post("/api/kiro/device/poll", async (request, reply) => {
1636
1660
  }
1637
1661
  try {
1638
1662
  const result = await deviceLoginService.pollDeviceLogin(sessionId);
1663
+ // Auto-enable Kiro provider when a new account is added
1664
+ if (result.status === "completed") {
1665
+ try {
1666
+ const kiroProvider = providerRepository.getProvider("account-kiro");
1667
+ if (kiroProvider && !kiroProvider.enabled) {
1668
+ providerRepository.updateProvider("account-kiro", { ...kiroProvider, enabled: true });
1669
+ }
1670
+ }
1671
+ catch { /* ignore */ }
1672
+ }
1639
1673
  return reply.send({ ok: true, ...result });
1640
1674
  }
1641
1675
  catch (error) {
@@ -1938,33 +1972,122 @@ app.delete("/api/cli-tools/codex-settings", async (_request, reply) => {
1938
1972
  return reply.code(500).send({ error: error instanceof Error ? error.message : "Failed to reset settings" });
1939
1973
  }
1940
1974
  });
1941
- // ─── MITM Server Status (stub — requires host-mode deployment) ──────────────
1975
+ // ─── MITM Server Status ─────────────────────────────────────────────────────
1942
1976
  app.get("/api/cli-tools/mitm-status", async (_request, reply) => {
1943
- // MITM requires running directly on host (not Docker) with root privileges.
1944
- // Return stub status indicating MITM is not available in this deployment mode.
1945
- return reply.send({
1946
- running: false,
1947
- certExists: false,
1948
- certTrusted: false,
1949
- dnsStatus: {},
1950
- available: false,
1951
- reason: "MITM requires host-mode deployment with root privileges (port 443 + DNS + cert trust). Not available in Docker mode.",
1952
- });
1977
+ try {
1978
+ const { rootCaExists, isRootCaTrusted } = await import("./mitm/cert.js");
1979
+ const { isMitmRunning } = await import("./mitm/server.js");
1980
+ const { checkAllDNSStatus, isSudoPasswordRequired } = await import("./mitm/dns.js");
1981
+ return reply.send({
1982
+ running: isMitmRunning(),
1983
+ certExists: rootCaExists(),
1984
+ certTrusted: isRootCaTrusted(),
1985
+ dnsStatus: checkAllDNSStatus(),
1986
+ needsSudoPassword: isSudoPasswordRequired(),
1987
+ available: true,
1988
+ isWin: process.platform === "win32",
1989
+ });
1990
+ }
1991
+ catch (error) {
1992
+ return reply.send({
1993
+ running: false,
1994
+ certExists: false,
1995
+ certTrusted: false,
1996
+ dnsStatus: {},
1997
+ available: false,
1998
+ reason: error instanceof Error ? error.message : "MITM module not available",
1999
+ });
2000
+ }
1953
2001
  });
1954
- app.post("/api/cli-tools/mitm-start", async (_request, reply) => {
1955
- return reply.code(501).send({
1956
- error: "MITM server is not available in Docker deployment mode. Run the proxy directly on the host with root privileges.",
1957
- });
2002
+ app.post("/api/cli-tools/mitm-start", async (request, reply) => {
2003
+ const body = request.body;
2004
+ try {
2005
+ const { generateRootCA, trustRootCA, rootCaExists: caExists } = await import("./mitm/cert.js");
2006
+ const { spawn } = await import("node:child_process");
2007
+ // Ensure CA exists
2008
+ if (!caExists()) {
2009
+ generateRootCA();
2010
+ }
2011
+ // Start MITM server as a child process (needs root for port 443)
2012
+ const mitmScript = path.resolve(import.meta.dirname || __dirname, "mitm", "start-server.js");
2013
+ const sudoPassword = body?.sudoPassword || "";
2014
+ // Use sudo to start the server process
2015
+ const env = {
2016
+ ...process.env,
2017
+ MITM_ROUTER_BASE_URL: `http://127.0.0.1:${config.PORT}`,
2018
+ };
2019
+ const child = spawn("sudo", ["-S", process.execPath, mitmScript], {
2020
+ env,
2021
+ stdio: ["pipe", "ignore", "pipe"],
2022
+ detached: true,
2023
+ });
2024
+ if (sudoPassword && child.stdin) {
2025
+ child.stdin.write(`${sudoPassword}\n`);
2026
+ child.stdin.end();
2027
+ }
2028
+ child.unref();
2029
+ // Wait a moment to check if it started
2030
+ await new Promise((resolve) => setTimeout(resolve, 2000));
2031
+ return reply.send({ ok: true });
2032
+ }
2033
+ catch (error) {
2034
+ return reply.code(500).send({
2035
+ error: error instanceof Error ? error.message : "Failed to start MITM server",
2036
+ });
2037
+ }
1958
2038
  });
1959
2039
  app.post("/api/cli-tools/mitm-stop", async (_request, reply) => {
1960
- return reply.code(501).send({
1961
- error: "MITM server is not available in Docker deployment mode.",
1962
- });
2040
+ try {
2041
+ const { getMitmPid } = await import("./mitm/server.js");
2042
+ const pid = getMitmPid();
2043
+ if (pid) {
2044
+ try {
2045
+ process.kill(pid, "SIGTERM");
2046
+ }
2047
+ catch { /* already dead */ }
2048
+ }
2049
+ return reply.send({ ok: true });
2050
+ }
2051
+ catch (error) {
2052
+ return reply.code(500).send({
2053
+ error: error instanceof Error ? error.message : "Failed to stop MITM server",
2054
+ });
2055
+ }
1963
2056
  });
1964
- app.post("/api/cli-tools/mitm-dns", async (_request, reply) => {
1965
- return reply.code(501).send({
1966
- error: "DNS interception is not available in Docker deployment mode.",
1967
- });
2057
+ app.post("/api/cli-tools/mitm-dns", async (request, reply) => {
2058
+ const body = request.body;
2059
+ const toolId = typeof body?.toolId === "string" ? body.toolId : "";
2060
+ const enable = body?.enable !== false;
2061
+ const sudoPassword = typeof body?.sudoPassword === "string" ? body.sudoPassword : "";
2062
+ try {
2063
+ const { addDNSEntry, removeDNSEntry } = await import("./mitm/dns.js");
2064
+ if (enable) {
2065
+ await addDNSEntry(toolId, sudoPassword);
2066
+ }
2067
+ else {
2068
+ await removeDNSEntry(toolId, sudoPassword);
2069
+ }
2070
+ return reply.send({ ok: true });
2071
+ }
2072
+ catch (error) {
2073
+ return reply.code(500).send({
2074
+ error: error instanceof Error ? error.message : "Failed to update DNS",
2075
+ });
2076
+ }
2077
+ });
2078
+ app.post("/api/cli-tools/mitm-trust-cert", async (request, reply) => {
2079
+ const body = request.body;
2080
+ try {
2081
+ const { generateRootCA, trustRootCA } = await import("./mitm/cert.js");
2082
+ generateRootCA();
2083
+ await trustRootCA(body?.sudoPassword);
2084
+ return reply.send({ ok: true });
2085
+ }
2086
+ catch (error) {
2087
+ return reply.code(500).send({
2088
+ error: error instanceof Error ? error.message : "Failed to trust certificate",
2089
+ });
2090
+ }
1968
2091
  });
1969
2092
  app.get("/api/customer/codex/setup.sh", async (request, reply) => {
1970
2093
  const routingApiKey = readBearerToken(request.headers.authorization);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "responses-proxy",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "AI routing proxy with multi-provider fallback, RTK token saver, and web dashboard",
5
5
  "bin": {
6
6
  "responses-proxy": "./cli.js"