diff --git a/electron/main.ts b/electron/main.ts index a4fafc23..65c71299 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeImage, dialog } from 'electron'; +import { app, BrowserWindow, nativeImage, dialog, session } from 'electron'; import path from 'path'; import { spawn, execFileSync, ChildProcess } from 'child_process'; import fs from 'fs'; @@ -253,6 +253,46 @@ function getIconPath(): string { return path.join(process.resourcesPath, 'icon.icns'); } +/** + * Fetch proxy settings from the CodePilot API and apply them + * to Electron's default session so renderer network requests + * (e.g. remote images in markdown) also route through the proxy. + */ +async function applyProxySettings(port: number): Promise { + try { + const http = require('http'); + const data: string = await new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${port}/api/settings/app`, (res: { statusCode?: number; on: Function }) => { + let body = ''; + res.on('data', (chunk: string) => { body += chunk; }); + res.on('end', () => resolve(body)); + }); + req.on('error', reject); + req.setTimeout(3000, () => { req.destroy(); reject(new Error('timeout')); }); + }); + + const parsed = JSON.parse(data); + const settings = parsed?.settings || {}; + + if (settings.proxy_enabled === 'true' && settings.proxy_url) { + const proxyUrl = settings.proxy_url; + const bypass = settings.proxy_bypass || 'localhost,127.0.0.1'; + // Electron's setProxy expects proxyRules in the format: scheme://host:port + await session.defaultSession.setProxy({ + proxyRules: proxyUrl, + proxyBypassRules: bypass, + }); + console.log(`[proxy] Electron session proxy set to ${proxyUrl} (bypass: ${bypass})`); + } else { + // Ensure proxy is cleared if disabled + await session.defaultSession.setProxy({ mode: 'direct' as never }); + console.log('[proxy] Electron session proxy: direct (no proxy)'); + } + } catch (err) { + console.warn('[proxy] Failed to apply Electron session proxy:', err); + } +} + function createWindow(port: number) { const windowOptions: Electron.BrowserWindowConstructorOptions = { width: 1280, @@ -319,6 +359,7 @@ app.whenReady().then(async () => { } serverPort = port; + await applyProxySettings(port); createWindow(port); } catch (err) { console.error('Failed to start:', err); @@ -349,6 +390,7 @@ app.on('activate', async () => { await waitForServer(port); serverPort = port; } + await applyProxySettings(serverPort || 3000); createWindow(serverPort || 3000); } catch (err) { console.error('Failed to restart server:', err); diff --git a/scripts/test-proxy.mjs b/scripts/test-proxy.mjs new file mode 100644 index 00000000..4fd294e6 --- /dev/null +++ b/scripts/test-proxy.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Minimal HTTP CONNECT proxy for testing the proxy feature. + * + * Usage: + * 1. node scripts/test-proxy.mjs (starts on port 9999) + * 2. In CodePilot Settings, set proxy to http://127.0.0.1:9999, enable it + * 3. Send a message in chat + * 4. Watch this terminal — you should see CONNECT requests to api.anthropic.com + * + * Press Ctrl+C to stop. + */ + +import http from 'node:http'; +import net from 'node:net'; +import { URL } from 'node:url'; + +const PORT = parseInt(process.argv[2] || '9999', 10); +let requestCount = 0; + +const server = http.createServer((req, res) => { + // Handle normal HTTP proxy requests (non-CONNECT) + requestCount++; + const timestamp = new Date().toISOString().slice(11, 23); + console.log(`[${timestamp}] #${requestCount} HTTP ${req.method} ${req.url}`); + + try { + const target = new URL(req.url); + const proxyReq = http.request( + { + hostname: target.hostname, + port: target.port || 80, + path: target.pathname + target.search, + method: req.method, + headers: req.headers, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + } + ); + proxyReq.on('error', (err) => { + console.log(` -> ERROR: ${err.message}`); + res.writeHead(502); + res.end('Bad Gateway'); + }); + req.pipe(proxyReq); + } catch (err) { + res.writeHead(400); + res.end('Bad Request'); + } +}); + +// Handle HTTPS CONNECT tunneling +server.on('connect', (req, clientSocket, head) => { + requestCount++; + const timestamp = new Date().toISOString().slice(11, 23); + console.log(`[${timestamp}] #${requestCount} CONNECT ${req.url}`); + + const [hostname, port] = req.url.split(':'); + const serverSocket = net.connect(parseInt(port) || 443, hostname, () => { + clientSocket.write( + 'HTTP/1.1 200 Connection Established\r\n' + + 'Proxy-agent: codepilot-test-proxy\r\n' + + '\r\n' + ); + serverSocket.write(head); + serverSocket.pipe(clientSocket); + clientSocket.pipe(serverSocket); + }); + + serverSocket.on('error', (err) => { + console.log(` -> CONNECT ERROR to ${req.url}: ${err.message}`); + clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + clientSocket.end(); + }); + + clientSocket.on('error', () => serverSocket.destroy()); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(''); + console.log('='.repeat(60)); + console.log(' CodePilot Test Proxy'); + console.log('='.repeat(60)); + console.log(` Listening on: http://127.0.0.1:${PORT}`); + console.log(''); + console.log(' Steps:'); + console.log(` 1. Open CodePilot Settings`); + console.log(` 2. Enable proxy, set URL to http://127.0.0.1:${PORT}`); + console.log(` 3. Save, then send a chat message`); + console.log(` 4. Watch this terminal for CONNECT/HTTP requests`); + console.log(''); + console.log(' Expected output:'); + console.log(' CONNECT api.anthropic.com:443'); + console.log(''); + console.log(' Press Ctrl+C to stop'); + console.log('='.repeat(60)); + console.log(''); +}); diff --git a/src/app/api/settings/app/route.ts b/src/app/api/settings/app/route.ts index 47ccfdd3..a1409bb2 100644 --- a/src/app/api/settings/app/route.ts +++ b/src/app/api/settings/app/route.ts @@ -9,6 +9,9 @@ import { getSetting, setSetting } from '@/lib/db'; const ALLOWED_KEYS = [ 'anthropic_auth_token', 'anthropic_base_url', + 'proxy_enabled', + 'proxy_url', + 'proxy_bypass', ]; export async function GET() { diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index d60e5bf9..ece451e0 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -24,6 +24,7 @@ import { CodeIcon, SlidersHorizontalIcon, Loading02Icon, + GlobeIcon, } from "@hugeicons/core-free-icons"; interface SettingsData { @@ -165,6 +166,135 @@ function ApiConfigSection() { ); } +// --- Proxy Configuration Section (CodePilot app settings, stored in SQLite) --- +function ProxyConfigSection() { + const [proxyEnabled, setProxyEnabled] = useState(false); + const [proxyUrl, setProxyUrl] = useState(""); + const [proxyBypass, setProxyBypass] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState<"idle" | "saved" | "error">("idle"); + + useEffect(() => { + fetch("/api/settings/app") + .then((r) => r.json()) + .then((data) => { + const s = data.settings || {}; + setProxyEnabled(s.proxy_enabled === "true"); + setProxyUrl(s.proxy_url || ""); + setProxyBypass(s.proxy_bypass || ""); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + setSaving(true); + setStatus("idle"); + try { + const res = await fetch("/api/settings/app", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + settings: { + proxy_enabled: proxyEnabled ? "true" : "false", + proxy_url: proxyUrl, + proxy_bypass: proxyBypass, + }, + }), + }); + if (res.ok) { + setStatus("saved"); + setTimeout(() => setStatus("idle"), 2000); + } else { + setStatus("error"); + } + } catch { + setStatus("error"); + } finally { + setSaving(false); + } + }; + + if (loading) return null; + + return ( +
+
+
+ + +
+

+ Configure an HTTP/HTTPS/SOCKS5 proxy for all network access. The proxy + environment variables (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY) will be + passed to the Claude Code subprocess. +

+
+
+ + +
+
+
+ + setProxyUrl(e.target.value)} + disabled={!proxyEnabled} + className="mt-1 font-mono text-sm" + /> +

+ Supports http://, https://, socks5:// protocols +

+
+
+ + setProxyBypass(e.target.value)} + disabled={!proxyEnabled} + className="mt-1 font-mono text-sm" + /> +

+ Comma-separated list of hosts that should bypass the proxy +

+
+
+
+ + {status === "saved" && ( + Saved + )} + {status === "error" && ( + Failed to save + )} +
+
+ ); +} + // --- Claude CLI Settings Section (manages ~/.claude/settings.json) --- function SettingsPageInner() { const [settings, setSettings] = useState({}); @@ -281,6 +411,7 @@ function SettingsPageInner() {
+ {loading ? (
diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index e5b23129..502b167d 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -117,6 +117,25 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream