diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f5a3121d..cae7e4f0 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -49,6 +49,11 @@ export interface BrowserState { export class BrowserManager { private browser: Browser | null = null; private context: BrowserContext | null = null; + // Proxy config applied to chromium.launch() when set (D8). Set by server.ts + // at startup based on BROWSE_PROXY_URL. For SOCKS5 with auth, server.ts + // points this at the local bridge (socks5://127.0.0.1:); for + // HTTP/HTTPS or unauth SOCKS5, it's the upstream URL directly. + private proxyConfig: { server: string; username?: string; password?: string } | null = null; private pages: Map = new Map(); private tabSessions: Map = new Map(); private activeTabId: number = 0; @@ -163,6 +168,15 @@ export class BrowserManager { return null; } + /** + * Set the proxy config applied to chromium.launch() in launch() and + * launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5 + * bridge is up. + */ + setProxyConfig(cfg: { server: string; username?: string; password?: string } | null): void { + this.proxyConfig = cfg; + } + /** * Get the ref map for external consumers (e.g., /refs endpoint). */ @@ -207,6 +221,7 @@ export class BrowserManager { // browsing user-specified URLs has marginal sandbox benefit. chromiumSandbox: process.platform !== 'win32', ...(launchArgs.length > 0 ? { args: launchArgs } : {}), + ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), }); // Chromium crash → exit with clear message @@ -359,6 +374,7 @@ export class BrowserManager { viewport: null, // Use browser's default viewport (real window size) userAgent: this.customUserAgent || customUA, ...(executablePath ? { executablePath } : {}), + ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), // Playwright adds flags that block extension loading ignoreDefaultArgs: [ '--disable-extensions', @@ -1257,6 +1273,7 @@ export class BrowserManager { headless: false, args: launchArgs, viewport: null, + ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), ignoreDefaultArgs: [ '--disable-extensions', '--disable-component-extensions-with-background-pages', diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 9c4881a2..39f3c95d 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -13,6 +13,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; +import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; +import { redactProxyUrl } from './proxy-redact'; const config = resolveConfig(); const IS_WINDOWS = process.platform === 'win32'; @@ -92,6 +94,12 @@ interface ServerState { serverPath: string; binaryVersion?: string; mode?: 'launched' | 'headed'; + /** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */ + configHash?: string; + /** Xvfb child PID for cleanup on disconnect. */ + xvfbPid?: number; + xvfbStartTime?: number; + xvfbDisplay?: string; } // ─── State File ──────────────────────────────────────────────── @@ -305,19 +313,43 @@ function acquireServerLock(): (() => void) | null { } } -async function ensureServer(): Promise { +async function ensureServer(flags?: GlobalFlags): Promise { const state = readState(); + const desiredHash = flags?.configHash; + const extraEnv: Record = {}; + if (flags?.proxyUrl) extraEnv.BROWSE_PROXY_URL = flags.proxyUrl; + if (flags?.headed) extraEnv.BROWSE_HEADED = '1'; + if (desiredHash) extraEnv.BROWSE_CONFIG_HASH = desiredHash; // Health-check-first: HTTP is definitive proof the server is alive and responsive. // This replaces the PID-gated approach which breaks on Windows (Bun's process.kill // always throws ESRCH for Windows PIDs in compiled binaries). if (state && await isServerHealthy(state.port)) { + // D2 daemon-mismatch check: existing daemon's configHash must match the + // CLI's resolved hash. If --proxy or --headed are passed and the existing + // daemon was started with different config, refuse with a `disconnect` + // hint. No silent restart — that would drop tab state, cookies, and + // logged-in sessions without warning. + if (desiredHash && state.configHash && state.configHash !== desiredHash) { + console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`); + console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`); + process.exit(1); + } + // Same path: existing daemon is plain (no flags) but caller passes + // --proxy/--headed. Refuse for the same reason — apply explicitly via + // disconnect+reconnect. + if (desiredHash && !state.configHash && (flags?.proxyUrl || flags?.headed)) { + console.error(`[browse] existing daemon was started without --proxy/--headed.`); + console.error(`[browse] run 'browse disconnect' first to apply new flags.`); + process.exit(1); + } + // Check for binary version mismatch (auto-restart on update) const currentVersion = readVersionHash(); if (currentVersion && state.binaryVersion && currentVersion !== state.binaryVersion) { console.error('[browse] Binary updated, restarting server...'); await killServer(state.pid); - return startServer(); + return startServer(extraEnv); } return state; } @@ -368,8 +400,14 @@ async function ensureServer(): Promise { if (state && state.pid) { await killServer(state.pid); } - console.error('[browse] Starting server...'); - return await startServer(); + if (flags?.redactedProxyUrl && flags.redactedProxyUrl !== '') { + console.error(`[browse] Starting server with proxy ${flags.redactedProxyUrl}${flags.headed ? ' (headed)' : ''}...`); + } else if (flags?.headed) { + console.error('[browse] Starting server in headed mode...'); + } else { + console.error('[browse] Starting server...'); + } + return await startServer(extraEnv); } finally { releaseLock(); } @@ -608,6 +646,78 @@ function hasFlag(args: string[], flag: string): boolean { return args.includes(flag); } +export interface GlobalFlags { + /** Cleaned argv with --proxy/--headed stripped out. */ + args: string[]; + /** Resolved BROWSE_PROXY_URL (with creds embedded) or null. */ + proxyUrl: string | null; + /** Whether --headed was passed. */ + headed: boolean; + /** Hash of (proxy + headed) for daemon-mismatch check. */ + configHash: string; + /** Redacted form of proxyUrl, safe for logs. */ + redactedProxyUrl: string; +} + +/** + * Strip the global --proxy and --headed flags from args, validate cred policy, + * and return the resolved config. Exits 1 with a clear hint on policy + * violations (D9 cred mixing, malformed URL, unsupported scheme). + * + * Exported for unit tests. + */ +export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): GlobalFlags { + const out: string[] = []; + let proxyUrl: string | null = null; + let headed = false; + + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i]; + if (arg === '--proxy') { + const value = rawArgs[i + 1]; + if (!value) { + throw new ProxyConfigError( + 'usage: --proxy ', + '--proxy requires a URL value', + ); + } + proxyUrl = value; + i++; + continue; + } + if (arg.startsWith('--proxy=')) { + proxyUrl = arg.slice('--proxy='.length); + continue; + } + if (arg === '--headed') { headed = true; continue; } + out.push(arg); + } + + // Compose the canonical proxyUrl with creds resolved from argv+env. + let canonicalProxyUrl: string | null = null; + if (proxyUrl) { + const parsed = parseProxyConfig({ + proxyUrl, + envUser: env.BROWSE_PROXY_USER, + envPass: env.BROWSE_PROXY_PASS, + }); + // Re-encode with resolved creds embedded (server reads BROWSE_PROXY_URL + // from env — env passes to child process safely without ps-aux exposure). + const rebuilt = new URL(proxyUrl); + rebuilt.username = parsed.userId ? encodeURIComponent(parsed.userId) : ''; + rebuilt.password = parsed.password ? encodeURIComponent(parsed.password) : ''; + canonicalProxyUrl = rebuilt.toString(); + } + + return { + args: out, + proxyUrl: canonicalProxyUrl, + headed, + configHash: computeConfigHash({ proxyUrl: canonicalProxyUrl, headed }), + redactedProxyUrl: redactProxyUrl(canonicalProxyUrl), + }; +} + async function handlePairAgent(state: ServerState, args: string[]): Promise { const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`; const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim()); @@ -751,7 +861,23 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise 65535) { + throw new ProxyConfigError( + 'expected scheme://[user:pass@]host:port', + `invalid proxy URL — bad port`, + ); + } + + const urlHasUser = !!url.username; + const urlHasPass = !!url.password; + const envHasUser = !!opts.envUser; + const envHasPass = !!opts.envPass; + const urlHasCreds = urlHasUser || urlHasPass; + const envHasCreds = envHasUser || envHasPass; + + // D9 (codex correction): refuse on mixed sources. Silent override is a + // debugging trap — when a stale BROWSE_PROXY_USER from a prior session + // wins over a fresh --proxy URL, the user can't tell why. + if (urlHasCreds && envHasCreds) { + throw new ProxyConfigError( + 'unset BROWSE_PROXY_USER/PASS or remove user:pass@ from --proxy', + `proxy creds set in both env (BROWSE_PROXY_USER) and URL — pick one source`, + ); + } + + let userId: string | undefined; + let password: string | undefined; + if (urlHasCreds) { + userId = decodeURIComponent(url.username); + password = url.password ? decodeURIComponent(url.password) : undefined; + } else if (envHasCreds) { + userId = opts.envUser; + password = opts.envPass; + } + + return { + scheme: scheme as 'socks5' | 'http' | 'https', + host: url.hostname, + port, + ...(userId ? { userId } : {}), + ...(password ? { password } : {}), + hasAuth: !!(userId || password), + }; +} + +/** Convert a ParsedProxyConfig to the UpstreamConfig shape socks-bridge wants. */ +export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig { + return { + host: cfg.host, + port: cfg.port, + ...(cfg.userId ? { userId: cfg.userId } : {}), + ...(cfg.password ? { password: cfg.password } : {}), + }; +} + +/** + * Compute a stable hash of (proxyUrl + headed flag) for daemon-mismatch + * detection (D2). The hash is deterministic across CLI invocations on the + * same machine and survives daemon restarts via the state file. + * + * NEVER include resolved creds — the hash compares config intent, not + * specific credential values, and we don't want creds in any persisted form. + */ +export function computeConfigHash(opts: { + proxyUrl: string | null | undefined; + headed: boolean; +}): string { + const proxyKey = canonicalizeProxyUrl(opts.proxyUrl); + const input = JSON.stringify({ proxy: proxyKey, headed: opts.headed }); + return createHash('sha256').update(input).digest('hex').slice(0, 16); +} + +/** Strip creds from a proxy URL for hashing. Returns null for empty input. */ +function canonicalizeProxyUrl(input: string | null | undefined): string | null { + if (!input) return null; + try { + const u = new URL(input); + u.username = ''; + u.password = ''; + return `${u.protocol}//${u.host}`; + } catch { + return ''; + } +} diff --git a/browse/src/server.ts b/browse/src/server.ts index 042616e7..8bf991db 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -41,6 +41,9 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory // Bun.spawn used instead of child_process.spawn (compiled bun binaries // fail posix_spawn on all executables including /bin/bash) import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling'; +import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge'; +import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config'; +import { redactProxyUrl } from './proxy-redact'; import { logTunnelDenial } from './tunnel-denial-log'; import { mintSseSessionToken, validateSseSessionToken, extractSseCookie, @@ -1020,6 +1023,70 @@ async function start() { const port = await findPort(); LOCAL_LISTEN_PORT = port; + // ─── Proxy config (D8 + codex F5) ────────────────────────────── + // BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5 + // with auth, we run a local 127.0.0.1 bridge that relays to the + // authenticated upstream (Chromium can't do SOCKS5 auth itself). For + // HTTP/HTTPS or unauthenticated SOCKS5, we pass the URL directly to + // Chromium's proxy.server option. + let proxyBridge: BridgeHandle | null = null; + const proxyUrl = process.env.BROWSE_PROXY_URL; + if (proxyUrl) { + let parsed; + try { + parsed = parseProxyConfig({ + proxyUrl, + envUser: process.env.BROWSE_PROXY_USER, + envPass: process.env.BROWSE_PROXY_PASS, + }); + } catch (err) { + if (err instanceof ProxyConfigError) { + console.error(`[browse] error: ${err.message} (${err.hint})`); + process.exit(1); + } + throw err; + } + + if (parsed.scheme === 'socks5' && parsed.hasAuth) { + // Pre-flight: verify upstream accepts our creds before launching + // Chromium. 5s budget, 3 retries with 500ms backoff (D4: handles VPN + // warm-up race). On failure, exit with redacted error. + console.log(`[browse] Testing SOCKS5 upstream ${redactProxyUrl(proxyUrl)}...`); + try { + const test = await testUpstream({ + upstream: toUpstreamConfig(parsed), + budgetMs: 5000, + retries: 3, + backoffMs: 500, + }); + console.log(`[browse] [proxy] upstream test ok in ${test.ms}ms (${test.attempts} attempt${test.attempts === 1 ? '' : 's'})`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`[browse] [proxy] FAIL upstream ${redactProxyUrl(proxyUrl)}: ${msg}`); + process.exit(1); + } + + proxyBridge = await startSocksBridge({ upstream: toUpstreamConfig(parsed) }); + console.log(`[browse] [proxy] bridge listening on 127.0.0.1:${proxyBridge.port}`); + browserManager.setProxyConfig({ server: `socks5://127.0.0.1:${proxyBridge.port}` }); + } else { + // HTTP/HTTPS or unauth SOCKS5 — pass through to Chromium directly. + browserManager.setProxyConfig({ + server: `${parsed.scheme}://${parsed.host}:${parsed.port}`, + ...(parsed.userId ? { username: parsed.userId } : {}), + ...(parsed.password ? { password: parsed.password } : {}), + }); + console.log(`[browse] [proxy] using ${redactProxyUrl(proxyUrl)} (pass-through to Chromium)`); + } + + // Tear down bridge on shutdown. + process.on('exit', () => { + if (proxyBridge) { + proxyBridge.close().catch(() => { /* shutting down anyway */ }); + } + }); + } + // Launch browser (headless or headed with extension) // BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing) const skipBrowser = process.env.BROWSE_HEADLESS_SKIP === '1'; @@ -1998,6 +2065,9 @@ async function start() { serverPath: path.resolve(import.meta.dir, 'server.ts'), binaryVersion: readVersionHash() || undefined, mode: browserManager.getConnectionMode(), + // D2 daemon-mismatch detection: CLI computes the same hash from its + // resolved flags and refuses if it differs from this stored value. + ...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}), }; const tmpFile = config.stateFile + '.tmp'; fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); diff --git a/browse/test/proxy-config.test.ts b/browse/test/proxy-config.test.ts new file mode 100644 index 00000000..88b4cf63 --- /dev/null +++ b/browse/test/proxy-config.test.ts @@ -0,0 +1,189 @@ +import { describe, test, expect } from 'bun:test'; +import { parseProxyConfig, computeConfigHash, ProxyConfigError } from '../src/proxy-config'; +import { extractGlobalFlags } from '../src/cli'; + +describe('parseProxyConfig', () => { + test('parses socks5 URL with embedded creds', () => { + const cfg = parseProxyConfig({ + proxyUrl: 'socks5://alice:secret@host.example.com:1080', + }); + expect(cfg.scheme).toBe('socks5'); + expect(cfg.host).toBe('host.example.com'); + expect(cfg.port).toBe(1080); + expect(cfg.userId).toBe('alice'); + expect(cfg.password).toBe('secret'); + expect(cfg.hasAuth).toBe(true); + }); + + test('parses URL-only env-credentials', () => { + const cfg = parseProxyConfig({ + proxyUrl: 'socks5://host.example.com:1080', + envUser: 'env-user', + envPass: 'env-pass', + }); + expect(cfg.userId).toBe('env-user'); + expect(cfg.password).toBe('env-pass'); + expect(cfg.hasAuth).toBe(true); + }); + + test('parses URL-only no-auth', () => { + const cfg = parseProxyConfig({ proxyUrl: 'http://proxy.corp:3128' }); + expect(cfg.scheme).toBe('http'); + expect(cfg.hasAuth).toBe(false); + expect(cfg.userId).toBeUndefined(); + }); + + test('D9: refuses on mixed cred sources (env + URL)', () => { + expect(() => parseProxyConfig({ + proxyUrl: 'socks5://alice:secret@host:1080', + envUser: 'env-user', + envPass: 'env-pass', + })).toThrow(/proxy creds set in both env.*and URL/); + }); + + test('D9: refuses when env has only password and URL has user', () => { + // Asymmetric mixing still counts. + expect(() => parseProxyConfig({ + proxyUrl: 'socks5://alice@host:1080', + envPass: 'env-pass', + })).toThrow(/pick one source/); + }); + + test('rejects malformed URL', () => { + expect(() => parseProxyConfig({ proxyUrl: 'not-a-url' })) + .toThrow(ProxyConfigError); + }); + + test('rejects unsupported scheme', () => { + expect(() => parseProxyConfig({ proxyUrl: 'ftp://host:21' })) + .toThrow(/unsupported proxy scheme/); + }); + + test('decodes URL-encoded creds', () => { + const cfg = parseProxyConfig({ + proxyUrl: 'socks5://user%40example.com:p%40ss%21@host:1080', + }); + expect(cfg.userId).toBe('user@example.com'); + expect(cfg.password).toBe('p@ss!'); + }); +}); + +describe('computeConfigHash', () => { + test('same inputs → same hash', () => { + const a = computeConfigHash({ proxyUrl: 'socks5://host:1080', headed: true }); + const b = computeConfigHash({ proxyUrl: 'socks5://host:1080', headed: true }); + expect(a).toBe(b); + }); + + test('different proxy → different hash', () => { + const a = computeConfigHash({ proxyUrl: 'socks5://host:1080', headed: false }); + const b = computeConfigHash({ proxyUrl: 'socks5://other:1080', headed: false }); + expect(a).not.toBe(b); + }); + + test('different headed → different hash', () => { + const a = computeConfigHash({ proxyUrl: null, headed: false }); + const b = computeConfigHash({ proxyUrl: null, headed: true }); + expect(a).not.toBe(b); + }); + + test('strips creds before hashing (cred-stable hash)', () => { + // Same proxy host, different creds → same hash. We don't want the hash + // to change just because the user rotated their password. + const a = computeConfigHash({ proxyUrl: 'socks5://alice:pass1@host:1080', headed: false }); + const b = computeConfigHash({ proxyUrl: 'socks5://alice:pass2@host:1080', headed: false }); + expect(a).toBe(b); + }); + + test('null proxy + headed=false → stable hash', () => { + const hash = computeConfigHash({ proxyUrl: null, headed: false }); + expect(hash).toMatch(/^[a-f0-9]{16}$/); + }); +}); + +describe('extractGlobalFlags', () => { + const ENV_EMPTY: NodeJS.ProcessEnv = {}; + + test('strips --proxy and --headed from args', () => { + const result = extractGlobalFlags( + ['goto', 'https://example.com', '--proxy', 'socks5://h:1080', '--headed'], + ENV_EMPTY, + ); + expect(result.args).toEqual(['goto', 'https://example.com']); + expect(result.proxyUrl).toContain('socks5://h:1080'); + expect(result.headed).toBe(true); + }); + + test('supports --proxy=value form', () => { + const result = extractGlobalFlags( + ['goto', 'https://x', '--proxy=socks5://h:1080'], + ENV_EMPTY, + ); + expect(result.proxyUrl).toContain('socks5://h:1080'); + expect(result.args).toEqual(['goto', 'https://x']); + }); + + test('no flags → empty proxy + headed=false + non-empty hash', () => { + const result = extractGlobalFlags(['goto', 'https://x'], ENV_EMPTY); + expect(result.proxyUrl).toBeNull(); + expect(result.headed).toBe(false); + expect(result.configHash).toMatch(/^[a-f0-9]{16}$/); + }); + + test('redactedProxyUrl masks creds from --proxy URL', () => { + const result = extractGlobalFlags( + ['goto', 'https://x', '--proxy', 'socks5://alice:secret@host:1080'], + ENV_EMPTY, + ); + expect(result.redactedProxyUrl).not.toContain('alice'); + expect(result.redactedProxyUrl).not.toContain('secret'); + expect(result.redactedProxyUrl).toContain('***'); + expect(result.redactedProxyUrl).toContain('host:1080'); + }); + + test('D9: throws on mixed cred sources', () => { + expect(() => extractGlobalFlags( + ['goto', 'https://x', '--proxy', 'socks5://alice:secret@host:1080'], + { BROWSE_PROXY_USER: 'env-user', BROWSE_PROXY_PASS: 'env-pass' } as NodeJS.ProcessEnv, + )).toThrow(ProxyConfigError); + }); + + test('--proxy without value → throws', () => { + expect(() => extractGlobalFlags( + ['goto', 'https://x', '--proxy'], + ENV_EMPTY, + )).toThrow(ProxyConfigError); + }); + + test('env-only creds resolve into canonical proxyUrl', () => { + const result = extractGlobalFlags( + ['goto', 'https://x', '--proxy', 'socks5://host:1080'], + { BROWSE_PROXY_USER: 'envuser', BROWSE_PROXY_PASS: 'envpass' } as NodeJS.ProcessEnv, + ); + // proxyUrl should now have the env creds embedded (URL-encoded). + expect(result.proxyUrl).toContain('envuser'); + expect(result.proxyUrl).toContain('envpass'); + expect(result.proxyUrl).toContain('host:1080'); + }); + + test('configHash is stable across cred rotations', () => { + const a = extractGlobalFlags( + ['goto', 'x', '--proxy', 'socks5://u1:p1@host:1080'], + ENV_EMPTY, + ); + const b = extractGlobalFlags( + ['goto', 'x', '--proxy', 'socks5://u2:p2@host:1080'], + ENV_EMPTY, + ); + expect(a.configHash).toBe(b.configHash); + }); + + test('configHash changes between proxied vs no-proxy', () => { + const a = extractGlobalFlags(['goto', 'x'], ENV_EMPTY); + const b = extractGlobalFlags( + ['goto', 'x', '--proxy', 'socks5://host:1080'], + ENV_EMPTY, + ); + expect(a.configHash).not.toBe(b.configHash); + }); +});