mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-21 12:18:24 +08:00
feat(browse): --proxy and --headed flags wire bridge into daemon
Adds the global --proxy <url> and --headed flags to the browse CLI.
Resolves cred policy and routes the daemon launch through the SOCKS5
bridge (or pass-through for HTTP/HTTPS) before chromium.launch().
CLI (cli.ts):
- extractGlobalFlags() strips --proxy/--headed from argv, parses URL via
Node URL class, validates D9 cred-mixing (env BROWSE_PROXY_USER/PASS
+ URL creds → exit 1 with hint), composes canonical proxy URL with
resolved creds, computes a stable configHash for daemon-mismatch
- ensureServer() now reads existing daemon's configHash from state file
and refuses (exit 1 with disconnect hint) if --proxy/--headed mismatch
the existing daemon. No silent restart that would drop tab state.
- All proxy-related stderr lines go through redactProxyUrl
proxy-config.ts (new):
- parseProxyConfig() — URL parser + D9 cred-mixing detector + scheme allowlist
- computeConfigHash() — stable hash of (proxy URL minus creds + headed flag)
- toUpstreamConfig() — map ParsedProxyConfig → socks-bridge.UpstreamConfig
Server (server.ts):
- Reads BROWSE_PROXY_URL at startup; for SOCKS5+auth, runs testUpstream
pre-flight (5s budget, 3 retries, 500ms backoff) and exits 1 on failure
with redacted error
- Spawns startSocksBridge() on 127.0.0.1:<ephemeral> and points
Chromium at it via socks5://127.0.0.1:<port>
- HTTP/HTTPS or unauth SOCKS5 → pass-through to chromium.launch
proxy.server (with username/password if present)
- State file gains optional configHash for daemon-mismatch check
- Bridge tears down via process.on('exit')
Browser manager (browser-manager.ts):
- New setProxyConfig({ server, username, password }) called by server.ts
before launch
- chromium.launch() and both launchPersistentContext sites pass the
proxy config through when set
Tests: 22 new across proxy-config (parse + cred-mixing + hash stability)
and extractGlobalFlags (flag stripping + cred-mixing rejection + cred
rotation hash stability + redaction).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,11 @@ export interface BrowserState {
|
|||||||
export class BrowserManager {
|
export class BrowserManager {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
private context: BrowserContext | 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:<bridgePort>); 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<number, Page> = new Map();
|
private pages: Map<number, Page> = new Map();
|
||||||
private tabSessions: Map<number, TabSession> = new Map();
|
private tabSessions: Map<number, TabSession> = new Map();
|
||||||
private activeTabId: number = 0;
|
private activeTabId: number = 0;
|
||||||
@@ -163,6 +168,15 @@ export class BrowserManager {
|
|||||||
return null;
|
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).
|
* 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.
|
// browsing user-specified URLs has marginal sandbox benefit.
|
||||||
chromiumSandbox: process.platform !== 'win32',
|
chromiumSandbox: process.platform !== 'win32',
|
||||||
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
|
||||||
|
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chromium crash → exit with clear message
|
// Chromium crash → exit with clear message
|
||||||
@@ -359,6 +374,7 @@ export class BrowserManager {
|
|||||||
viewport: null, // Use browser's default viewport (real window size)
|
viewport: null, // Use browser's default viewport (real window size)
|
||||||
userAgent: this.customUserAgent || customUA,
|
userAgent: this.customUserAgent || customUA,
|
||||||
...(executablePath ? { executablePath } : {}),
|
...(executablePath ? { executablePath } : {}),
|
||||||
|
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||||
// Playwright adds flags that block extension loading
|
// Playwright adds flags that block extension loading
|
||||||
ignoreDefaultArgs: [
|
ignoreDefaultArgs: [
|
||||||
'--disable-extensions',
|
'--disable-extensions',
|
||||||
@@ -1257,6 +1273,7 @@ export class BrowserManager {
|
|||||||
headless: false,
|
headless: false,
|
||||||
args: launchArgs,
|
args: launchArgs,
|
||||||
viewport: null,
|
viewport: null,
|
||||||
|
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
|
||||||
ignoreDefaultArgs: [
|
ignoreDefaultArgs: [
|
||||||
'--disable-extensions',
|
'--disable-extensions',
|
||||||
'--disable-component-extensions-with-background-pages',
|
'--disable-component-extensions-with-background-pages',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-handling';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||||
|
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config';
|
||||||
|
import { redactProxyUrl } from './proxy-redact';
|
||||||
|
|
||||||
const config = resolveConfig();
|
const config = resolveConfig();
|
||||||
const IS_WINDOWS = process.platform === 'win32';
|
const IS_WINDOWS = process.platform === 'win32';
|
||||||
@@ -92,6 +94,12 @@ interface ServerState {
|
|||||||
serverPath: string;
|
serverPath: string;
|
||||||
binaryVersion?: string;
|
binaryVersion?: string;
|
||||||
mode?: 'launched' | 'headed';
|
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 ────────────────────────────────────────────────
|
// ─── State File ────────────────────────────────────────────────
|
||||||
@@ -305,19 +313,43 @@ function acquireServerLock(): (() => void) | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureServer(): Promise<ServerState> {
|
async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
|
||||||
const state = readState();
|
const state = readState();
|
||||||
|
const desiredHash = flags?.configHash;
|
||||||
|
const extraEnv: Record<string, string> = {};
|
||||||
|
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.
|
// 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
|
// This replaces the PID-gated approach which breaks on Windows (Bun's process.kill
|
||||||
// always throws ESRCH for Windows PIDs in compiled binaries).
|
// always throws ESRCH for Windows PIDs in compiled binaries).
|
||||||
if (state && await isServerHealthy(state.port)) {
|
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)
|
// Check for binary version mismatch (auto-restart on update)
|
||||||
const currentVersion = readVersionHash();
|
const currentVersion = readVersionHash();
|
||||||
if (currentVersion && state.binaryVersion && currentVersion !== state.binaryVersion) {
|
if (currentVersion && state.binaryVersion && currentVersion !== state.binaryVersion) {
|
||||||
console.error('[browse] Binary updated, restarting server...');
|
console.error('[browse] Binary updated, restarting server...');
|
||||||
await killServer(state.pid);
|
await killServer(state.pid);
|
||||||
return startServer();
|
return startServer(extraEnv);
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -368,8 +400,14 @@ async function ensureServer(): Promise<ServerState> {
|
|||||||
if (state && state.pid) {
|
if (state && state.pid) {
|
||||||
await killServer(state.pid);
|
await killServer(state.pid);
|
||||||
}
|
}
|
||||||
|
if (flags?.redactedProxyUrl && flags.redactedProxyUrl !== '<no proxy>') {
|
||||||
|
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...');
|
console.error('[browse] Starting server...');
|
||||||
return await startServer();
|
}
|
||||||
|
return await startServer(extraEnv);
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
@@ -608,6 +646,78 @@ function hasFlag(args: string[], flag: string): boolean {
|
|||||||
return args.includes(flag);
|
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 <scheme://[user:pass@]host:port>',
|
||||||
|
'--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<void> {
|
async function handlePairAgent(state: ServerState, args: string[]): Promise<void> {
|
||||||
const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`;
|
const clientName = parseFlag(args, '--client') || `remote-${Date.now()}`;
|
||||||
const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim());
|
const domains = parseFlag(args, '--domain')?.split(',').map(d => d.trim());
|
||||||
@@ -751,7 +861,23 @@ async function handlePairAgent(state: ServerState, args: string[]): Promise<void
|
|||||||
|
|
||||||
// ─── Main ──────────────────────────────────────────────────────
|
// ─── Main ──────────────────────────────────────────────────────
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const rawArgs = process.argv.slice(2);
|
||||||
|
|
||||||
|
// ─── Global flags (--proxy, --headed) ───────────────────────
|
||||||
|
// Extract before command dispatch so they apply to any command. Throws
|
||||||
|
// ProxyConfigError on invalid URL or D9 cred-mixing violations.
|
||||||
|
let globalFlags: GlobalFlags;
|
||||||
|
try {
|
||||||
|
globalFlags = extractGlobalFlags(rawArgs, process.env);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ProxyConfigError) {
|
||||||
|
console.error(`[browse] error: ${err.message}`);
|
||||||
|
console.error(`[browse] hint: ${err.hint}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const args = globalFlags.args;
|
||||||
|
|
||||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||||
console.log(`gstack browse — Fast headless browser for AI coding agents
|
console.log(`gstack browse — Fast headless browser for AI coding agents
|
||||||
@@ -978,7 +1104,7 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||||||
commandArgs.push(stdin.trim());
|
commandArgs.push(stdin.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = await ensureServer();
|
let state = await ensureServer(globalFlags);
|
||||||
|
|
||||||
// ─── Pair-Agent (post-server, pre-dispatch) ──────────────
|
// ─── Pair-Agent (post-server, pre-dispatch) ──────────────
|
||||||
if (command === 'pair-agent') {
|
if (command === 'pair-agent') {
|
||||||
|
|||||||
155
browse/src/proxy-config.ts
Normal file
155
browse/src/proxy-config.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Parse + validate proxy config from CLI flags and environment.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* cli.ts — to detect cred-mixing, daemon-mismatch, and forward to server
|
||||||
|
* server.ts — to spawn the bridge and pass proxy to chromium.launch
|
||||||
|
*
|
||||||
|
* Cred policy (D9): if BOTH the URL embeds creds AND the env vars
|
||||||
|
* BROWSE_PROXY_USER/PASS are set, refuse with a clear error. No silent
|
||||||
|
* override — debugging confusion is worse than a one-time setup error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import type { UpstreamConfig } from './socks-bridge';
|
||||||
|
|
||||||
|
export interface ParsedProxyConfig {
|
||||||
|
/** Original scheme: 'socks5' | 'http' | 'https' */
|
||||||
|
scheme: 'socks5' | 'http' | 'https';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
userId?: string;
|
||||||
|
password?: string;
|
||||||
|
/** True if creds are present (from URL or env). */
|
||||||
|
hasAuth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxyConfigError extends Error {
|
||||||
|
constructor(public readonly hint: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ProxyConfigError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the BROWSE_PROXY_URL string and merge env-supplied creds.
|
||||||
|
*
|
||||||
|
* @throws ProxyConfigError on malformed URL, unsupported scheme, or
|
||||||
|
* ambiguous credentials (set in both URL and env).
|
||||||
|
*/
|
||||||
|
export function parseProxyConfig(opts: {
|
||||||
|
proxyUrl: string;
|
||||||
|
envUser?: string;
|
||||||
|
envPass?: string;
|
||||||
|
}): ParsedProxyConfig {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(opts.proxyUrl);
|
||||||
|
} catch {
|
||||||
|
throw new ProxyConfigError(
|
||||||
|
'expected scheme://[user:pass@]host:port',
|
||||||
|
`invalid proxy URL — could not parse`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = url.protocol.replace(':', '');
|
||||||
|
if (scheme !== 'socks5' && scheme !== 'http' && scheme !== 'https') {
|
||||||
|
throw new ProxyConfigError(
|
||||||
|
'use socks5://, http://, or https://',
|
||||||
|
`unsupported proxy scheme '${scheme}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.hostname) {
|
||||||
|
throw new ProxyConfigError(
|
||||||
|
'expected scheme://[user:pass@]host:port',
|
||||||
|
`invalid proxy URL — missing host`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = url.port
|
||||||
|
? parseInt(url.port, 10)
|
||||||
|
: (scheme === 'http' ? 80 : scheme === 'https' ? 443 : 1080);
|
||||||
|
if (!Number.isInteger(port) || port <= 0 || port > 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 '<unparseable>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory
|
|||||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||||
// fail posix_spawn on all executables including /bin/bash)
|
// fail posix_spawn on all executables including /bin/bash)
|
||||||
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
|
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 { logTunnelDenial } from './tunnel-denial-log';
|
||||||
import {
|
import {
|
||||||
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
|
||||||
@@ -1020,6 +1023,70 @@ async function start() {
|
|||||||
const port = await findPort();
|
const port = await findPort();
|
||||||
LOCAL_LISTEN_PORT = port;
|
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)
|
// Launch browser (headless or headed with extension)
|
||||||
// BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing)
|
// BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing)
|
||||||
const skipBrowser = process.env.BROWSE_HEADLESS_SKIP === '1';
|
const skipBrowser = process.env.BROWSE_HEADLESS_SKIP === '1';
|
||||||
@@ -1998,6 +2065,9 @@ async function start() {
|
|||||||
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
serverPath: path.resolve(import.meta.dir, 'server.ts'),
|
||||||
binaryVersion: readVersionHash() || undefined,
|
binaryVersion: readVersionHash() || undefined,
|
||||||
mode: browserManager.getConnectionMode(),
|
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';
|
const tmpFile = config.stateFile + '.tmp';
|
||||||
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||||
|
|||||||
189
browse/test/proxy-config.test.ts
Normal file
189
browse/test/proxy-config.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user