Skip to content

Commit fadf5d3

Browse files
committed
Add codeforge proxy command for mitmproxy traffic inspection
New CLI command that launches Claude Code through mitmweb, enabling raw API request/response inspection via a browser UI. Auto-installs mitmproxy via pipx, manages CA certificate trust, and cleans up on exit. Usage: codeforge proxy [--setup] [--no-web] [-- <claude-args>]
1 parent 7b14e64 commit fadf5d3

File tree

4 files changed

+409
-1
lines changed

4 files changed

+409
-1
lines changed

cli/src/commands/proxy.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import chalk from "chalk";
2+
import type { Command } from "commander";
3+
import { isInsideContainer } from "../utils/context.js";
4+
import {
5+
ensureCaCert,
6+
findMitmproxy,
7+
installCaToSystem,
8+
installMitmproxy,
9+
isCaInstalled,
10+
isPortInUse,
11+
launchClaude,
12+
startMitmdump,
13+
startMitmweb,
14+
} from "../utils/mitmproxy.js";
15+
16+
export function registerProxyCommand(parent: Command): void {
17+
parent
18+
.command("proxy")
19+
.description("Launch Claude Code through mitmproxy for traffic inspection")
20+
.option("--proxy-port <port>", "mitmproxy listen port", "8080")
21+
.option("--web-port <port>", "mitmweb UI port", "8081")
22+
.option("--web-host <host>", "mitmweb bind address", "0.0.0.0")
23+
.option("--setup", "Install mitmproxy and CA certificate only")
24+
.option("--no-web", "Use mitmdump instead of mitmweb (headless)")
25+
.allowUnknownOption(true)
26+
.allowExcessArguments(true)
27+
.action(async (options) => {
28+
try {
29+
if (!isInsideContainer()) {
30+
console.error(
31+
`${chalk.red("✗")} codeforge proxy must be run inside a devcontainer.`,
32+
);
33+
console.error(
34+
" Use `codeforge container shell` to enter a container first.",
35+
);
36+
process.exit(1);
37+
}
38+
39+
if (!findMitmproxy()) {
40+
console.error(
41+
`${chalk.yellow("⚡")} mitmproxy not found. Installing via pipx...`,
42+
);
43+
await installMitmproxy();
44+
if (!findMitmproxy()) {
45+
console.error(`${chalk.red("✗")} Failed to install mitmproxy.`);
46+
process.exit(1);
47+
}
48+
}
49+
50+
const caCertPath = await ensureCaCert();
51+
if (!isCaInstalled()) {
52+
console.error(
53+
`${chalk.yellow("⚡")} Installing CA certificate to system trust store...`,
54+
);
55+
await installCaToSystem(caCertPath);
56+
}
57+
58+
if (options.setup) {
59+
console.error(
60+
`${chalk.green("✓")} mitmproxy and CA certificate are ready.`,
61+
);
62+
process.exit(0);
63+
}
64+
65+
const proxyPort = parseInt(options.proxyPort, 10);
66+
const webPort = parseInt(options.webPort, 10);
67+
const webHost: string = options.webHost;
68+
69+
if (isNaN(proxyPort) || isNaN(webPort)) {
70+
console.error(`${chalk.red("✗")} Invalid port number.`);
71+
process.exit(1);
72+
}
73+
74+
if (await isPortInUse(proxyPort)) {
75+
console.error(
76+
`${chalk.red("✗")} Port ${proxyPort} is already in use.`,
77+
);
78+
process.exit(1);
79+
}
80+
if (options.web && (await isPortInUse(webPort))) {
81+
console.error(`${chalk.red("✗")} Port ${webPort} is already in use.`);
82+
process.exit(1);
83+
}
84+
85+
const dashDashIndex = process.argv.indexOf("--");
86+
const claudeArgs =
87+
dashDashIndex !== -1 ? process.argv.slice(dashDashIndex + 1) : [];
88+
89+
let proxyProc;
90+
if (options.web) {
91+
proxyProc = startMitmweb({ proxyPort, webPort, webHost });
92+
console.error(
93+
`${chalk.green("✓")} mitmweb UI: http://localhost:${webPort}`,
94+
);
95+
} else {
96+
proxyProc = startMitmdump({ proxyPort });
97+
console.error(
98+
`${chalk.green("✓")} mitmdump running (traffic logged to terminal)`,
99+
);
100+
}
101+
console.error(
102+
`${chalk.green("✓")} Proxy listening on port ${proxyPort}`,
103+
);
104+
105+
const cleanup = () => {
106+
try {
107+
proxyProc.kill();
108+
} catch {}
109+
};
110+
process.on("SIGINT", () => {
111+
cleanup();
112+
process.exit(130);
113+
});
114+
process.on("SIGTERM", () => {
115+
cleanup();
116+
process.exit(143);
117+
});
118+
119+
console.error(
120+
`${chalk.blue("→")} Launching Claude Code through proxy...\n`,
121+
);
122+
const exitCode = await launchClaude(claudeArgs, proxyPort, caCertPath);
123+
124+
cleanup();
125+
process.exit(exitCode);
126+
} catch (err) {
127+
const message = err instanceof Error ? err.message : String(err);
128+
console.error(`${chalk.red("✗")} ${message}`);
129+
process.exit(1);
130+
}
131+
});
132+
}

