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:
Garry Tan
2026-05-07 13:26:41 -07:00
parent 7e7530ea3f
commit 7c8412fb41
5 changed files with 563 additions and 6 deletions

View File

@@ -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',

View File

@@ -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
View 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>';
}
}

View File

@@ -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 });

View 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);
});
});