fix(browse): direct mode launches Chromium with --proxy-server=direct:// so proxy env vars never win silently (#3440 item 8)

BROWSE_PROXY_URL=direct only stopped gstack from configuring a proxy; the daemon
inherits the environment and the launch passed no proxy switch, so Chromium read
HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself and every goto still went through that
proxy while browse reported a direct connection. Every launch path now adds
--proxy-server=direct:// when no proxy is configured (chrome-headless-shell
ignores --no-proxy-server), the daemon logs the effective proxy mode on launch,
and BROWSER.md's proxy section says what Chromium actually gets.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-26 02:19:30 +00:00
1 parent 8d9501373b
commit dba8e92c37
7 files changed
+180 -6

No files matched your search

+15 -1
View File
@@ -972,6 +972,19 @@ first one set wins:
5. `ALL_PROXY`, then `all_proxy`
6. no proxy (a direct connection)
Whatever `$B` resolves is what Chromium gets, explicitly: a proxy is passed
as `--proxy-server=<url>`, and a direct connection as
`--proxy-server=direct://`. Chromium never picks a proxy on its own, so with
`BROWSE_PROXY_URL=direct` (or an unparseable variable, below) a set
`HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` is ignored, and so are the
system proxy settings on macOS and Windows: to use one, name it through steps
1-5. Earlier releases left Chromium to read those variables itself in the
direct case (#3440), so a session that reported a direct connection could
still route through a proxy. The daemon log (`browse-daemon.log` next to the
state file) records the effective mode on every launch, as `[proxy] using
<url> from <source>` or `[proxy] none: direct connection
(--proxy-server=direct://; ignoring HTTPS_PROXY)`.
Values from the environment follow the same credential policy as `--proxy`.
`socks5h://` is treated as `socks5://`, because Chromium's SOCKS5 already
resolves names through the proxy. `NO_PROXY` (or `no_proxy`) is translated to
@@ -983,7 +996,8 @@ where it came from, with credentials redacted.
- **`warning: HTTPS_PROXY is not a proxy URL browse can use (...)`.** The
variable doesn't parse as `scheme://host:port`: a curl-style `host:port`, an
unsupported scheme, or a bad port. `$B` connects directly instead of failing
unsupported scheme, or a bad port. `$B` connects directly (Chromium is
told so, and does not read the variable itself) instead of failing
every command. Fix: `export HTTPS_PROXY=http://host:port` (or `socks5://`),
or set `BROWSE_PROXY_URL=direct` to silence the warning. A bad `--proxy` or
`BROWSE_PROXY_URL` is still an error, because those are explicit browse
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [Unreleased]
**`$B` in direct mode no longer lets Chromium pick a proxy from the environment (#3440 item 8).** `BROWSE_PROXY_URL=direct` (and an unparseable `HTTPS_PROXY`) only stopped gstack from configuring a proxy. The daemon inherits the whole environment and the launch passed no proxy switch, so Chromium read `HTTPS_PROXY`, `HTTP_PROXY` or `ALL_PROXY` itself and every `goto` still went through that proxy while `$B` reported a direct connection. That made the BROWSER.md precedence list false and any certificate check on a proxied machine unreliable. Reproduced on Linux with Playwright 1.62.1: a logging CONNECT proxy in `HTTPS_PROXY` received `CONNECT <host>:443` with `BROWSE_PROXY_URL=direct` set.
### Fixed
- **`browse/src/browser-manager.ts`**: every launch path (headless `launch`, the `BROWSE_EXTENSIONS_DIR` persistent context, `launchHeaded`, `handoff`) adds `--proxy-server=direct://` when no proxy is configured, from one `proxyLaunchArgs()`. A configured proxy was already explicit (Playwright passes it as `--proxy-server`), so the environment can no longer win silently in either mode. `--no-proxy-server` was measured and rejected: chrome-headless-shell, the default headless build, ignores it and still took `HTTPS_PROXY`; the full Chromium honors both switches, and `direct://` works on both.
- **`browse/src/server.ts`** logs the effective proxy mode on every launch. The direct case now prints `[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring HTTPS_PROXY)`, naming each standard variable that was set and ignored, beside the existing `[proxy] using <url> from <source>` line.
- **BROWSER.md, "Proxy from environment"**: says that whatever `$B` resolves is passed to Chromium explicitly, that a set proxy variable or the macOS/Windows system proxy is ignored in direct mode, and where the daemon log records the effective mode. The eMudhra/TLS trust section is unchanged.
### Tests
- `browse/test/proxy-direct-launch.test.ts`: two logging CONNECT proxies on loopback and a target name that does not resolve. With `HTTPS_PROXY` set and `BROWSE_PROXY_URL=direct`, the goto fails at DNS, no CONNECT reaches the proxy, the daemon log names the ignored variable and the state file has no proxy; this case fails on the unfixed tree with `ERR_TUNNEL_CONNECTION_FAILED` and a logged CONNECT. Two controls: `HTTPS_PROXY` alone is the configured proxy and receives the CONNECT; `--proxy` beside `HTTPS_PROXY` sends the CONNECT only to the configured proxy.
## [1.91.226.0] - 2026-10-26
**/review can re-review just its own fixes after a fixing pass, under the same rules as /ship (#3339).** Before, every /review fix cycle reran the whole review over the whole branch, so a large branch could hit the three-cycle stop on findings in code the earlier passes had already read. Now the pass after an edited pass starts with `gstack-review-log --start review --fix-range` and its checklist, specialists and QA read only what changed since that pass. The Step 4.8 adversarial review still reads the whole branch on every pass, because its rows carry their own freshness. A narrowed pass is never the final review: when it makes no edits, /review runs one full pass before writing the final record, and a fix-range record is never CURRENT. Garry's "do it" on 2026-10-24 for a fix-range re-review with a final full pass covers it.
+20 -2
View File
@@ -614,6 +614,23 @@ export class BrowserManager {
this.proxyConfig = cfg;
}
/**
* Chromium picks its own proxy when it is launched without --proxy-server:
* HTTPS_PROXY, HTTP_PROXY and ALL_PROXY on Linux, the system proxy settings
* on macOS and Windows. Playwright passes a configured proxy explicitly as
* --proxy-server, so only a session browse resolved to direct was exposed:
* `BROWSE_PROXY_URL=direct` on a machine with HTTPS_PROXY set still routed
* every `goto` through that proxy (#3440 item 8). Direct now means
* `--proxy-server=direct://` on every launch path (headless, headed,
* handoff). Not `--no-proxy-server`: chrome-headless-shell (the default
* headless build) only reads --proxy-server and still took HTTPS_PROXY
* with --no-proxy-server set, measured on Playwright 1.62.1; the full
* Chromium honors both.
*/
private proxyLaunchArgs(): string[] {
return this.proxyConfig ? [] : ['--proxy-server=direct://'];
}
/**
* Get the ref map for external consumers (e.g., /refs endpoint).
*/
@@ -635,7 +652,7 @@ export class BrowserManager {
// macOS still showed (#2281, #432).
const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs, STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs(), ...this.proxyLaunchArgs()];
// #2771/#1968: honor GSTACK_CHROMIUM_PATH headless too (NixOS, macOS 13).
// That bundle belongs to the user, so the XProtect heal never touches it,
// and a launch failure names it.
@@ -809,6 +826,7 @@ export class BrowserManager {
// empty-fallback returns native), so this is safe on stock Playwright
// Chromium too.
...buildGStackLaunchArgs(),
...this.proxyLaunchArgs(),
];
if (extensionPath) {
// Skip --load-extension when running against a custom Chromium build
@@ -2117,7 +2135,7 @@ export class BrowserManager {
// Same blink-level stealth flags as launch()/launchHeaded(). Without
// STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled
// tell that the other two paths strip.
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs(), ...this.proxyLaunchArgs()];
if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`);
+10
View File
@@ -239,6 +239,16 @@ export function serverProxyEnv(env: NodeJS.ProcessEnv): { url: string | undefine
};
}
/**
* The daemon's launch log line for a direct connection (#3440 item 8). Direct
* is enforced with `--proxy-server=direct://` (see BrowserManager.proxyLaunchArgs),
* so this names each standard variable Chromium would otherwise have read.
*/
export function directProxyLogLine(env: NodeJS.ProcessEnv): string {
const ignored = STANDARD_PROXY_VARS.filter(name => env[name]);
return `[browse] [proxy] none: direct connection (--proxy-server=direct://${ignored.length ? `; ignoring ${ignored.join(', ')}` : ''})`;
}
/**
* Resolve the proxy for this invocation (#915). Env URLs go through
* parseProxyConfig, so the cred policy applies. A standard variable that does
+2 -2
View File
@@ -49,7 +49,7 @@ import { isProcessAlive } from './error-handling';
import { allowedExtensionOrigin } from './extension-id';
import { sanitizeBody, stripLoneSurrogates } from './sanitize';
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError, serverProxyEnv } from './proxy-config';
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError, serverProxyEnv, directProxyLogLine } from './proxy-config';
import { writeReceipt } from '../../lib/egress-receipt';
import { redactProxyUrl } from './proxy-redact';
import { type XvfbHandle } from './xvfb';
@@ -1943,7 +1943,7 @@ export async function start() {
proxyBridge.close().catch(() => { /* shutting down anyway */ });
}
});
}
} else console.log(directProxyLogLine(process.env));
process.on('exit', () => { browserManager.closeOwnedDisplay(); });
+12 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from '../src/proxy-config';
import { parseProxyConfig, computeConfigHash, directProxyLogLine, ProxyConfigError } from '../src/proxy-config';
import { extractGlobalFlags } from '../src/cli';
describe('parseProxyConfig', () => {
@@ -187,3 +187,14 @@ describe('extractGlobalFlags', () => {
expect(a.configHash).not.toBe(b.configHash);
});
});
describe('directProxyLogLine (#3440 item 8)', () => {
test('names the switch alone when no standard variable is set', () => {
expect(directProxyLogLine({})).toBe('[browse] [proxy] none: direct connection (--proxy-server=direct://)');
});
test('names every set standard variable in precedence order, and nothing else', () => {
const line = directProxyLogLine({ all_proxy: 'socks5://a:1', HTTPS_PROXY: 'http://b:2', BROWSE_PROXY_URL: 'direct', NO_PROXY: 'x', PATH: '/bin' });
expect(line).toBe('[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring HTTPS_PROXY, all_proxy)');
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* #3440 item 8: a session browse resolves to direct launches Chromium with
* `--proxy-server=direct://`, so the proxy variables Chromium reads on its
* own (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY) never win silently. Two logging
* CONNECT proxies on loopback stand in for the environment's proxy and a
* configured one; a name that does not resolve is the target, so a CONNECT at
* a proxy is the only way the navigation can get past DNS.
*/
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
const CLI = path.resolve(import.meta.dir, '../src/cli.ts');
const TARGET = 'proxied.gstack-3440.test';
let scratch = '';
let stateFile = '';
let daemonLog = '';
const proxies: Array<{ server: http.Server; url: string; connects: string[] }> = [];
async function connectProxy() {
const connects: string[] = [];
const server = http.createServer((_req, res) => { res.writeHead(502); res.end(); });
server.on('connect', (req, socket) => {
connects.push(req.url ?? '');
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', () => resolve()));
const entry = { server, url: `http://127.0.0.1:${(server.address() as { port: number }).port}`, connects };
proxies.push(entry);
return entry;
}
beforeAll(() => {
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-proxy-direct-'));
stateFile = path.join(scratch, 'state', 'browse.json');
daemonLog = path.join(scratch, 'state', 'browse-daemon.log');
});
// stop works whatever the proxy settings are, so a failed case never leaves
// its daemon behind for the next one to be refused by.
afterEach(async () => { await browse(['stop'], {}); });
afterAll(async () => {
if (fs.existsSync(stateFile)) {
try { process.kill(JSON.parse(fs.readFileSync(stateFile, 'utf8')).pid, 'SIGKILL'); } catch {}
}
for (const { server } of proxies) await new Promise<void>(resolve => server.close(() => resolve()));
fs.rmSync(scratch, { recursive: true, force: true });
});
async function browse(args: string[], extraEnv: Record<string, string>) {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !/^(BROWSE_|GSTACK_|NO_PROXY$|no_proxy$|HTTPS?_PROXY$|https?_proxy$|ALL_PROXY$|all_proxy$)/.test(key)) env[key] = value;
}
Object.assign(env, {
HOME: scratch,
GSTACK_HOME: path.join(scratch, '.gstack'),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), ...(process.platform === 'darwin' ? ['Library', 'Caches'] : ['.cache']), 'ms-playwright'),
BROWSE_STATE_FILE: stateFile,
BROWSE_PARENT_PID: '0',
}, extraEnv);
const cli = Bun.spawn([process.execPath, CLI, ...args], { cwd: scratch, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
const timer = setTimeout(() => cli.kill('SIGKILL'), 60_000);
const [code, stdout, stderr] = await Promise.all([cli.exited, new Response(cli.stdout).text(), new Response(cli.stderr).text()]);
clearTimeout(timer);
return { code, stdout, stderr, out: stdout + stderr };
}
const readDaemonLog = () => fs.existsSync(daemonLog) ? fs.readFileSync(daemonLog, 'utf8') : '';
describe.skipIf(process.platform === 'win32')('#3440 item 8: direct mode never lets Chromium pick a proxy from the environment', () => {
test('BROWSE_PROXY_URL=direct with HTTPS_PROXY set: no CONNECT reaches the proxy, and the daemon names what it ignored', async () => {
const envProxy = await connectProxy();
const env = { HTTPS_PROXY: envProxy.url, BROWSE_PROXY_URL: 'direct' };
const r = await browse(['goto', `https://${TARGET}/`], env);
expect(r.stderr).toContain('Starting server...');
expect(r.stderr).not.toContain('Starting server with proxy');
expect(r.out).toContain('ERR_NAME_NOT_RESOLVED');
expect(r.out).not.toContain('ERR_TUNNEL_CONNECTION_FAILED');
expect(envProxy.connects).toEqual([]);
expect(readDaemonLog()).toContain('[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring HTTPS_PROXY)');
expect(JSON.parse(fs.readFileSync(stateFile, 'utf8')).proxy).toBeUndefined();
}, 120_000);
test('control: the same HTTPS_PROXY without the opt-out is the configured proxy, and the CONNECT reaches it', async () => {
const envProxy = await connectProxy();
const env = { HTTPS_PROXY: envProxy.url };
const r = await browse(['goto', `https://${TARGET}/`], env);
expect(r.stderr).toContain(`Starting server with proxy ${envProxy.url}/ from HTTPS_PROXY...`);
expect(r.out).toContain('ERR_TUNNEL_CONNECTION_FAILED');
expect(envProxy.connects).toEqual([`${TARGET}:443`]);
expect(readDaemonLog()).toContain(`[browse] [proxy] using ${envProxy.url}/ from HTTPS_PROXY (pass-through to Chromium)`);
}, 120_000);
test('control: --proxy beats HTTPS_PROXY explicitly, so only the configured proxy sees the CONNECT', async () => {
const envProxy = await connectProxy();
const configured = await connectProxy();
const env = { HTTPS_PROXY: envProxy.url };
const r = await browse(['--proxy', configured.url, 'goto', `https://${TARGET}/`], env);
expect(r.stderr).toContain(`Starting server with proxy ${configured.url}/ from --proxy...`);
expect(r.out).toContain('ERR_TUNNEL_CONNECTION_FAILED');
expect(configured.connects).toEqual([`${TARGET}:443`]);
expect(envProxy.connects).toEqual([]);
}, 120_000);
});