mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 21:49:45 +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 {
|
||||
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:<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 tabSessions: Map<number, TabSession> = 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',
|
||||
|
||||
@@ -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<ServerState> {
|
||||
async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
|
||||
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.
|
||||
// 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<ServerState> {
|
||||
if (state && state.pid) {
|
||||
await killServer(state.pid);
|
||||
}
|
||||
console.error('[browse] Starting server...');
|
||||
return await startServer();
|
||||
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...');
|
||||
}
|
||||
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 <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> {
|
||||
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<void
|
||||
|
||||
// ─── 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') {
|
||||
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());
|
||||
}
|
||||
|
||||
let state = await ensureServer();
|
||||
let state = await ensureServer(globalFlags);
|
||||
|
||||
// ─── Pair-Agent (post-server, pre-dispatch) ──────────────
|
||||
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
|
||||
// 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 });
|
||||
|
||||
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