From 5fd8ff4698ff256846344aec7301e61bc7512008 Mon Sep 17 00:00:00 2001 From: Roman Rodriguez Date: Wed, 14 Jan 2026 14:00:52 -0300 Subject: [PATCH] Add proxy support - Add proxy support on broser launch - Add proxy examples to CONTRIBUTING.md for CLI, JS, Python, and MCP - Update README.md to list proxy support in Clicker features - Create comprehensive how-to guide at docs/how-to-guides/using-proxies.md with examples, troubleshooting, and security considerations Co-Authored-By: Claude Sonnet 4.5 --- C.patch | 0 CONTRIBUTING.md | 42 ++++- README.md | 1 + clicker/cmd/clicker/main.go | 22 +-- clicker/internal/browser/launcher.go | 13 +- clicker/internal/mcp/handlers.go | 17 +- clicker/internal/mcp/schema.go | 4 + clicker/internal/proxy/router.go | 5 +- clients/javascript/src/browser.ts | 6 +- clients/javascript/src/clicker/process.ts | 4 + clients/javascript/src/sync/browser.ts | 1 + clients/python/src/vibium/browser.py | 3 + clients/python/src/vibium/browser_sync.py | 3 + clients/python/src/vibium/clicker.py | 4 + docs/how-to-guides/using-proxies.md | 181 ++++++++++++++++++++++ 15 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 C.patch create mode 100644 docs/how-to-guides/using-proxies.md diff --git a/C.patch b/C.patch new file mode 100644 index 00000000..e69de29b diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddd37006..039e7efb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -142,6 +142,20 @@ require('fs').writeFileSync('test.png', shot) await vibe.quit() ``` +With proxy: + +```javascript +// HTTP proxy +const vibe = await browser.launch({ + proxy: 'http://proxy.example.com:8080' +}) + +// SOCKS5 proxy with authentication +const vibe = await browser.launch({ + proxy: 'socks5://user:pass@proxy.example.com:1080' +}) +``` + --- ## Using the Python Client @@ -203,6 +217,18 @@ async def main(): asyncio.run(main()) ``` +### Using a Proxy + +Both sync and async APIs support proxy configuration: + +```python +# Sync with HTTP proxy +vibe = browser_sync.launch(proxy='http://proxy.example.com:8080') + +# Async with SOCKS5 proxy and authentication +vibe = await browser.launch(proxy='socks5://user:pass@proxy.example.com:1080') +``` + --- ## Using Clicker @@ -251,12 +277,20 @@ After building, the binary is at `./clicker/bin/clicker`. --headless # Hide the browser window (visible by default) --wait-open 5 # Wait 5 seconds after navigation for page to load --wait-close 3 # Keep browser open 3 seconds before closing +--proxy URL # Use a proxy server (HTTP, HTTPS, or SOCKS5) ``` -Example: +Examples: ```bash +# Basic screenshot with wait ./clicker/bin/clicker screenshot https://example.com --wait-close 5 -o shot.png + +# Using an HTTP proxy +./clicker/bin/clicker navigate https://example.com --proxy http://proxy.example.com:8080 + +# Using a SOCKS5 proxy with authentication +./clicker/bin/clicker navigate https://example.com --proxy socks5://user:pass@proxy.example.com:1080 ``` --- @@ -269,7 +303,7 @@ Clicker includes an MCP (Model Context Protocol) server for AI agent integration | Tool | Description | |------|-------------| -| `browser_launch` | Start a browser session | +| `browser_launch` | Start a browser session (supports `headless` and `proxy` params) | | `browser_navigate` | Go to a URL | | `browser_click` | Click an element by CSS selector | | `browser_type` | Type into an element | @@ -277,6 +311,10 @@ Clicker includes an MCP (Model Context Protocol) server for AI agent integration | `browser_find` | Find element info | | `browser_quit` | Close the browser | +The `browser_launch` tool accepts optional parameters: +- `headless` (boolean): Run browser in headless mode +- `proxy` (string): Proxy server URL (e.g., `http://proxy:8080`, `socks5://user:pass@proxy:1080`) + ### Running the MCP Server ```bash diff --git a/README.md b/README.md index abebe01a..7bcbca34 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ A single Go binary (~10MB) that does everything: - **MCP Server:** stdio interface for LLM agents - **Auto-Wait:** Polls for elements before interacting - **Screenshots:** Viewport capture as PNG +- **Proxy Support:** HTTP, HTTPS, and SOCKS5 proxies with authentication **Design goal:** The binary is invisible. JS developers just `npm install vibium` and it works. diff --git a/clicker/cmd/clicker/main.go b/clicker/cmd/clicker/main.go index 423b67ff..ba1a9df8 100644 --- a/clicker/cmd/clicker/main.go +++ b/clicker/cmd/clicker/main.go @@ -27,6 +27,7 @@ var ( waitOpen int waitClose int verbose bool + proxyFlag string ) // doWaitOpen waits for page to load if --wait-open is set. @@ -78,6 +79,7 @@ func main() { rootCmd.PersistentFlags().IntVar(&waitOpen, "wait-open", 0, "Seconds to wait after navigation for page to load") rootCmd.PersistentFlags().IntVar(&waitClose, "wait-close", 0, "Seconds to keep browser open before closing") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable debug logging") + rootCmd.PersistentFlags().StringVar(&proxyFlag, "proxy", "", "Proxy server URL (e.g., http://proxy:8080, socks5://proxy:1080)") rootCmd.AddCommand(&cobra.Command{ Use: "version", @@ -135,7 +137,7 @@ func main() { Use: "launch-test", Short: "Launch browser via chromedriver and print BiDi WebSocket URL", Run: func(cmd *cobra.Command, args []string) { - result, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + result, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -197,7 +199,7 @@ func main() { Short: "Launch browser, connect via BiDi, send session.status", Run: func(cmd *cobra.Command, args []string) { fmt.Println("[1/5] Launching chromedriver...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: true, Verbose: true}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: true, Verbose: true, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -245,7 +247,7 @@ func main() { url := args[0] fmt.Println("Launching browser...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -288,7 +290,7 @@ func main() { output, _ := cmd.Flags().GetString("output") fmt.Println("Launching browser...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -353,7 +355,7 @@ func main() { expression := args[1] fmt.Println("Launching browser...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -403,7 +405,7 @@ func main() { selector := args[1] fmt.Println("Launching browser...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -459,7 +461,7 @@ func main() { timeout, _ := cmd.Flags().GetDuration("timeout") fmt.Println("Launching browser...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -536,7 +538,7 @@ func main() { timeout, _ := cmd.Flags().GetDuration("timeout") fmt.Println("Launching browser...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -609,7 +611,7 @@ func main() { selector := args[1] fmt.Println("Launching browser...") - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless, Proxy: proxyFlag}) if err != nil { fmt.Fprintf(os.Stderr, "Error launching browser: %v\n", err) os.Exit(1) @@ -671,7 +673,7 @@ func main() { fmt.Printf("Starting Clicker proxy server on port %d...\n", port) // Create router to manage browser sessions - router := proxy.NewRouter(headless) + router := proxy.NewRouter(headless, proxyFlag) server := proxy.NewServer( proxy.WithPort(port), diff --git a/clicker/internal/browser/launcher.go b/clicker/internal/browser/launcher.go index 7b0a2095..7970916b 100644 --- a/clicker/internal/browser/launcher.go +++ b/clicker/internal/browser/launcher.go @@ -49,8 +49,9 @@ func (pw *prefixWriter) Write(p []byte) (n int, err error) { // LaunchOptions contains options for launching the browser. type LaunchOptions struct { Headless bool - Port int // Chromedriver port, 0 = auto-select - Verbose bool // Show chromedriver output + Port int // Chromedriver port, 0 = auto-select + Verbose bool // Show chromedriver output + Proxy string // Proxy server URL (e.g., http://proxy:8080, socks5://proxy:1080) } // LaunchResult contains the result of launching the browser via chromedriver. @@ -145,7 +146,7 @@ func Launch(opts LaunchOptions) (*LaunchResult, error) { } // Create session with BiDi enabled - sessionID, wsURL, err := createSession(baseURL, chromePath, opts.Headless, opts.Verbose) + sessionID, wsURL, err := createSession(baseURL, chromePath, opts.Headless, opts.Proxy, opts.Verbose) if err != nil { cmd.Process.Kill() return nil, fmt.Errorf("failed to create session: %w", err) @@ -187,7 +188,7 @@ func waitForChromedriver(baseURL string, timeout time.Duration) error { } // createSession creates a new WebDriver session with BiDi enabled. -func createSession(baseURL, chromePath string, headless, verbose bool) (string, string, error) { +func createSession(baseURL, chromePath string, headless bool, proxy string, verbose bool) (string, string, error) { args := []string{ "--no-first-run", "--no-default-browser-check", @@ -221,6 +222,10 @@ func createSession(baseURL, chromePath string, headless, verbose bool) (string, args = append(args, "--headless=new") } + if proxy != "" { + args = append(args, fmt.Sprintf("--proxy-server=%s", proxy)) + } + reqBody := map[string]interface{}{ "capabilities": map[string]interface{}{ "alwaysMatch": map[string]interface{}{ diff --git a/clicker/internal/mcp/handlers.go b/clicker/internal/mcp/handlers.go index 0995af44..c7b1ba3e 100644 --- a/clicker/internal/mcp/handlers.go +++ b/clicker/internal/mcp/handlers.go @@ -76,8 +76,16 @@ func (h *Handlers) browserLaunch(args map[string]interface{}) (*ToolsCallResult, headless = val } + proxy := "" + if val, ok := args["proxy"].(string); ok { + proxy = val + } + // Launch browser - launchResult, err := browser.Launch(browser.LaunchOptions{Headless: headless}) + launchResult, err := browser.Launch(browser.LaunchOptions{ + Headless: headless, + Proxy: proxy, + }) if err != nil { return nil, fmt.Errorf("failed to launch browser: %w", err) } @@ -93,10 +101,15 @@ func (h *Handlers) browserLaunch(args map[string]interface{}) (*ToolsCallResult, h.conn = conn h.client = bidi.NewClient(conn) + msg := fmt.Sprintf("Browser launched (headless: %v)", headless) + if proxy != "" { + msg += fmt.Sprintf(", proxy: %s", proxy) + } + return &ToolsCallResult{ Content: []Content{{ Type: "text", - Text: fmt.Sprintf("Browser launched (headless: %v)", headless), + Text: msg, }}, }, nil } diff --git a/clicker/internal/mcp/schema.go b/clicker/internal/mcp/schema.go index 2eedf123..a1348822 100644 --- a/clicker/internal/mcp/schema.go +++ b/clicker/internal/mcp/schema.go @@ -14,6 +14,10 @@ func GetToolSchemas() []Tool { "description": "Run browser in headless mode (no visible window)", "default": false, }, + "proxy": map[string]interface{}{ + "type": "string", + "description": "Proxy server URL (e.g., http://proxy:8080, socks5://proxy:1080, http://user:pass@proxy:8080)", + }, }, }, }, diff --git a/clicker/internal/proxy/router.go b/clicker/internal/proxy/router.go index 8adf344e..67976faa 100644 --- a/clicker/internal/proxy/router.go +++ b/clicker/internal/proxy/router.go @@ -53,12 +53,14 @@ type bidiError struct { type Router struct { sessions sync.Map // map[uint64]*BrowserSession (client ID -> session) headless bool + proxy string } // NewRouter creates a new router. -func NewRouter(headless bool) *Router { +func NewRouter(headless bool, proxy string) *Router { return &Router{ headless: headless, + proxy: proxy, } } @@ -70,6 +72,7 @@ func (r *Router) OnClientConnect(client *ClientConn) { // Launch browser launchResult, err := browser.Launch(browser.LaunchOptions{ Headless: r.headless, + Proxy: r.proxy, }) if err != nil { fmt.Printf("[router] Failed to launch browser for client %d: %v\n", client.ID, err) diff --git a/clients/javascript/src/browser.ts b/clients/javascript/src/browser.ts index 95dd6aac..def51505 100644 --- a/clients/javascript/src/browser.ts +++ b/clients/javascript/src/browser.ts @@ -7,18 +7,20 @@ export interface LaunchOptions { headless?: boolean; port?: number; executablePath?: string; + proxy?: string; } export const browser = { async launch(options: LaunchOptions = {}): Promise { - const { headless = false, port, executablePath } = options; - debug('launching browser', { headless, port, executablePath }); + const { headless = false, port, executablePath, proxy } = options; + debug('launching browser', { headless, port, executablePath, proxy }); // Start the clicker process const process = await ClickerProcess.start({ headless, port, executablePath, + proxy, }); debug('clicker started', { port: process.port }); diff --git a/clients/javascript/src/clicker/process.ts b/clients/javascript/src/clicker/process.ts index 3b082c9f..9b26dbe6 100644 --- a/clients/javascript/src/clicker/process.ts +++ b/clients/javascript/src/clicker/process.ts @@ -6,6 +6,7 @@ export interface ClickerProcessOptions { port?: number; headless?: boolean; executablePath?: string; + proxy?: string; } export class ClickerProcess { @@ -33,6 +34,9 @@ export class ClickerProcess { if (options.headless === true) { args.push('--headless'); } + if (options.proxy) { + args.push('--proxy', options.proxy); + } const proc = spawn(binaryPath, args, { stdio: ['ignore', 'pipe', 'pipe'], diff --git a/clients/javascript/src/sync/browser.ts b/clients/javascript/src/sync/browser.ts index af4243b9..21cd3bd3 100644 --- a/clients/javascript/src/sync/browser.ts +++ b/clients/javascript/src/sync/browser.ts @@ -3,6 +3,7 @@ import { VibeSync } from './vibe'; export interface LaunchOptions { headless?: boolean; + proxy?: string; } export const browserSync = { diff --git a/clients/python/src/vibium/browser.py b/clients/python/src/vibium/browser.py index 3fb01454..835e627d 100644 --- a/clients/python/src/vibium/browser.py +++ b/clients/python/src/vibium/browser.py @@ -21,6 +21,7 @@ async def launch( headless: bool = False, port: Optional[int] = None, executable_path: Optional[str] = None, + proxy: Optional[str] = None, ) -> Vibe: """Launch a new browser instance. @@ -28,6 +29,7 @@ async def launch( headless: Run browser in headless mode (default: visible). port: WebSocket port (default: auto-assigned). executable_path: Path to clicker binary (default: auto-detect). + proxy: Proxy server URL (e.g., http://proxy:8080, socks5://proxy:1080). Returns: A Vibe instance for browser automation. @@ -36,6 +38,7 @@ async def launch( headless=headless, port=port, executable_path=executable_path, + proxy=proxy, ) client = await BiDiClient.connect(f"ws://localhost:{process.port}") diff --git a/clients/python/src/vibium/browser_sync.py b/clients/python/src/vibium/browser_sync.py index e2a1c2af..a91264e2 100644 --- a/clients/python/src/vibium/browser_sync.py +++ b/clients/python/src/vibium/browser_sync.py @@ -113,6 +113,7 @@ def launch( headless: bool = False, port: Optional[int] = None, executable_path: Optional[str] = None, + proxy: Optional[str] = None, ) -> VibeSync: """Launch a new browser instance. @@ -120,6 +121,7 @@ def launch( headless: Run browser in headless mode (default: visible). port: WebSocket port (default: auto-assigned). executable_path: Path to clicker binary (default: auto-detect). + proxy: Proxy server URL (e.g., http://proxy:8080, socks5://proxy:1080). Returns: A VibeSync instance for browser automation. @@ -132,6 +134,7 @@ def launch( headless=headless, port=port, executable_path=executable_path, + proxy=proxy, ) ) diff --git a/clients/python/src/vibium/clicker.py b/clients/python/src/vibium/clicker.py index f5832126..cd32ce79 100644 --- a/clients/python/src/vibium/clicker.py +++ b/clients/python/src/vibium/clicker.py @@ -156,6 +156,7 @@ async def start( headless: bool = False, port: Optional[int] = None, executable_path: Optional[str] = None, + proxy: Optional[str] = None, ) -> "ClickerProcess": """Start a clicker process. @@ -163,6 +164,7 @@ async def start( headless: Run browser in headless mode. port: WebSocket port (default: auto-assigned). executable_path: Path to clicker binary (default: auto-detect). + proxy: Proxy server URL (e.g., http://proxy:8080, socks5://proxy:1080). Returns: A ClickerProcess instance. @@ -177,6 +179,8 @@ async def start( args.append("--headless") if port: args.extend(["--port", str(port)]) + if proxy: + args.extend(["--proxy", proxy]) # Start the process process = subprocess.Popen( diff --git a/docs/how-to-guides/using-proxies.md b/docs/how-to-guides/using-proxies.md new file mode 100644 index 00000000..75d2e7e2 --- /dev/null +++ b/docs/how-to-guides/using-proxies.md @@ -0,0 +1,181 @@ +# Using Proxies with Vibium + +Vibium supports routing browser traffic through proxy servers. This is useful for: +- Testing geo-restricted content +- Working behind corporate firewalls +- Privacy and anonymity +- Load balancing and rate limiting + +## Supported Proxy Types + +- **HTTP/HTTPS proxies**: Standard HTTP proxy protocol +- **SOCKS5 proxies**: More versatile, works with any protocol +- **Authenticated proxies**: Both HTTP and SOCKS5 with username/password + +## Command Line Usage + +### Basic HTTP Proxy + +```bash +./clicker/bin/clicker navigate https://example.com --proxy http://proxy.example.com:8080 +``` + +**Sample output:** +``` +Navigating to https://example.com... +✓ Loaded: https://example.com (via proxy http://proxy.example.com:8080) +``` + +### SOCKS5 Proxy + +```bash +./clicker/bin/clicker navigate https://example.com --proxy socks5://proxy.example.com:1080 +``` + +### Authenticated Proxy + +```bash +./clicker/bin/clicker navigate https://example.com --proxy http://user:pass@proxy.example.com:8080 +``` + +**Note:** Special characters in passwords should be URL-encoded. + +### With Other Flags + +Proxy works with all other flags: + +```bash +# Headless mode with proxy +./clicker/bin/clicker screenshot https://example.com --headless --proxy http://proxy:8080 -o shot.png + +# Serve mode with proxy (all browser sessions use the proxy) +./clicker/bin/clicker serve --proxy socks5://proxy:1080 +``` + +## JavaScript/TypeScript Client + +### Async API + +```javascript +import { browser } from 'vibium'; + +// HTTP proxy +const vibe = await browser.launch({ + proxy: 'http://proxy.example.com:8080' +}); + +// SOCKS5 with auth +const vibe = await browser.launch({ + proxy: 'socks5://user:pass@proxy.example.com:1080' +}); + +await vibe.go('https://example.com'); +await vibe.quit(); +``` + +### Sync API + +```javascript +const { browserSync } = require('vibium'); + +const vibe = browserSync.launch({ + proxy: 'http://proxy.example.com:8080' +}); + +vibe.go('https://example.com'); +vibe.quit(); +``` + +## Python Client + +### Async API + +```python +from vibium import browser + +# HTTP proxy +vibe = await browser.launch(proxy='http://proxy.example.com:8080') +await vibe.go('https://example.com') +await vibe.quit() + +# SOCKS5 with auth +vibe = await browser.launch(proxy='socks5://user:pass@proxy.example.com:1080') +``` + +### Sync API + +```python +from vibium import browser_sync + +vibe = browser_sync.launch(proxy='http://proxy.example.com:8080') +vibe.go('https://example.com') +vibe.quit() +``` + +## MCP Server + +When using the MCP server (e.g., with Claude Code), pass the proxy parameter to `browser_launch`: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "browser_launch", + "arguments": { + "proxy": "http://proxy.example.com:8080" + } + } +} +``` + +The proxy setting applies to all subsequent browser operations in that session. + +## Proxy URL Format + +Proxy URLs follow this format: + +``` +[protocol://][username:password@]host:port +``` + +**Examples:** +- `http://proxy.example.com:8080` - Basic HTTP proxy +- `socks5://proxy.example.com:1080` - SOCKS5 proxy +- `http://user:pass@proxy.example.com:8080` - HTTP proxy with authentication +- `socks5://user:pass@proxy.example.com:1080` - SOCKS5 with authentication + +## Troubleshooting + +### Connection Fails + +If the browser fails to connect through the proxy: + +1. Verify the proxy is accessible: `curl -x http://proxy:8080 https://example.com` +2. Check authentication credentials +3. Ensure the proxy supports HTTPS CONNECT method (for HTTPS sites) +4. Try with `--verbose` flag to see detailed connection logs + +### SOCKS5 Not Working + +Chrome/Chromium requires that SOCKS5 proxies are specified as `socks5://` (not `socks://`). + +### Special Characters in Password + +URL-encode special characters: +- `@` → `%40` +- `:` → `%3A` +- `/` → `%2F` + +Example: `http://user:p%40ssword@proxy:8080` for password `p@ssword` + +## Environment Variables + +Currently, Vibium does not support environment variables like `HTTP_PROXY` or `HTTPS_PROXY`. You must explicitly pass the proxy URL. + +## Security Considerations + +- Proxy credentials are passed to Chrome via command-line arguments, which may be visible in process listings +- For sensitive operations, consider using SSH tunnels or VPNs instead +- Authenticated proxy credentials are not encrypted in memory