Files
gstack/browse/test/proxy-config.test.ts
Garry Tan 7c8412fb41 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>
2026-05-07 13:26:41 -07:00

190 lines
6.6 KiB
TypeScript

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