Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void> {
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,
Expand Down Expand Up @@ -319,6 +359,7 @@ app.whenReady().then(async () => {
}

serverPort = port;
await applyProxySettings(port);
createWindow(port);
} catch (err) {
console.error('Failed to start:', err);
Expand Down Expand Up @@ -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);
Expand Down
100 changes: 100 additions & 0 deletions scripts/test-proxy.mjs
Original file line number Diff line number Diff line change
@@ -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('');
});
3 changes: 3 additions & 0 deletions src/app/api/settings/app/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
131 changes: 131 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
CodeIcon,
SlidersHorizontalIcon,
Loading02Icon,
GlobeIcon,
} from "@hugeicons/core-free-icons";

interface SettingsData {
Expand Down Expand Up @@ -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 (
<div className="rounded-lg border border-border/50 p-4 space-y-4">
<div>
<div className="flex items-center gap-2">
<HugeiconsIcon icon={GlobeIcon} className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Proxy Configuration</Label>
</div>
<p className="text-xs text-muted-foreground mt-1">
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.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="proxy-enabled"
checked={proxyEnabled}
onCheckedChange={setProxyEnabled}
/>
<Label htmlFor="proxy-enabled" className="text-sm">
{proxyEnabled ? "Proxy Enabled" : "Proxy Disabled"}
</Label>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="proxy-url" className="text-xs text-muted-foreground">
Proxy URL
</Label>
<Input
id="proxy-url"
placeholder="http://127.0.0.1:7890"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
disabled={!proxyEnabled}
className="mt-1 font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Supports http://, https://, socks5:// protocols
</p>
</div>
<div>
<Label htmlFor="proxy-bypass" className="text-xs text-muted-foreground">
Bypass List (NO_PROXY)
</Label>
<Input
id="proxy-bypass"
placeholder="localhost,127.0.0.1,::1"
value={proxyBypass}
onChange={(e) => setProxyBypass(e.target.value)}
disabled={!proxyEnabled}
className="mt-1 font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated list of hosts that should bypass the proxy
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button onClick={handleSave} disabled={saving} size="sm" className="gap-2">
{saving ? (
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
)}
{saving ? "Saving..." : "Save Proxy Config"}
</Button>
{status === "saved" && (
<span className="text-sm text-green-600 dark:text-green-400">Saved</span>
)}
{status === "error" && (
<span className="text-sm text-destructive">Failed to save</span>
)}
</div>
</div>
);
}

// --- Claude CLI Settings Section (manages ~/.claude/settings.json) ---
function SettingsPageInner() {
const [settings, setSettings] = useState<SettingsData>({});
Expand Down Expand Up @@ -281,6 +411,7 @@ function SettingsPageInner() {
<div className="flex-1 overflow-auto p-6">
<div className="max-w-3xl space-y-6">
<ApiConfigSection />
<ProxyConfigSection />

{loading ? (
<div className="flex items-center justify-center py-12">
Expand Down
19 changes: 19 additions & 0 deletions src/lib/claude-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,25 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
sdkEnv.ANTHROPIC_BASE_URL = appBaseUrl;
}

// Inject proxy environment variables if proxy is enabled
const proxyEnabled = getSetting('proxy_enabled');
if (proxyEnabled === 'true') {
const proxyUrl = getSetting('proxy_url');
const proxyBypass = getSetting('proxy_bypass');
if (proxyUrl) {
sdkEnv.HTTP_PROXY = proxyUrl;
sdkEnv.HTTPS_PROXY = proxyUrl;
sdkEnv.ALL_PROXY = proxyUrl;
sdkEnv.http_proxy = proxyUrl;
sdkEnv.https_proxy = proxyUrl;
sdkEnv.all_proxy = proxyUrl;
}
if (proxyBypass) {
sdkEnv.NO_PROXY = proxyBypass;
sdkEnv.no_proxy = proxyBypass;
}
}

const queryOptions: Options = {
cwd: workingDirectory || process.cwd(),
abortController,
Expand Down
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,9 @@ export const SETTING_KEYS = {
THEME: 'theme',
PERMISSION_MODE: 'permission_mode',
MAX_THINKING_TOKENS: 'max_thinking_tokens',
PROXY_ENABLED: 'proxy_enabled',
PROXY_URL: 'proxy_url',
PROXY_BYPASS: 'proxy_bypass',
} as const;

// ==========================================
Expand Down