mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-10 06:28:23 +08:00
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>
190 lines
6.6 KiB
TypeScript
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);
|
|
});
|
|
});
|