diff --git a/BROWSER.md b/BROWSER.md index 27f6de3a0..5fb04a9c9 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -1022,7 +1022,10 @@ where it came from, with credentials redacted. silently reuse a daemon that routes differently. The refusal prints the daemon's settings and this session's. Fix: run `browse stop` to restart the daemon with this session's settings (stop works whatever the proxy settings - are), or set `BROWSE_PROXY_URL=direct` when the running daemon has no proxy. + are), or match the daemon: unset the variable that differs, or on Linux set + `BROWSE_PROXY_URL=direct` when the running daemon has no proxy (on macOS and + Windows that opt-out is itself part of the daemon settings, so it only + matches a daemon started with it). **Stealth scope (Layer C, always on).** Every context — headless `launch`, `--headed`/`--proxy`, `handoff`, and the `useragent`/`viewport --scale` diff --git a/CHANGELOG.md b/CHANGELOG.md index 62450fe2f..d705aeefe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### 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 and `forcesDirect()` says Chromium would otherwise pick one browse did not resolve, 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/proxy-config.ts`**: `forcesDirect(env, platform)` is true on Linux always, where the leak lives, and on macOS and Windows only with `BROWSE_PROXY_URL=direct`. Chromium there never read the proxy variables and follows the system proxy settings (PAC and WPAD included), so a user of GStack Browser.app behind a corporate proxy keeps that connectivity, and a PAC cannot be named through `BROWSE_PROXY_URL`. An explicit `direct` bypasses the system settings on every platform. -- **`browse/src/cli.ts`, `browse/src/server.ts`**: on macOS and Windows the explicit `direct` is part of the daemon config hash (`explicitDirect()`), is recorded in the daemon state as `proxyDirect` and is re-emitted on a crash auto-restart, so a running daemon started on the system proxy is refused rather than reused by a session that opted out (the refusal names `BROWSE_PROXY_URL` and offers `browse stop` or unsetting it; a daemon started before this release meets that refusal once, and a daemon with no recorded config hash is refused like one started without `--proxy`), and a restart with no invocation flags re-emits the opt-out from the daemon state; a restart driven by a session's own flags emits only what that session's hash says. The unparseable-variable warning's hint now says that `BROWSE_PROXY_URL=direct` also bypasses the system proxy settings on macOS and Windows. On Linux the opt-out hashes like no proxy, as before, because every direct connection there already carries the switch. +- **`browse/src/cli.ts`, `browse/src/server.ts`**: on macOS and Windows the explicit `direct` is part of the daemon config hash (`explicitDirect()`), is recorded in the daemon state as `proxyDirect` and is re-emitted on a crash auto-restart, so a running daemon started on the system proxy is refused rather than reused by a session that opted out (the refusal names `BROWSE_PROXY_URL` and offers `browse stop` or unsetting it; a daemon started before this release meets that refusal once, and a daemon with no recorded config hash is refused like one started without `--proxy`; the mismatch refusal offers `BROWSE_PROXY_URL=direct` as a remedy only where it would match the running daemon, on Linux or against a daemon started with it, and otherwise says to unset the variable that differs), and a restart with no invocation flags re-emits the opt-out from the daemon state; a restart driven by a session's own flags emits only what that session's hash says. The unparseable-variable warning's hint now says that `BROWSE_PROXY_URL=direct` also bypasses the system proxy settings on macOS and Windows. On Linux the opt-out hashes like no proxy, as before, because every direct connection there already carries the switch. - **`browse/src/server.ts`** logs the effective proxy mode each time the daemon starts, beside the existing `[proxy] using from ` line: `[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring HTTPS_PROXY)` on Linux, naming each standard variable that was set and ignored; `(...; ignoring the system proxy settings)` for an explicit direct on macOS and Windows; and `[browse] [proxy] none: system proxy settings (Chromium's default on ; BROWSE_PROXY_URL=direct connects directly)` when those platforms are left to the system. - **BROWSER.md, "Proxy from environment"**: says that whatever `$B` resolves is passed to Chromium explicitly, that on Linux a set proxy variable is ignored in direct mode, that macOS and Windows follow the system proxy settings unless `BROWSE_PROXY_URL=direct` is set (and that the opt-out is part of the daemon's settings there), where the daemon log records the effective mode, and that a managed Chromium proxy policy still overrides the switch. The eMudhra/TLS trust section is unchanged. @@ -15,7 +15,7 @@ - `browse/test/proxy-direct-launch.test.ts` (Linux): two logging CONNECT proxies on loopback (`browse/test/helpers/connect-proxy.ts`) 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. An unparseable `HTTPS_PROXY` (curl-style `host:port`) warns and still forces direct, so no CONNECT reaches the host Chromium itself would have parsed. 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. Each case asserts on the daemon-log slice its own invocation wrote. - `browse/test/proxy-direct-launch-paths.test.ts` (Linux, real Chromium under Xvfb): the `BROWSE_EXTENSIONS_DIR` persistent context, `launchHeaded()` and the `handoff()` relaunch each ignore an inherited `HTTPS_PROXY` (no CONNECT, `ERR_NAME_NOT_RESOLVED`), and a headed control with a configured proxy still sends the CONNECT to it. - `browse/test/proxy-config.test.ts`: `forcesDirect` per platform, and `directProxyLogLine` for the Linux variable list, the explicit-direct line on macOS/Windows, and the system-proxy line. -- `browse/test/proxy-env.test.ts`, `browse/test/restart-env.test.ts`: the config hash separates an explicit direct from no proxy on macOS and Windows and not on Linux (`extractGlobalFlags` takes the platform); the mismatch refusal for both directions (explicit session against a system-proxy daemon, default session against an explicit-direct daemon); `buildRestartEnv` re-emits `BROWSE_PROXY_URL=direct` from the invocation or the persisted state, and a proxied invocation wins over it. +- `browse/test/proxy-env.test.ts`, `browse/test/restart-env.test.ts`: the config hash separates an explicit direct from no proxy on macOS and Windows and not on Linux (`extractGlobalFlags` takes the platform); the mismatch refusal for both directions (explicit session against a system-proxy daemon, default session against an explicit-direct daemon) and the remedy an env-proxied session gets against a system-proxy daemon (unset the variable, not `BROWSE_PROXY_URL=direct`, which that daemon would refuse); `buildRestartEnv` re-emits `BROWSE_PROXY_URL=direct` from the invocation or the persisted state, and a proxied invocation wins over it. - `browse/test/proxy-env-daemon.test.ts`: through the real CLI and daemon, `BROWSE_PROXY_URL=direct` is written to the daemon state as `proxyDirect` where it bypasses the system proxy and a default session is then refused with the `BROWSE_PROXY_URL differs` lines; on Linux the daemon is reused. This file and `proxy-direct-launch-args.test.ts` join the macOS named-regressions CI job, so the refusal arm and the "no switch without an opt-out" arm run on macOS. `buildHeadedServerEnv` carries the opt-out to the headed server, and BROWSER.md's section is checked against the real switch and log literals. - `browse/test/proxy-direct-launch-args.test.ts`: reads the real command line of the headless Chromium `BrowserManager` launched (from the pid it records) and asserts the switch is present on Linux and absent elsewhere without an opt-out, and present on every platform with `BROWSE_PROXY_URL=direct`; the per-platform expectations are written out, not read back from `forcesDirect`. Skipped on Windows, where no command line reader exists. diff --git a/browse/src/cli.ts b/browse/src/cli.ts index dff8ff441..c7672b158 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -486,9 +486,12 @@ function reportDaemonBusyAndExit(pid: number): never { * #915 refusal for a daemon whose proxy differs from this session's when * either side came from the environment: names the variable, the two * settings and the remedies. Null leaves the --proxy/--headed refusals as they - * were (a headed difference always uses those). + * were (a headed difference always uses those). The BROWSE_PROXY_URL=direct + * remedy is offered only where it matches a proxy-less daemon's hash: on + * Linux, or when that daemon was started with it (#3440); elsewhere following + * it would be refused again. */ -export function proxyMismatchLines(flags: GlobalFlags, state: ServerState, env: NodeJS.ProcessEnv = process.env): string[] | null { +export function proxyMismatchLines(flags: GlobalFlags, state: ServerState, env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): string[] | null { if ((state.mode === 'headed') !== flags.headed) return null; const fromEnv = (source?: string | null) => !!source && source !== '--proxy'; const optedOut = env.BROWSE_PROXY_URL === 'direct' && !flags.proxyUrl; @@ -504,7 +507,8 @@ export function proxyMismatchLines(flags: GlobalFlags, state: ServerState, env: const remedies = [`run 'browse stop' to restart the daemon with this session's proxy settings`]; if (state.proxy) remedies.push(`or run this session with the daemon's ${variable} so the settings match`); else if (flags.explicitDirect && !state.proxyDirect) remedies.push(`or unset BROWSE_PROXY_URL to use the running daemon with the system proxy settings`); - else remedies.push(`or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy`); + else if (state.proxyDirect || platform === 'linux') remedies.push(`or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy`); + else remedies.push(`or unset ${variable} to use the running daemon with the system proxy settings`); return [ `the running browse daemon was started with different proxy settings (${variable} differs), so it was not reused.`, ` daemon: ${daemon}`, diff --git a/browse/src/proxy-config.ts b/browse/src/proxy-config.ts index f31ebb9f9..e02062ebc 100644 --- a/browse/src/proxy-config.ts +++ b/browse/src/proxy-config.ts @@ -133,9 +133,11 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig { } /** - * 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. + * Compute a stable hash of the daemon config (proxyUrl + headed flag, plus + * the NO_PROXY rules beside a proxy and the explicit direct opt-out where it + * bypasses the system proxy settings, #3440) 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. diff --git a/browse/test/proxy-env.test.ts b/browse/test/proxy-env.test.ts index 35eb8a65e..67043ca44 100644 --- a/browse/test/proxy-env.test.ts +++ b/browse/test/proxy-env.test.ts @@ -208,6 +208,16 @@ describe('proxyMismatchLines', () => { expect(lines[3]).toContain('or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy'); }); + test('an env proxy meeting a system-proxy daemon (macOS/Windows) offers unsetting the variable, not BROWSE_PROXY_URL=direct, which that daemon would refuse (#3440)', () => { + const env = { HTTPS_PROXY: 'http://proxy.example:3128' }; + const lines = proxyMismatchLines(extractGlobalFlags(['text'], env, 'darwin'), state(), env, 'darwin')!; + expect(lines[0]).toContain('(HTTPS_PROXY differs)'); + expect(lines[3]).toBe("fix: run 'browse stop' to restart the daemon with this session's proxy settings, or unset HTTPS_PROXY to use the running daemon with the system proxy settings."); + expect(lines[3]).not.toContain('BROWSE_PROXY_URL=direct'); + const linux = proxyMismatchLines(extractGlobalFlags(['text'], env, 'linux'), state(), env, 'linux')!; + expect(linux[3]).toContain('or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy'); + }); + test('a credentialed proxy is redacted on both sides', () => { const lines = proxyMismatchLines(extractGlobalFlags(['text'], { HTTPS_PROXY: 'http://USER:PASSWORD@proxy.example:3128' }), state(), {})!; expect(lines.join('\n')).not.toContain('PASSWORD');