cli/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { registerPluginHooksCommand } from "./commands/plugin/hooks.js";
2323
import { registerPluginListCommand } from "./commands/plugin/list.js";
2424
import { registerPluginShowCommand } from "./commands/plugin/show.js";
2525
import { registerPluginSkillsCommand } from "./commands/plugin/skills.js";
26+
import { registerProxyCommand } from "./commands/proxy.js";
2627
import { registerListCommand } from "./commands/session/list.js";
2728
import { registerSearchCommand } from "./commands/session/search.js";
2829
import { registerShowCommand } from "./commands/session/show.js";
@@ -100,6 +101,8 @@ registerContainerExecCommand(container);
100101
registerContainerLsCommand(container);
101102
registerContainerShellCommand(container);
102103

104+
registerProxyCommand(program);
105+
103106
// Proxy middleware: when outside container and not --local, proxy existing commands into container
104107
program.hook("preAction", async (_thisCommand, actionCommand) => {
105108
const opts = program.opts();
@@ -112,7 +115,7 @@ program.hook("preAction", async (_thisCommand, actionCommand) => {
112115
while (cmd.parent && cmd.parent !== program) {
113116
cmd = cmd.parent;
114117
}
115-
if (cmd.name() === "container") return;
118+
if (cmd.name() === "container" || cmd.name() === "proxy") return;
116119

117120
// Proxy into running container
118121
try {

cli/src/utils/mitmproxy.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import type { Subprocess } from "bun";
2+
import { existsSync } from "fs";
3+
import { homedir } from "os";
4+
import { join } from "path";
5+
6+
/**
7+
* Check if mitmweb is available on PATH.
8+
* Returns the path if found, null otherwise.
9+
*/
10+
export function findMitmproxy(): string | null {
11+
const result = Bun.spawnSync(["which", "mitmweb"], {
12+
stdout: "pipe",
13+
stderr: "pipe",
14+
});
15+
if (result.exitCode === 0) {
16+
return new TextDecoder().decode(result.stdout).trim();
17+
}
18+
return null;
19+
}
20+
21+
/**
22+
* Install mitmproxy via pipx.
23+
*/
24+
export async function installMitmproxy(): Promise<void> {
25+
const proc = Bun.spawn(["pipx", "install", "mitmproxy"], {
26+
stdout: "inherit",
27+
stderr: "inherit",
28+
});
29+
const exitCode = await proc.exited;
30+
if (exitCode !== 0) {
31+
throw new Error("Failed to install mitmproxy via pipx.");
32+
}
33+
}
34+
35+
/**
36+
* Ensure the mitmproxy CA certificate exists.
37+
* If it doesn't, briefly run mitmdump to trigger generation.
38+
* Returns the absolute path to the CA cert PEM.
39+
*/
40+
export async function ensureCaCert(): Promise<string> {
41+
const certPath = join(homedir(), ".mitmproxy", "mitmproxy-ca-cert.pem");
42+
if (existsSync(certPath)) {
43+
return certPath;
44+
}
45+
46+
const proc = Bun.spawn(["mitmdump", "-q"], {
47+
stdout: "pipe",
48+
stderr: "pipe",
49+
});
50+
await new Promise((resolve) => setTimeout(resolve, 2000));
51+
proc.kill();
52+
53+
if (!existsSync(certPath)) {
54+
throw new Error(
55+
`CA certificate was not generated at ${certPath}. Is mitmproxy installed correctly?`,
56+
);
57+
}
58+
return certPath;
59+
}
60+
61+
/**
62+
* Check if the mitmproxy CA is installed in the system trust store.
63+
*/
64+
export function isCaInstalled(): boolean {
65+
return existsSync("/usr/local/share/ca-certificates/mitmproxy.crt");
66+
}
67+
68+
/**
69+
* Install the mitmproxy CA cert to the system trust store.
70+
* Prints a warning instead of throwing if update-ca-certificates fails.
71+
*/
72+
export async function installCaToSystem(certPath: string): Promise<void> {
73+
const cp = Bun.spawn(
74+
["sudo", "cp", certPath, "/usr/local/share/ca-certificates/mitmproxy.crt"],
75+
{ stdout: "pipe", stderr: "pipe" },
76+
);
77+
if ((await cp.exited) !== 0) {
78+
console.error(
79+
"Warning: failed to copy CA cert. Run manually:\n sudo cp " +
80+
certPath +
81+
" /usr/local/share/ca-certificates/mitmproxy.crt && sudo update-ca-certificates",
82+
);
83+
return;
84+
}
85+
86+
const update = Bun.spawn(["sudo", "update-ca-certificates"], {
87+
stdout: "pipe",
88+
stderr: "pipe",
89+
});
90+
if ((await update.exited) !== 0) {
91+
console.error(
92+
"Warning: update-ca-certificates failed. Run manually:\n sudo update-ca-certificates",
93+
);
94+
}
95+
}
96+
97+
/**
98+
* Start mitmweb as a background process.
99+
*/
100+
export function startMitmweb(opts: {
101+
proxyPort: number;
102+
webPort: number;
103+
webHost: string;
104+
}): Subprocess {
105+
return Bun.spawn(
106+
[
107+
"mitmweb",
108+
"--mode",
109+
"regular",
110+
"--listen-port",
111+
String(opts.proxyPort),
112+
"--web-host",
113+
opts.webHost,
114+
"--web-port",
115+
String(opts.webPort),
116+
"--set",
117+
"connection_strategy=lazy",
118+
"--set",
119+
"web_password=123",
120+
],
121+
{ stdout: "pipe", stderr: "pipe" },
122+
);
123+
}
124+
125+
/**
126+
* Start mitmdump with output to the terminal.
127+
*/
128+
export function startMitmdump(opts: { proxyPort: number }): Subprocess {
129+
return Bun.spawn(
130+
[
131+
"mitmdump",
132+
"--mode",
133+
"regular",
134+
"--listen-port",
135+
String(opts.proxyPort),
136+
"--set",
137+
"connection_strategy=lazy",
138+
],
139+
{ stdout: "inherit", stderr: "inherit" },
140+
);
141+
}
142+
143+
/**
144+
* Launch claude with proxy env vars. Returns the exit code.
145+
*/
146+
export async function launchClaude(
147+
args: string[],
148+
proxyPort: number,
149+
caCertPath: string,
150+
): Promise<number> {
151+
const existingNodeOptions = process.env.NODE_OPTIONS ?? "";
152+
const nodeOptions = existingNodeOptions
153+
? `${existingNodeOptions} --use-system-ca`
154+
: "--use-system-ca";
155+
156+
const cmd = args.length > 0 ? ["claude", ...args] : ["claude"];
157+
const proc = Bun.spawn(cmd, {
158+
stdout: "inherit",
159+
stderr: "inherit",
160+
stdin: "inherit",
161+
env: {
162+
...process.env,
163+
HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,
164+
NODE_EXTRA_CA_CERTS: caCertPath,
165+
NODE_OPTIONS: nodeOptions,
166+
},
167+
});
168+
return proc.exited;
169+
}
170+
171+
/**
172+
* Check if a port is currently in use.
173+
*/
174+
export async function isPortInUse(port: number): Promise<boolean> {
175+
const proc = Bun.spawn(["lsof", "-i", `:${port}`], {
176+
stdout: "pipe",
177+
stderr: "pipe",
178+
});
179+
const exitCode = await proc.exited;
180+
return exitCode === 0;
181+
}

0 commit comments

Comments
 (0)