From 3c2adf81f01ec561f1f54fadd0dd1aeaca12b3e6 Mon Sep 17 00:00:00 2001 From: quetaifu Date: Fri, 17 Apr 2026 10:31:09 +0800 Subject: [PATCH 1/2] feat(browse): BROWSE_NO_PROXY env var to bypass system proxy on launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, Chromium (via Playwright) inherits the system HTTP/S proxy by default. When the user runs a local VPN/proxy app (Shadowrocket, ClashX, Surge, etc.) that does TLS interception, Chromium's HTTPS handshake breaks with SSL error 1 / net_error -100, and every goto fails with ERR_ABORTED. The renderer then crashes and kills the browse server, triggering the familiar '[browse] Starting server...' restart loop. curl is unaffected because curl ignores the system proxy by default, so curl-based health checks continue to pass while browse is broken — making this failure mode especially confusing. Opt-in: set BROWSE_NO_PROXY=1 in the environment and browse will pass '--proxy-server=direct://' to Chromium at launch, bypassing the system proxy entirely. Default behavior unchanged (still inherits system proxy) so corporate proxy users are not affected. Repro: # With Shadowrocket running in HTTP-proxy mode on macOS: browse goto https://example.com # ERR_ABORTED / Timeout 15000ms BROWSE_NO_PROXY=1 browse goto https://example.com # 200 OK --- browse/src/browser-manager.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 63d783580..845611091 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -163,6 +163,19 @@ export class BrowserManager { launchArgs.push('--no-sandbox'); } + // System proxy bypass: on macOS, Chromium inherits the system HTTP/S proxy + // by default. Local VPN/proxy apps (Shadowrocket, ClashX, Surge, etc.) + // typically MITM TLS via a local proxy — when the proxy's cert handling + // breaks, every Chromium navigation fails with net_error -100 / + // ERR_ABORTED and the renderer crashes, killing the browse server. + // Setting BROWSE_NO_PROXY=1 tells Chromium to route directly, bypassing + // the system proxy entirely. curl is unaffected by system proxy by + // default, so curl-based health checks can remain working while + // Chromium is broken — hence this needs to be explicit. + if (process.env.BROWSE_NO_PROXY === '1') { + launchArgs.push('--proxy-server=direct://'); + } + if (extensionsDir) { launchArgs.push( `--disable-extensions-except=${extensionsDir}`, From e695f7f58613bf6242e1b2ae8cc850cc7532ec1d Mon Sep 17 00:00:00 2001 From: quetaifu Date: Sun, 19 Apr 2026 12:12:48 +0800 Subject: [PATCH 2/2] auto-detect VPN TUN mode to prevent direct:// from breaking TLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a transparent-proxy VPN (Shadowrocket, ClashX, Surge in "enhanced/TUN mode") captures traffic at the routing layer via a utun interface, the original opt-in patch (BROWSE_NO_PROXY=1 → --proxy-server=direct://) becomes counter-productive: direct:// can't escape TUN, packets still get MITM'd, and Chromium's own cert store rejects the VPN's MITM cert resulting in SSL error code 1, net_error -100 on every HTTPS navigation. The fix in HTTP-proxy-only mode (original scenario) and the fix in TUN mode need opposite Chromium configs: - HTTP-proxy-only: --proxy-server=direct:// bypasses the proxy. - TUN mode: no flag — Chromium inherits system proxy, connects through the VPN's HTTPS proxy explicitly, and accepts the MITM cert signed by a keychain-trusted root. Heuristic: look for a utun interface with an IP in 198.18.x.x (IANA benchmark range, used by Shadowrocket/ClashX for TUN routing). When found, skip direct:// and log an explanation so the user knows why. Backward compatible: - BROWSE_NO_PROXY unset (default): no change, no flag added. - BROWSE_NO_PROXY=1 + no TUN: no change, direct:// applied as before. - BROWSE_NO_PROXY=1 + TUN: NEW — auto-skip with informative log. - BROWSE_NO_PROXY=force: always apply direct:// (escape hatch for users who want the old strict opt-in behavior). Verified on macOS with Shadowrocket TUN mode active (utun6 198.18.0.1): before patch → SSL handshake failed / net_error -100 on all HTTPS. After patch → example.com + authenticated fly.dev pages load correctly. --- browse/src/browser-manager.ts | 48 ++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 845611091..a64eb7005 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -15,6 +15,7 @@ * restores state. Falls back to clean slate on any failure. */ +import { execSync } from 'child_process'; import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { validateNavigationUrl } from './url-validation'; @@ -163,16 +164,45 @@ export class BrowserManager { launchArgs.push('--no-sandbox'); } - // System proxy bypass: on macOS, Chromium inherits the system HTTP/S proxy - // by default. Local VPN/proxy apps (Shadowrocket, ClashX, Surge, etc.) - // typically MITM TLS via a local proxy — when the proxy's cert handling - // breaks, every Chromium navigation fails with net_error -100 / - // ERR_ABORTED and the renderer crashes, killing the browse server. - // Setting BROWSE_NO_PROXY=1 tells Chromium to route directly, bypassing - // the system proxy entirely. curl is unaffected by system proxy by - // default, so curl-based health checks can remain working while - // Chromium is broken — hence this needs to be explicit. + // System proxy handling on macOS. Local VPN/proxy apps (Shadowrocket, + // ClashX, Surge) intercept traffic in TWO modes that need OPPOSITE + // Chromium configs: + // + // HTTP-proxy-only mode: app registers at 127.0.0.1: as an HTTPS + // proxy. Chromium inherits via system proxy → proxy MITMs TLS → + // Chromium rejects the MITM cert → net_error -100. Fix: + // --proxy-server=direct:// bypasses the proxy entirely. + // + // TUN mode: app creates a utun interface (typically 198.18.0.0/15, + // the IANA benchmark range) and captures ALL traffic at the + // routing layer. direct:// does NOT escape — packets still flow + // through TUN and get MITM'd. But when Chromium connects to the + // HTTPS proxy explicitly (inherited from system), the proxy + // serves a cert signed by a CA installed in the macOS keychain + // that Chromium trusts on that path → works. So: no flag works, + // direct:// breaks. + // + // Default: opt-in via BROWSE_NO_PROXY=1 (preserves original behavior). + // Auto-override: if user set =1 but current VPN is in TUN mode + // (detected via utun interface in 198.18.x.x), skip the flag and log. + // This protects users with `export BROWSE_NO_PROXY=1` in their shell + // rc when their VPN app silently switches modes. if (process.env.BROWSE_NO_PROXY === '1') { + let tunMode = false; + if (process.platform === 'darwin') { + try { + const ifOut = execSync('ifconfig', { encoding: 'utf8', timeout: 2000 }); + tunMode = /^utun\d+:[\s\S]*?inet 198\.18\./m.test(ifOut); + } catch { + // ifconfig failed — assume non-TUN (preserves original behavior). + } + } + if (tunMode) { + console.log('[browse] BROWSE_NO_PROXY=1 but VPN appears to be in TUN mode (utun with 198.18.x.x). Skipping --proxy-server=direct:// because it would break TLS in this mode. Set BROWSE_NO_PROXY=force to override.'); + } else { + launchArgs.push('--proxy-server=direct://'); + } + } else if (process.env.BROWSE_NO_PROXY === 'force') { launchArgs.push('--proxy-server=direct://'); }