diff --git a/.github/workflows/free-tests.yml b/.github/workflows/free-tests.yml index 65d14b08f..948b597ee 100644 --- a/.github/workflows/free-tests.yml +++ b/.github/workflows/free-tests.yml @@ -146,7 +146,7 @@ jobs: resolved="$(realpath "$tmp")" echo "temp root: $tmp -> $resolved" test "$resolved" != "${tmp%/}" - files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts) + files=(test/gstack-design-detect.test.ts browse/test/chromium-profile-isolation.test.ts test/cso-git-hardening.test.ts test/plan-count-file-permission.test.ts test/spec-quality-gate-secret-sink.test.ts test/gstack-memory-ingest.test.ts test/regression-issue3024-zsh-sourced-helpers.test.ts test/codex-probe-subcommands.test.ts browse/test/proxy-env-daemon.test.ts browse/test/proxy-direct-launch-args.test.ts) for file in "${files[@]}"; do test -f "$file"; done bun test "${files[@]}" diff --git a/BROWSER.md b/BROWSER.md index 20ce73954..886016ced 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -970,9 +970,47 @@ first one set wins: 3. `HTTPS_PROXY`, then `https_proxy` 4. `HTTP_PROXY`, then `http_proxy` 5. `ALL_PROXY`, then `all_proxy` -6. no proxy (a direct connection) +6. no proxy configured by `$B` (a direct connection on Linux; the system proxy + settings on macOS and Windows) + +Whatever `$B` resolves is what Chromium gets, explicitly: a proxy is passed +as `--proxy-server=`, and a direct connection as +`--proxy-server=direct://`. On Linux, Chromium launched without a proxy +switch reads `HTTPS_PROXY`, `HTTP_PROXY` and `ALL_PROXY` itself, so every +direct connection there carries the switch: with `BROWSE_PROXY_URL=direct` +(or an unparseable variable, below) a set variable is ignored, and to use +one, name it through steps 1-5. Earlier releases left Chromium to read those +variables in the direct case (#3440), so a session that reported a direct +connection could still route through a proxy. Chromium on macOS and Windows +never reads those variables; it follows the system proxy settings (PAC and +WPAD included), and step 6 leaves it to them, so GStack Browser.app behind a +corporate proxy keeps working. `BROWSE_PROXY_URL=direct` bypasses the system +settings too, on every platform; on macOS and Windows it is part of the +daemon's settings, so a running daemon started without it is not reused (for +a headless daemon the refusal below names `BROWSE_PROXY_URL`; a `connect` +daemon gets the generic flags refusal) and a crash restart keeps it. The +daemon log (`browse-daemon.log` next to the state file) records the +effective mode each time the daemon starts, as `[proxy] using from +`, `[proxy] none: direct connection (--proxy-server=direct://; +ignoring HTTPS_PROXY)` (on macOS and Windows, `ignoring the system proxy +settings`), or `[proxy] none: system proxy settings` (an authenticated +`socks5://` upstream logs its `[proxy] upstream test ok` and `[proxy] bridge +listening on 127.0.0.1:` lines instead). A managed Chromium +proxy policy (`ProxyMode`/`ProxySettings`) still overrides the switch; `$B` +cannot see it, so on a managed machine the log line describes what `$B` +asked for, not what the policy enforces. Values from the environment follow the same credential policy as `--proxy`. +A variable without a scheme (curl-style `host:port` or `user:pass@host:port`) +is read as `http://host:port` (port 80 when none is given). For `host:port` +that is what Chromium's own environment reader took it as, so a value +Chromium on Linux used to pick up by itself now reaches the same proxy +through `$B`; `user:pass@host:port` follows curl's reading and is newly +proxied, because Chromium rejects any proxy spec with a userinfo. On macOS and +Windows such a value is now a configured proxy too, where it used to be +ignored with a warning in favour of the system proxy settings. `--proxy` and +`BROWSE_PROXY_URL` still require the scheme. + `socks5h://` is treated as `socks5://`, because Chromium's SOCKS5 already resolves names through the proxy. `NO_PROXY` (or `no_proxy`) is translated to Chromium's bypass list: `example.com` and `.example.com` cover the domain and @@ -982,10 +1020,19 @@ proxy source. When a server starts, `$B` prints one line naming the proxy and 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 + variable doesn't parse even as `http://` plus the value: an unsupported + scheme (`ftp://`), a bad port, a missing host, a path other than a bare + `/`, a query or a fragment (where a `DOMAIN\user` login or a `/` in a + password ends up after URL parsing, so such values are refused for every + source rather than printed unredacted; percent-encode `/ ? #` in + credentials for every scheme, and `\` in `http://` and `https://` + credentials), or credentials that are not valid percent-encoding. A + credential-free value with a path (`http://proxy.corp:3128/pac`) is refused + the same way, where it used to be read with the path dropped. `$B` connects directly (on Linux, + 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 + or set `BROWSE_PROXY_URL=direct` to silence the warning (on macOS and + Windows that also makes Chromium bypass the system proxy settings). A bad `--proxy` or `BROWSE_PROXY_URL` is still an error, because those are explicit browse settings and a silent direct connection could expose your real IP address. - **`the running browse daemon was started with different proxy settings ( differs), so it was not reused.`** @@ -994,7 +1041,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` @@ -1705,7 +1755,7 @@ browse/ │ ├── port-allocator.ts # Fixed 10000-49151 scan range for every long-lived listener (never port:0) │ ├── xprotect-heal.ts # macOS XProtect launch-kill classify + quarantine-clear + bounded reinstall │ ├── socks-bridge.ts # Local 127.0.0.1 SOCKS5 bridge that handles auth handshakes Chromium can't speak -│ ├── proxy-config.ts # --proxy URL parsing + cred resolution (URL vs env, fail-fast on both) +│ ├── proxy-config.ts # --proxy URL parsing + cred resolution (URL vs env, fail-fast on both); direct:// launch switch + proxy-mode log line │ ├── proxy-redact.ts # Cred-redaction helper for any proxy URL surfaced to logs/errors │ ├── xvfb.ts # Xvfb auto-spawn + orphan cleanup with PID + start-time validation │ ├── stealth.ts # Layer C: webdriver mask + window.chrome.* + Notification/Permissions + per-install hardware + toString proxy + automation-global sweep; buildGStackLaunchArgs (GSTACK_* cmdline switches); GSTACK_STEALTH=extended opt-in diff --git a/CHANGELOG.md b/CHANGELOG.md index b97785de3..bcc901d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.91.248.0] - 2026-10-26 + +**`$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 :443` with `BROWSE_PROXY_URL=direct` set. + +### Fixed +- **`browse/src/browser-manager.ts`**: each of `BrowserManager`'s four Chromium launch sites (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. The cookie-import worker (`cookie-import-native-worker.ts`), which launches the user's own browser only to read its cookie store and never navigates, is unchanged. +- **`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. A standard proxy variable without a scheme (curl-style `host:port` or `user:pass@host:port`) is now read as an `http://` proxy (port 80 when none is given). For `host:port` that is what Chromium's own environment reader took it as: before this change browse rejected it with a warning and Chromium on Linux read the variable anyway, so that session was proxied; with the launch now explicit, reading it the same way keeps those sessions on their proxy instead of sending them direct. `user:pass@host:port` follows curl's reading and is newly proxied: Chromium rejects any proxy spec that carries a userinfo (`net/base/proxy_string_util.cc`), so that session was direct before and now sends its credentials to the named proxy. On macOS and Windows the same value is now a configured proxy too, where it used to be ignored in favour of the system proxy settings, so a daemon started under the old warning meets the mismatch refusal once. Only the plain `[user:pass@]host[:port]` shape is read that way; a value with a scheme (including the slash-less `https:user:pass@host:port`, which the URL parser reads as `https://`), a `/`, `\`, `?`, `#`, `@` or whitespace anywhere else in the value, or a bare number goes to the URL parser as written, so a bare number such as `1234` keeps its diagnostic and a one-slash spelling such as `http:/host:3128` is read as the URL parser always read it (`http://host:3128/`). A proxy URL with a path other than a bare `/`, a query or a fragment (a literal `?` or `#` anywhere) is now refused for every source (`--proxy` included), because that is where a `DOMAIN\user` login or a `/` in a password lands after URL parsing and only the userinfo is redacted; percent-encode `/`, `?` and `#` in credentials for every scheme and `\` in `http://`/`https://` credentials (a `socks5://` userinfo keeps a backslash as written). This narrows the environment path: a credential-free value with a path, such as `http://proxy.corp:3128/pac`, used to be read with the path dropped and the session proxied; it now warns and goes direct like any other unusable standard-variable value, and `--proxy`/`BROWSE_PROXY_URL` refuse it. Credentials that are not valid percent-encoding (a literal `%`) are a proxy configuration error instead of a crash, for every source. For a standard variable each of these parse problems is a warning and a direct connection, like any other unusable value; a scheme-less value with credentials beside `BROWSE_PROXY_USER`/`BROWSE_PROXY_PASS` is now refused like its `http://` spelling, which has always been a hard error (that value used to warn and go direct because it did not parse). A value that does not parse even as `http://` (an unsupported scheme, a bad port, a missing host) still warns and connects directly; `--proxy` and `BROWSE_PROXY_URL` still require the scheme. +- **`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 from the session's own flags, 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). 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. + +### Tests +- `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. A scheme-less `HTTPS_PROXY` (`host:port`) is the configured proxy and the CONNECT reaches it (this case fails on the tree before the lenient parse); an `HTTPS_PROXY` that does not parse even as `http://` (`ftp://`) warns and still forces direct, so no CONNECT reaches the proxy it named. 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) 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's flags only, never from the persisted state, and a proxied invocation wins over it; `resolveProxy` reads scheme-less standard variables as `http://` (with and without `user:pass@`, IPv6, no port, surrounding whitespace), leaves a one-slash scheme and a bare number to the URL parser, gives `h:3128` and `http://h:3128` the same config hash, warns for an unsupported scheme, a bad port, a missing host, a credential that would spill past the userinfo (`/`, `\`, `?`, `#`, including a `DOMAIN\user` login) or one that is not percent-encoded, without echoing credentials, refuses a proxy URL with a path other than a bare `/`, a query or a fragment (a bare trailing `?` or `#` included) for every source, keeps the protocol of a slash-less `https:user:pass@host:port` spelling, refuses mixed credential sources for the scheme-less spelling as for the `http://` one, and still fails closed for `--proxy` and `BROWSE_PROXY_URL`. +- `browse/test/proxy-env-daemon.test.ts`: through the real CLI and daemon, a scheme-less `HTTPS_PROXY` with credentials starts the daemon on that `http://` proxy, redacted in the start line and the state, and an `ftp://` value warns and falls back to direct; `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. + ## [1.91.247.0] - 2026-10-26 **/plan-eng-review no longer points its reader at three things its own text never defines.** The plan-eng-review sections judge marked down clarity and actionability for these references. All three live in shared resolvers, so the fixes reach every skill that renders them. The Claude Code hook that backs up AskUserQuestion carried the same stale pointer, so it's fixed too: diff --git a/VERSION b/VERSION index 5edabf294..e035a9e84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.247.0 +1.91.248.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d1068cdb2..c881b7de6 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.247.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.248.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 7ad480813..a6ff2a7c8 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -25,6 +25,7 @@ import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks, resolveConfig } from './config'; import { ensureProjectProfile } from './chromium-profiles'; import { launchWithXProtectHeal } from './xprotect-heal'; +import { DIRECT_PROXY_ARG, forcesDirect } from './proxy-config'; import { readPidStartTime, shouldSpawnXvfb, spawnFreeXvfb, xvfbInstallHint, type XvfbHandle } from './xvfb'; import { withCdpSession } from './cdp-bridge'; import { resultLine } from '../../lib/result-codes'; @@ -614,6 +615,18 @@ export class BrowserManager { this.proxyConfig = cfg; } + /** + * 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 DIRECT_PROXY_ARG on every launch + * path (headless, the extension context, headed, handoff) wherever Chromium + * would otherwise pick a proxy browse did not resolve (forcesDirect). + */ + private proxyLaunchArgs(): string[] { + return !this.proxyConfig && forcesDirect(process.env) ? [DIRECT_PROXY_ARG] : []; + } + /** * Get the ref map for external consumers (e.g., /refs endpoint). */ @@ -635,7 +648,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 +822,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 +2131,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}`); diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 556947f0d..5bea3006e 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -16,7 +16,7 @@ import { safeUnlink, safeUnlinkQuiet, safeKill, isProcessAlive } from './error-h import { readPidStartTime, readPidCmdline } from './xvfb'; import { writeSecureFile, mkdirSecure } from './file-permissions'; import { resolveConfig, ensureStateDir, readVersionHash, isPairAgentEnabled, resolveChromiumProfile } from './config'; -import { resolveProxy, computeConfigHash, ProxyConfigError, PROXY_TROUBLESHOOTING } from './proxy-config'; +import { resolveProxy, computeConfigHash, explicitDirect, ProxyConfigError, PROXY_TROUBLESHOOTING } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; import { spawnTerminalAgent, bunChildFlags } from './terminal-agent-control'; import { profileOwner, ensureProjectProfile, runProfilesCommand } from './chromium-profiles'; @@ -194,6 +194,8 @@ interface ServerState { proxy?: string; proxySource?: string; proxyBypass?: string; + /** #3440: started with BROWSE_PROXY_URL=direct where that bypasses the system proxy settings. */ + proxyDirect?: boolean; /** Xvfb child PID for cleanup on disconnect. */ xvfbPid?: number; xvfbStartTime?: string; @@ -484,24 +486,29 @@ 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; - if (!fromEnv(flags.proxySource) && !fromEnv(state.proxySource) && !flags.proxyWarning && !optedOut) return null; + if (!fromEnv(flags.proxySource) && !fromEnv(state.proxySource) && !flags.proxyWarning && !optedOut && !state.proxyDirect) return null; const describe = (proxy: string | undefined, source: string | null | undefined, bypass: string | null | undefined) => proxy ? `proxy ${proxy} from ${source ?? 'an older browse'}${bypass ? `, bypassing ${bypass}` : ''}` : 'no proxy'; - const daemon = describe(state.proxy, state.proxySource, state.proxyBypass); + const daemon = describe(state.proxy, state.proxySource, state.proxyBypass) + (state.proxyDirect ? ' (BROWSE_PROXY_URL=direct)' : ''); const session = flags.proxyUrl ? describe(flags.redactedProxyUrl, flags.proxySource, flags.proxyBypass) : `no proxy${optedOut ? ' (BROWSE_PROXY_URL=direct)' : flags.proxyWarning ? ' (a proxy variable did not parse)' : ''}`; const sameProxy = !!state.proxy && state.proxy === (flags.proxyUrl ? flags.redactedProxyUrl : undefined); const variable = sameProxy ? 'NO_PROXY' : fromEnv(flags.proxySource) ? flags.proxySource : state.proxySource ?? 'BROWSE_PROXY_URL'; const remedies = [`run 'browse stop' to restart the daemon with this session's proxy settings`]; - if (!state.proxy) remedies.push(`or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy`); - else remedies.push(`or run this session with the daemon's ${variable} so the settings match`); + 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 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}`, @@ -511,9 +518,14 @@ export function proxyMismatchLines(flags: GlobalFlags, state: ServerState, env: ]; } -/** The server env for this invocation's resolved proxy (#915); empty when direct. */ -function proxyServerEnv(flags: Partial> | null | undefined): Record { - if (!flags?.proxyUrl) return {}; +/** + * The server env for this invocation's resolved proxy (#915); empty when + * direct, except BROWSE_PROXY_URL=direct where that bypasses the system proxy + * settings (#3440), so a daemon start or auto-restart carries the opt-out the + * session's hash was computed from. + */ +function proxyServerEnv(flags: Partial> | null | undefined): Record { + if (!flags?.proxyUrl) return flags?.explicitDirect ? { BROWSE_PROXY_URL: 'direct' } : {}; return { BROWSE_PROXY_URL: flags.proxyUrl, ...(flags.proxySource ? { BROWSE_PROXY_SOURCE: flags.proxySource } : {}), @@ -522,10 +534,13 @@ function proxyServerEnv(flags: Partial & Partial>, + globalFlags: Pick & Partial>, ): Record { return { BROWSE_HEADED: '1', @@ -935,8 +950,8 @@ async function ensureServer(flags?: GlobalFlags): Promise { // Same path: existing daemon is plain (no flags) but caller passes // --proxy/--headed. Refuse for the same reason — apply explicitly via // disconnect+reconnect. - if (desiredHash && !state.configHash && (flags?.proxyUrl || flags?.headed)) { - console.error(`[browse] existing daemon was started without --proxy/--headed.`); + if (desiredHash && !state.configHash && (flags?.proxyUrl || flags?.headed || flags?.explicitDirect)) { + console.error(`[browse] existing daemon was started without --proxy/--headed${flags?.explicitDirect ? ' or BROWSE_PROXY_URL=direct' : ''}.`); console.error(`[browse] run 'browse disconnect' first to apply new flags.`); process.exit(1); } @@ -1347,9 +1362,11 @@ export interface GlobalFlags { proxyBypass: string | null; /** Why a standard proxy variable was ignored, printed when a server starts. */ proxyWarning: string | null; + /** BROWSE_PROXY_URL=direct on a platform where it bypasses the system proxy settings (#3440). */ + explicitDirect: boolean; /** Whether --headed was passed. */ headed: boolean; - /** Hash of (proxy + headed) for daemon-mismatch check. */ + /** Hash of (proxy + headed + explicit direct) for daemon-mismatch check. */ configHash: string; /** Redacted form of proxyUrl, safe for logs. */ redactedProxyUrl: string; @@ -1365,7 +1382,7 @@ export interface GlobalFlags { * * Exported for unit tests. */ -export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): GlobalFlags { +export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): GlobalFlags { const out: string[] = []; let proxyUrl: string | null = null; let headed = false; @@ -1398,6 +1415,7 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G // creds embedded (server reads BROWSE_PROXY_URL from env, which passes to // the child without ps-aux exposure). const proxy = resolveProxy(proxyUrl, env); + const direct = !proxy.url && explicitDirect(env, platform); return { args: out, @@ -1405,8 +1423,9 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G proxySource: proxy.source, proxyBypass: proxy.bypass, proxyWarning: proxy.warning, + explicitDirect: direct, headed, - configHash: computeConfigHash({ proxyUrl: proxy.url, headed, noProxyRules: proxy.noProxyRules }), + configHash: computeConfigHash({ proxyUrl: proxy.url, headed, noProxyRules: proxy.noProxyRules, explicitDirect: direct }), redactedProxyUrl: redactProxyUrl(proxy.url), forceRestart, }; diff --git a/browse/src/proxy-config.ts b/browse/src/proxy-config.ts index eb6efb7a1..101892072 100644 --- a/browse/src/proxy-config.ts +++ b/browse/src/proxy-config.ts @@ -36,11 +36,21 @@ export class ProxyConfigError extends Error { } } +function percentDecodable(part: string): boolean { + try { + decodeURIComponent(part); + return true; + } catch { + return false; + } +} + /** * Parse the BROWSE_PROXY_URL string and merge env-supplied creds. * - * @throws ProxyConfigError on malformed URL, unsupported scheme, or - * ambiguous credentials (set in both URL and env). + * @throws ProxyConfigError on malformed URL, unsupported scheme, a path + * (other than a bare `/`), query or fragment, bad credential + * percent-encoding, or ambiguous credentials (set in both URL and env). */ export function parseProxyConfig(opts: { proxyUrl: string; @@ -75,6 +85,17 @@ export function parseProxyConfig(opts: { ); } + // A proxy address has no path, query or fragment. Anything the URL parser + // put there came from a value that was not a proxy URL (a backslash in a + // DOMAIN\user login, a '/' in a password) and would otherwise be printed + // in the clear, because only the userinfo is redacted. + if ((url.pathname !== '' && url.pathname !== '/') || url.search || url.hash || /[?#]/.test(opts.proxyUrl)) { + throw new ProxyConfigError( + 'expected scheme://[user:pass@]host:port with no path; percent-encode any / \\ ? # @ in the credentials', + `invalid proxy URL — unexpected path, query or fragment`, + ); + } + const port = url.port ? parseInt(url.port, 10) : (scheme === 'http' ? 80 : scheme === 'https' ? 443 : 1080); @@ -104,6 +125,12 @@ export function parseProxyConfig(opts: { let userId: string | undefined; let password: string | undefined; + if (urlHasCreds && ![url.username, url.password].every(percentDecodable)) { + throw new ProxyConfigError( + 'percent-encode the credentials (a literal % is %25)', + `invalid proxy URL — credentials are not valid percent-encoding`, + ); + } if (urlHasCreds) { userId = decodeURIComponent(url.username); password = url.password ? decodeURIComponent(url.password) : undefined; @@ -133,9 +160,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. @@ -145,12 +174,15 @@ export function computeConfigHash(opts: { headed: boolean; /** Bypass rules derived from NO_PROXY (resolveProxy's noProxyRules). */ noProxyRules?: string | null; + /** explicitDirect(): BROWSE_PROXY_URL=direct where it bypasses the system proxy settings. */ + explicitDirect?: boolean; }): string { const proxyKey = canonicalizeProxyUrl(opts.proxyUrl); const input = JSON.stringify({ proxy: proxyKey, headed: opts.headed, ...(proxyKey && opts.noProxyRules ? { noProxy: opts.noProxyRules } : {}), + ...(!proxyKey && opts.explicitDirect ? { direct: true } : {}), }); return createHash('sha256').update(input).digest('hex').slice(0, 16); } @@ -228,23 +260,98 @@ export function withLoopbackBypass(rules: string[]): string { /** * The daemon's view of the proxy the CLI resolved: BROWSE_PROXY_URL ('direct' - * means none), its source, and the bypass rules (computed from NO_PROXY when - * a caller other than the CLI set only BROWSE_PROXY_URL). + * means none), its source, the bypass rules (computed from NO_PROXY when a + * caller other than the CLI set only BROWSE_PROXY_URL), and whether an + * explicit direct bypasses the system proxy settings here (explicitDirect). */ -export function serverProxyEnv(env: NodeJS.ProcessEnv): { url: string | undefined; source: string; bypass: string } { +export function serverProxyEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): { url: string | undefined; source: string; bypass: string; direct: boolean } { return { url: env.BROWSE_PROXY_URL === 'direct' ? undefined : env.BROWSE_PROXY_URL || undefined, source: env.BROWSE_PROXY_SOURCE || 'BROWSE_PROXY_URL', bypass: env.BROWSE_PROXY_BYPASS || withLoopbackBypass(noProxyToChromiumRules(env.NO_PROXY || env.no_proxy)), + direct: explicitDirect(env, platform), }; } +/** + * The Chromium switch that makes a direct connection explicit (#3440 item 8). + * Launched without --proxy-server, Chromium reads HTTPS_PROXY, HTTP_PROXY and + * ALL_PROXY (Linux) or the system proxy settings itself. Not --no-proxy-server: + * chrome-headless-shell, the default headless build, ignores that switch and + * still took HTTPS_PROXY (measured on Playwright 1.62.1); the full Chromium + * honors both, and direct:// works on both. + */ +export const DIRECT_PROXY_ARG = '--proxy-server=direct://'; + +/** + * Whether a direct connection is made explicit to Chromium with + * DIRECT_PROXY_ARG. Always on Linux, where the leak in #3440 item 8 lives: + * Chromium launched without --proxy-server reads the proxy variables itself. + * Chromium on macOS and Windows never reads them and follows the system proxy + * settings (PAC and WPAD included), which GStack Browser.app users behind a + * corporate proxy rely on, so there the switch is passed only when the user + * asked for a direct connection with BROWSE_PROXY_URL=direct. + */ +export function forcesDirect(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): boolean { + return platform === 'linux' || explicitDirect(env, platform); +} + +/** + * Whether BROWSE_PROXY_URL=direct changes what Chromium does on this + * platform. On macOS and Windows it is the only thing that makes the daemon + * bypass the system proxy settings, so it is part of the daemon's config hash + * (a reused daemon started without it would keep the system proxy while the + * CLI reports direct), is recorded in the daemon state and is re-emitted on an + * auto-restart from the invocation's own flags, which the hash gate already + * matched against that daemon. On Linux every direct connection already carries + * DIRECT_PROXY_ARG, so the opt-out changes nothing and hashes like no proxy. + */ +export function explicitDirect(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): boolean { + return platform !== 'linux' && env.BROWSE_PROXY_URL === 'direct'; +} + +/** + * The daemon's launch log line when no proxy is configured: the switch + * Chromium was given and what it ignores for it (each standard variable set on + * Linux, the system proxy settings elsewhere), or that Chromium follows the + * system proxy settings. + */ +export function directProxyLogLine(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): string { + if (!forcesDirect(env, platform)) { + return `[browse] [proxy] none: system proxy settings (Chromium's default on ${platform}; BROWSE_PROXY_URL=direct connects directly)`; + } + const ignored = platform === 'linux' ? STANDARD_PROXY_VARS.filter(name => env[name]) : ['the system proxy settings']; + return `[browse] [proxy] none: direct connection (${DIRECT_PROXY_ARG}${ignored.length ? `; ignoring ${ignored.join(', ')}` : ''})`; +} + +/** + * The scheme-less proxy shapes curl and Chromium's environment reader take as + * an HTTP proxy: `[user:pass@]host[:port]` with a name, dotted address or + * bracketed IPv6 host. Anything else (a scheme, a path, query or fragment + * character, a backslash, whitespace inside, a bare number) goes to the URL + * parser as written, so a bare number such as `1234` keeps the diagnostic + * it had and `http:/host:3128` reads as it always did; parseProxyConfig + * then rejects any path, query or fragment, so no part of a password can + * land outside the redacted userinfo. + * A userinfo that opens with a known scheme (`https:bob@host:3128`, the + * slash-less spelling the URL parser reads as an `https://` URL with user + * `bob`) is a scheme, not credentials, so it is excluded too. + */ +const SCHEMELESS_PROXY_RE = /^(?:[^/\\?#@\s]+@)?(?:\[[0-9a-f:.]+\]|(?=[^:]*[a-z.])[^/\\?#@\s:[\]]+)(?::\d+)?$/i; +const LEADING_SCHEME_RE = /^(?:https?|socks5h?):/i; + /** * Resolve the proxy for this invocation (#915). Env URLs go through - * parseProxyConfig, so the cred policy applies. A standard variable that does - * not parse (curl-style `host:port`, an unsupported scheme) is ignored with a - * warning instead of failing every command; --proxy and BROWSE_PROXY_URL are - * explicit browse settings and still fail closed. + * parseProxyConfig, so the cred policy applies. A standard variable in a + * scheme-less shape (curl-style `host:port` or `user:pass@host:port`, + * SCHEMELESS_PROXY_RE) is read as an `http://` proxy, the way curl takes it. + * For `host:port` that is also what Chromium's own environment reader did, + * so a session that worked through Chromium reading the variable keeps + * working now that the launch is explicit (#3440); `user:pass@host:port` is + * newly proxied, because Chromium rejects any proxy spec with a userinfo. One that still does not parse (an unsupported scheme, a bad port, + * a missing host) is ignored with a warning instead of failing every command; + * --proxy and BROWSE_PROXY_URL are explicit browse settings and still fail + * closed, scheme required. */ export function resolveProxy(flagUrl: string | null, env: NodeJS.ProcessEnv): ResolvedProxy { const direct = (warning: string | null = null): ResolvedProxy => ({ url: null, source: null, noProxyRules: null, bypass: null, warning }); @@ -258,13 +365,14 @@ export function resolveProxy(flagUrl: string | null, env: NodeJS.ProcessEnv): Re } else { const name = STANDARD_PROXY_VARS.find(key => env[key]); if (!name) return direct(); - [source, raw] = [name, env[name]!]; + const value = env[name]!.trim(); + [source, raw] = [name, SCHEMELESS_PROXY_RE.test(value) && !LEADING_SCHEME_RE.test(value) ? `http://${value}` : value]; try { parseProxyConfig({ proxyUrl: raw }); } catch (err) { if (!(err instanceof ProxyConfigError)) throw err; return direct(`${source} is not a proxy URL browse can use (${err.message}; ${err.hint}), so browse connects directly. ` + - `fix: export ${source}=http://host:port (or socks5://), or set BROWSE_PROXY_URL=direct to silence this. see ${PROXY_TROUBLESHOOTING}`); + `fix: export ${source}=http://host:port (or socks5://), or set BROWSE_PROXY_URL=direct to silence this (on macOS and Windows that also makes Chromium bypass the system proxy settings). see ${PROXY_TROUBLESHOOTING}`); } } const parsed = parseProxyConfig({ proxyUrl: raw, envUser: env.BROWSE_PROXY_USER, envPass: env.BROWSE_PROXY_PASS, source }); diff --git a/browse/src/server.ts b/browse/src/server.ts index 3bcaeab35..5cb5507b8 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -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'; @@ -1888,7 +1888,7 @@ export async function start() { // local 127.0.0.1 bridge to the authenticated upstream (Chromium can't do // SOCKS5 auth itself); HTTP/HTTPS or unauthenticated SOCKS5 pass through. let proxyBridge: BridgeHandle | null = null; - const { url: proxyUrl, source: proxySource, bypass: proxyBypass } = serverProxyEnv(process.env); + const { url: proxyUrl, source: proxySource, bypass: proxyBypass, direct: proxyDirect } = serverProxyEnv(process.env); if (proxyUrl) { let parsed; try { @@ -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(); }); @@ -2019,7 +2019,7 @@ export async function start() { // D2 daemon-mismatch detection: CLI computes the same hash from its // resolved flags and refuses if it differs from this stored value. ...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}), - ...(proxyUrl ? { proxy: redactProxyUrl(proxyUrl), proxySource, proxyBypass } : {}), // #915 refusal detail + ...(proxyUrl ? { proxy: redactProxyUrl(proxyUrl), proxySource, proxyBypass } : proxyDirect ? { proxyDirect } : {}), // #915/#3440 refusal detail // Xvfb child PID + start-time + display so disconnect (or a future // daemon launch on this state file) can validate-then-cleanup orphans // without clobbering a recycled PID. diff --git a/browse/test/cli-supervisor.test.ts b/browse/test/cli-supervisor.test.ts index a689a025a..eed733983 100644 --- a/browse/test/cli-supervisor.test.ts +++ b/browse/test/cli-supervisor.test.ts @@ -92,7 +92,7 @@ function harness(opts: { let clock = 1_000_000, sleeps = 0, tick = 0, exiting = false, starts = 0; const calls = { startEnv: [] as Record[], agents: [] as number[], log: [] as string[], warn: [] as string[], error: [] as string[] }; const deps: HeadedSupervisorDeps = { - env: buildHeadedServerEnv({ proxyUrl: 'socks5://127.0.0.1:9050', configHash: 'abc123' }), + env: buildHeadedServerEnv({ proxyUrl: 'socks5://127.0.0.1:9050', configHash: 'abc123', explicitDirect: false }), tickMs: opts.tickMs ?? 30_000, backoffMs: [1000, 2000, 4000, 8000, 30000], daemonLog: '/state/browse-daemon.log', @@ -181,7 +181,7 @@ describe('runHeadedSupervisor (behavior)', () => { describe('buildHeadedServerEnv', () => { test('omits proxy and config hash when this invocation has none', () => { - expect(buildHeadedServerEnv({ proxyUrl: null, configHash: '' })).toEqual({ BROWSE_HEADED: '1', BROWSE_PORT: '34567', BROWSE_PARENT_PID: '0' }); + expect(buildHeadedServerEnv({ proxyUrl: null, configHash: '', explicitDirect: false })).toEqual({ BROWSE_HEADED: '1', BROWSE_PORT: '34567', BROWSE_PARENT_PID: '0' }); }); }); diff --git a/browse/test/helpers/connect-proxy.ts b/browse/test/helpers/connect-proxy.ts new file mode 100644 index 000000000..bfd0ea2c7 --- /dev/null +++ b/browse/test/helpers/connect-proxy.ts @@ -0,0 +1,28 @@ +/** + * A logging HTTP CONNECT proxy on loopback for the #3440 item 8 tests: every + * CONNECT it receives is recorded and refused with 502, so a navigation whose + * target name does not resolve can reach the proxy only when Chromium was + * told to use it. close() stops every proxy a file opened. + */ +import * as http from 'node:http'; + +export interface ConnectProxy { server: http.Server; url: string; connects: string[] } + +const opened: ConnectProxy[] = []; + +export async function connectProxy(): Promise { + 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(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 }; + opened.push(entry); + return entry; +} + +export async function closeConnectProxies(): Promise { + for (const { server } of opened.splice(0)) await new Promise(resolve => server.close(() => resolve())); +} diff --git a/browse/test/proxy-config.test.ts b/browse/test/proxy-config.test.ts index 88b4cf633..bc2725c8f 100644 --- a/browse/test/proxy-config.test.ts +++ b/browse/test/proxy-config.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { parseProxyConfig, computeConfigHash, ProxyConfigError } from '../src/proxy-config'; +import { parseProxyConfig, computeConfigHash, directProxyLogLine, explicitDirect, forcesDirect, serverProxyEnv, ProxyConfigError } from '../src/proxy-config'; import { extractGlobalFlags } from '../src/cli'; describe('parseProxyConfig', () => { @@ -187,3 +187,65 @@ describe('extractGlobalFlags', () => { expect(a.configHash).not.toBe(b.configHash); }); }); + +describe('forcesDirect (#3440 item 8)', () => { + test('Linux always makes a direct connection explicit, because Chromium reads the proxy variables there', () => { + expect(forcesDirect({}, 'linux')).toBe(true); + expect(forcesDirect({ HTTPS_PROXY: 'http://b:2' }, 'linux')).toBe(true); + }); + + test('macOS and Windows keep the system proxy settings unless the user chose direct', () => { + for (const platform of ['darwin', 'win32'] as const) { + expect(forcesDirect({}, platform)).toBe(false); + expect(forcesDirect({ HTTPS_PROXY: 'http://b:2' }, platform)).toBe(false); + expect(forcesDirect({ BROWSE_PROXY_URL: 'direct' }, platform)).toBe(true); + } + }); +}); + +describe('explicitDirect (#3440 item 8)', () => { + test('is the opt-out on macOS and Windows and nothing on Linux, where direct is already explicit', () => { + expect(explicitDirect({ BROWSE_PROXY_URL: 'direct' }, 'darwin')).toBe(true); + expect(explicitDirect({ BROWSE_PROXY_URL: 'direct' }, 'win32')).toBe(true); + expect(explicitDirect({ BROWSE_PROXY_URL: 'direct' }, 'linux')).toBe(false); + expect(explicitDirect({ BROWSE_PROXY_URL: 'http://p:1' }, 'darwin')).toBe(false); + expect(explicitDirect({}, 'darwin')).toBe(false); + }); + + test('the config hash separates an explicit direct from no proxy, and ignores it beside a proxy', () => { + const plain = computeConfigHash({ proxyUrl: null, headed: false }); + expect(computeConfigHash({ proxyUrl: null, headed: false, explicitDirect: false })).toBe(plain); + expect(computeConfigHash({ proxyUrl: null, headed: false, explicitDirect: true })).not.toBe(plain); + expect(computeConfigHash({ proxyUrl: 'http://p:1', headed: false, explicitDirect: true })) + .toBe(computeConfigHash({ proxyUrl: 'http://p:1', headed: false })); + }); + + test('the daemon reads the opt-out from its own environment for the state file, on the platforms where it matters', () => { + expect(serverProxyEnv({ BROWSE_PROXY_URL: 'direct' }, 'darwin').direct).toBe(true); + expect(serverProxyEnv({ BROWSE_PROXY_URL: 'direct' }, 'win32').direct).toBe(true); + expect(serverProxyEnv({ BROWSE_PROXY_URL: 'direct' }, 'linux').direct).toBe(false); + expect(serverProxyEnv({}, 'darwin').direct).toBe(false); + expect(serverProxyEnv({ BROWSE_PROXY_URL: 'http://p:1' }, 'darwin').direct).toBe(false); + }); +}); + +describe('directProxyLogLine (#3440 item 8)', () => { + test('names the switch alone when no standard variable is set', () => { + expect(directProxyLogLine({}, 'linux')).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' }, 'linux'); + expect(line).toBe('[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring HTTPS_PROXY, all_proxy)'); + }); + + test('an explicit direct on macOS or Windows names the system proxy settings, never variables Chromium did not read', () => { + const line = directProxyLogLine({ HTTPS_PROXY: 'http://b:2', BROWSE_PROXY_URL: 'direct' }, 'win32'); + expect(line).toBe('[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring the system proxy settings)'); + }); + + test('no proxy on macOS or Windows reports the system proxy settings Chromium follows', () => { + expect(directProxyLogLine({ HTTPS_PROXY: 'http://b:2' }, 'darwin')) + .toBe("[browse] [proxy] none: system proxy settings (Chromium's default on darwin; BROWSE_PROXY_URL=direct connects directly)"); + }); +}); diff --git a/browse/test/proxy-direct-launch-args.test.ts b/browse/test/proxy-direct-launch-args.test.ts new file mode 100644 index 000000000..ed128aba3 --- /dev/null +++ b/browse/test/proxy-direct-launch-args.test.ts @@ -0,0 +1,84 @@ +/** + * #3440 item 8, the platform gate at the real boundary: the command line the + * headless launch hands Chromium. proxy-config.test.ts proves forcesDirect() + * per platform and proxy-direct-launch*.test.ts prove the switch's effect on + * traffic, but both run that proof on Linux, where the switch is always due, + * so neither can see the arm that protects GStack Browser.app users behind a + * corporate proxy: on macOS and Windows a launch with no browse proxy must + * NOT carry --proxy-server=direct:// (Chromium keeps the system proxy + * settings) unless the user set BROWSE_PROXY_URL=direct. The expected values + * are written out per platform here, not read back from forcesDirect(), so a + * launch path that stops consulting the predicate and a predicate that + * changes its answer both fail. Chromium's command line comes from the pid + * BrowserManager records for its own launch (/proc on Linux, ps on macOS); + * readPidCmdline has no Windows source, so the file skips there. + * + * Added by: /ship test coverage audit (Capy), 2026-10-25, for #3440 item 8. + * Value: protects=the headless Chromium command line carries --proxy-server=direct:// on Linux and with BROWSE_PROXY_URL=direct, and omits it on macOS without it; + * fails_when=proxyLaunchArgs stops consulting forcesDirect, hardcodes the switch, or drops it for an explicit direct; + * why_new=proxy-config.test.ts tests the predicate alone and the Linux e2e cannot observe the macOS/Windows omission that keeps system proxy settings; + * seam=none; no_claim=no navigation: the switch's effect on traffic is proxy-direct-launch*.test.ts (Linux); Windows and the system proxy itself are not exercised + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { BrowserManager } from '../src/browser-manager'; +import { readPidCmdline } from '../src/xvfb'; + +const DIRECT_SWITCH = '--proxy-server=direct://'; +const ENV_KEYS = [ + 'BROWSE_STATE_FILE', 'BROWSE_EXTENSIONS_DIR', 'GSTACK_CHROMIUM_PATH', + 'BROWSE_PROXY_URL', 'BROWSE_PROXY_USER', 'BROWSE_PROXY_PASS', 'NO_PROXY', 'no_proxy', + 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy', +] as const; + +let saved: Record; +let scratch = ''; +let bm: BrowserManager | null = null; + +beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-proxy-direct-args-')); + process.env.BROWSE_STATE_FILE = path.join(scratch, 'state', 'browse.json'); + // Nothing listens here: the test reads the command line, never navigates. + process.env.HTTPS_PROXY = 'http://127.0.0.1:9'; + bm = null; +}); + +afterEach(async () => { + try { await Promise.race([bm?.close(), new Promise(resolve => setTimeout(resolve, 5000))]); } catch {} + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + fs.rmSync(scratch, { recursive: true, force: true }); +}, 15000); + +/** The switches of the Chromium this manager launched, from the pid it recorded. */ +async function launchedSwitches(manager: BrowserManager): Promise { + await manager.launch(); + const info = manager.getChromiumProcInfo(); + if (!info) throw new Error('BrowserManager recorded no Chromium pid for its own headless launch'); + const cmdline = readPidCmdline(info.pid); + if (!cmdline) throw new Error(`no command line readable for Chromium pid ${info.pid}`); + return cmdline.split(' ').filter(arg => arg.startsWith('--')); +} + +describe.skipIf(process.platform === 'win32')('#3440 item 8: the headless Chromium command line carries direct:// only where it is due', () => { + test('no browse proxy with HTTPS_PROXY set: the switch is present on Linux and absent elsewhere, so macOS keeps the system proxy settings', async () => { + bm = new BrowserManager(); + const switches = await launchedSwitches(bm); + expect(switches.includes(DIRECT_SWITCH)).toBe(process.platform === 'linux'); + expect(switches.filter(arg => arg.startsWith('--proxy-server=')).length).toBeLessThanOrEqual(1); + }, 60_000); + + test('BROWSE_PROXY_URL=direct with HTTPS_PROXY set: the switch is present on every platform', async () => { + process.env.BROWSE_PROXY_URL = 'direct'; + bm = new BrowserManager(); + const switches = await launchedSwitches(bm); + expect(switches).toContain(DIRECT_SWITCH); + expect(switches.filter(arg => arg.startsWith('--proxy-server=')).length).toBe(1); + }, 60_000); +}); diff --git a/browse/test/proxy-direct-launch-paths.test.ts b/browse/test/proxy-direct-launch-paths.test.ts new file mode 100644 index 000000000..65bff98f0 --- /dev/null +++ b/browse/test/proxy-direct-launch-paths.test.ts @@ -0,0 +1,105 @@ +/** + * #3440 item 8 on the launch paths the CLI e2e (proxy-direct-launch.test.ts) + * never reaches: the BROWSE_EXTENSIONS_DIR persistent context, launchHeaded() + * and the handoff() relaunch. The three have drifted apart before (stealth + * args, profile resolution), so each is driven here through the real + * BrowserManager with HTTPS_PROXY inherited by Chromium, the way the daemon + * inherits the user's environment. A logging CONNECT proxy on loopback stands + * in for that proxy and the target name does not resolve, so a CONNECT at the + * proxy is the only way the navigation can get past DNS: direct means + * ERR_NAME_NOT_RESOLVED and an empty CONNECT log. The headed control with a + * configured proxy shows the same harness sees a CONNECT when one is due. + * + * Added by: /ship test coverage audit (Capy), 2026-10-25, for #3440 item 8. + * Value: protects=extension, headed and handoff launches ignore HTTPS_PROXY unless browse configured a proxy; fails_when=one path drops --proxy-server=direct:// or the direct switch overrides a configured proxy; why_new=proxy-direct-launch.test.ts covers only headless launch() through the CLI; seam=none; no_claim=Linux with Xvfb only; macOS/Windows system proxy settings are not exercised + */ +import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { BrowserManager } from '../src/browser-manager'; +import { closeConnectProxies, connectProxy, type ConnectProxy } from './helpers/connect-proxy'; + +const TARGET = 'proxied.gstack-3440.test'; +const ENV_KEYS = [ + 'DISPLAY', 'WAYLAND_DISPLAY', 'BROWSE_HEADED', 'CHROMIUM_PROFILE', 'BROWSE_STATE_FILE', 'BROWSE_EXTENSIONS_DIR', + 'BROWSE_PROXY_URL', 'BROWSE_PROXY_USER', 'BROWSE_PROXY_PASS', 'NO_PROXY', 'no_proxy', + 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy', +] as const; + +let saved: Record; +let scratch = ''; +let bm: BrowserManager | null = null; + +// One fresh environment proxy per test: a CONNECT leaked by one launch path +// must fail that test alone, never a later one. +let envProxy: ConnectProxy; + +afterAll(closeConnectProxies); + +beforeEach(async () => { + saved = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-proxy-direct-paths-')); + process.env.BROWSE_STATE_FILE = path.join(scratch, 'state', 'browse.json'); + process.env.CHROMIUM_PROFILE = path.join(scratch, 'profile'); + envProxy = await connectProxy(); + process.env.HTTPS_PROXY = envProxy.url; + bm = null; +}); + +afterEach(async () => { + try { await Promise.race([bm?.close(), new Promise(resolve => setTimeout(resolve, 5000))]); } catch {} + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + fs.rmSync(scratch, { recursive: true, force: true }); +}, 15000); + +/** What Chromium reports for the unresolvable target: the net error, or 'loaded'. */ +async function navigationError(manager: BrowserManager): Promise { + return manager.getPage().goto(`https://${TARGET}/`, { timeout: 20_000 }).then(() => 'loaded', e => String(e)); +} + +describe.skipIf(process.platform !== 'linux')('#3440 item 8: every launch path tells Chromium direct:// (Linux, real Chromium)', () => { + test('BROWSE_EXTENSIONS_DIR persistent context: HTTPS_PROXY is ignored, no CONNECT reaches the proxy', async () => { + const ext = path.join(scratch, 'ext'); + fs.mkdirSync(ext); + fs.writeFileSync(path.join(ext, 'manifest.json'), JSON.stringify({ manifest_version: 3, name: 'probe', version: '1' })); + process.env.BROWSE_EXTENSIONS_DIR = ext; + bm = new BrowserManager(); + await bm.launch(); + expect(await navigationError(bm)).toContain('ERR_NAME_NOT_RESOLVED'); + expect(envProxy.connects).toEqual([]); + }, 60_000); + + test('launchHeaded(): the full Chromium ignores HTTPS_PROXY, no CONNECT reaches the proxy', async () => { + bm = new BrowserManager(); + await bm.launchHeaded(); + expect(bm.getIsHeaded()).toBe(true); + expect(await navigationError(bm)).toContain('ERR_NAME_NOT_RESOLVED'); + expect(envProxy.connects).toEqual([]); + }, 60_000); + + test('handoff(): the relaunched headed Chromium ignores HTTPS_PROXY, no CONNECT reaches the proxy', async () => { + bm = new BrowserManager(); + await bm.launch(); + expect(await bm.handoff('proxy check')).toContain('HANDOFF:'); + expect(bm.getIsHeaded()).toBe(true); + expect(await navigationError(bm)).toContain('ERR_NAME_NOT_RESOLVED'); + expect(envProxy.connects).toEqual([]); + }, 60_000); + + test('control: a configured proxy on the headed path still receives the CONNECT, and HTTPS_PROXY none', async () => { + const configured = await connectProxy(); + bm = new BrowserManager(); + bm.setProxyConfig({ server: configured.url, bypass: 'localhost,127.0.0.1,[::1]' }); + await bm.launchHeaded(); + expect(await navigationError(bm)).toContain('ERR_TUNNEL_CONNECTION_FAILED'); + // Full Chromium retries a refused tunnel and tunnels its own background + // traffic too, so the target is present rather than the only entry. + expect(configured.connects).toContain(`${TARGET}:443`); + expect(envProxy.connects).toEqual([]); + }, 60_000); +}); diff --git a/browse/test/proxy-direct-launch.test.ts b/browse/test/proxy-direct-launch.test.ts new file mode 100644 index 000000000..cbcdc44f1 --- /dev/null +++ b/browse/test/proxy-direct-launch.test.ts @@ -0,0 +1,129 @@ +/** + * #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. Linux only: that is + * where Chromium reads the variables (forcesDirect); macOS and Windows follow + * the system proxy settings, which proxy-config.test.ts covers as a unit. + */ +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { closeConnectProxies, connectProxy } from './helpers/connect-proxy'; + +const CLI = path.resolve(import.meta.dir, '../src/cli.ts'); +const TARGET = 'proxied.gstack-3440.test'; + +let scratch = ''; +let stateFile = ''; +let daemonLog = ''; + +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 {} + } + await closeConnectProxies(); + fs.rmSync(scratch, { recursive: true, force: true }); +}); + +const readDaemonLog = () => fs.existsSync(daemonLog) ? fs.readFileSync(daemonLog, 'utf8') : ''; + +async function browse(args: string[], extraEnv: Record) { + const env: Record = {}; + 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); + // browse-daemon.log is append-mode and shared by every case, so `log` is + // only what this invocation's daemon wrote: an earlier case's line can never + // satisfy a later case's assertion. + const logStart = readDaemonLog().length; + 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, log: readDaemonLog().slice(logStart) }; +} + +describe.skipIf(process.platform !== 'linux')('#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(r.log).toContain('[browse] [proxy] none: direct connection (--proxy-server=direct://; ignoring HTTPS_PROXY)'); + expect(JSON.parse(fs.readFileSync(stateFile, 'utf8')).proxy).toBeUndefined(); + }, 120_000); + + // Added by: /ship test coverage audit (Capy), 2026-10-25, for #3440 item 8. + // Value: protects=a scheme-less HTTPS_PROXY (host:port, what Chromium's own environment reader took as an HTTP proxy before the launch was explicit; a user:pass@ shape is newly proxied, Chromium rejected it) still reaches that proxy, now through browse's own configuration; + // fails_when=resolveProxy stops reading scheme-less variables as http:// and the session goes direct with only a warning; why_new=proxy-env-daemon's scheme-less case targets loopback, which Chromium bypasses anyway; seam=none + test('a scheme-less HTTPS_PROXY is the configured proxy and the CONNECT reaches the host:port Chromium would have used by itself', async () => { + const envProxy = await connectProxy(); + const env = { HTTPS_PROXY: envProxy.url.replace(/^http:\/\//, '') }; + const r = await browse(['goto', `https://${TARGET}/`], env); + expect(r.stderr).toContain(`Starting server with proxy ${envProxy.url}/ from HTTPS_PROXY...`); + expect(r.stderr).not.toContain('warning: HTTPS_PROXY is not a proxy URL'); + expect(envProxy.connects).toEqual([`${TARGET}:443`]); + expect(r.log).toContain(`[browse] [proxy] using ${envProxy.url}/ from HTTPS_PROXY`); + expect(JSON.parse(fs.readFileSync(stateFile, 'utf8')).proxy).toBe(`${envProxy.url}/`); + }, 120_000); + + // Value: protects=the warning fallback ("browse connects directly") is true for a variable that does not parse even as http://: the session never carries it; + // fails_when=the direct switch is dropped or the unparseable variable is forwarded to Chromium; seam=none + test('an unparseable HTTPS_PROXY (unsupported scheme) warns and the daemon still forces direct: no CONNECT reaches the proxy it named', async () => { + const envProxy = await connectProxy(); + const env = { HTTPS_PROXY: envProxy.url.replace(/^http:\/\//, 'ftp://') }; + const r = await browse(['goto', `https://${TARGET}/`], env); + expect(r.stderr).toContain('warning: HTTPS_PROXY is not a proxy URL browse can use'); + expect(r.stderr).toContain('Starting server...'); + expect(r.out).toContain('ERR_NAME_NOT_RESOLVED'); + expect(envProxy.connects).toEqual([]); + expect(r.log).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(r.log).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); +}); diff --git a/browse/test/proxy-env-daemon.test.ts b/browse/test/proxy-env-daemon.test.ts index 69e1a00d8..785eef9bd 100644 --- a/browse/test/proxy-env-daemon.test.ts +++ b/browse/test/proxy-env-daemon.test.ts @@ -122,10 +122,22 @@ describe.skipIf(process.platform === 'win32')('#915 proxy from the environment, expect(proxiedHosts.length).toBe(before); }, 120_000); - test('a curl-style HTTPS_PROXY warns and falls back to direct instead of failing', async () => { + test('a scheme-less HTTPS_PROXY is the configured http:// proxy (#3440), redacted in the start line and the state', async () => { + expect((await browse(['stop'], {})).code).toBe(0); + const r = await browse(['goto', `http://127.0.0.1:${direct.port}/`], { HTTPS_PROXY: 'user:secret@proxy.corp:8080' }); + expect(r.code, r.out).toBe(0); + expect(r.stderr).toContain('Starting server with proxy http://***:***@proxy.corp:8080/ from HTTPS_PROXY...'); + expect(r.stderr).not.toContain('warning: HTTPS_PROXY is not a proxy URL'); + expect(r.out).not.toContain('secret'); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.proxy).toBe('http://***:***@proxy.corp:8080/'); + expect(state.proxySource).toBe('HTTPS_PROXY'); + }, 120_000); + + test('an HTTPS_PROXY that does not parse even as http:// warns and falls back to direct instead of failing', async () => { expect((await browse(['stop'], {})).code).toBe(0); const before = proxiedHosts.length; - const r = await browse(['goto', `http://127.0.0.1:${direct.port}/`], { HTTPS_PROXY: 'user:secret@proxy.corp:8080' }); + const r = await browse(['goto', `http://127.0.0.1:${direct.port}/`], { HTTPS_PROXY: 'ftp://user:secret@proxy.corp:21' }); expect(r.code, r.out).toBe(0); expect(r.stderr).toContain('warning: HTTPS_PROXY is not a proxy URL browse can use'); expect(r.stderr).toContain('BROWSE_PROXY_URL=direct'); @@ -134,4 +146,35 @@ describe.skipIf(process.platform === 'win32')('#915 proxy from the environment, expect(proxiedHosts.length).toBe(before); expect(JSON.parse(fs.readFileSync(stateFile, 'utf8')).proxy).toBeUndefined(); }, 120_000); + + // Added by: /ship test coverage audit (Capy), 2026-10-25, for #3440 item 8. + // Value: protects=the daemon state records BROWSE_PROXY_URL=direct where it bypasses the system proxy settings, and a default session there is refused, not handed the opt-out; + // fails_when=server.ts drops proxyDirect from the state, the CLI hash stops carrying the opt-out, or Linux starts refusing a reused daemon over it; + // why_new=proxy-env.test.ts drives proxyMismatchLines with a hand-built state; nothing reads the state the daemon wrote or runs the refusal through the CLI; + // seam=none; no_claim=Chromium's proxy behaviour (loopback page only); the proxyDirect=true write and the refusal arm run on the macOS named-regressions CI job and on a Windows checkout, the Linux lanes run the reuse arm + test('BROWSE_PROXY_URL=direct is daemon state where it bypasses the system proxy, and a default session is refused there (#3440)', async () => { + expect((await browse(['stop'], {})).code).toBe(0); + const r = await browse(['goto', `http://127.0.0.1:${direct.port}/`], { BROWSE_PROXY_URL: 'direct' }); + expect(r.code, r.out).toBe(0); + expect(r.stderr).toContain('Starting server...'); + const pid = daemonPid(); + const bypassesSystemProxy = process.platform !== 'linux'; + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + expect(state.proxy).toBeUndefined(); + if (bypassesSystemProxy) expect(state.proxyDirect).toBe(true); + else expect(state.proxyDirect).toBeUndefined(); + const plain = await browse(['text'], {}); + if (bypassesSystemProxy) { + expect(plain.code).toBe(1); + expect(plain.stderr).toContain('different proxy settings (BROWSE_PROXY_URL differs)'); + expect(plain.stderr).toContain('daemon: no proxy (BROWSE_PROXY_URL=direct)'); + expect(plain.stderr).toContain('session: no proxy'); + expect(plain.stderr).toContain('or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy'); + } else { + expect(plain.code, plain.out).toBe(0); + expect(plain.stderr).not.toContain('Starting server'); + expect(plain.stdout).toContain('direct loopback page'); + } + expect(daemonPid()).toBe(pid); + }, 120_000); }); diff --git a/browse/test/proxy-env.test.ts b/browse/test/proxy-env.test.ts index 1109c8b24..bd9ebbbc8 100644 --- a/browse/test/proxy-env.test.ts +++ b/browse/test/proxy-env.test.ts @@ -3,7 +3,7 @@ * mismatch refusal. The daemon round trip is in proxy-env-daemon.test.ts. */ import { describe, expect, test } from 'bun:test'; -import { computeConfigHash, noProxyToChromiumRules, parseProxyConfig, resolveProxy, serverProxyEnv, withLoopbackBypass } from '../src/proxy-config'; +import { DIRECT_PROXY_ARG, computeConfigHash, directProxyLogLine, noProxyToChromiumRules, parseProxyConfig, resolveProxy, serverProxyEnv, withLoopbackBypass } from '../src/proxy-config'; import { buildHeadedServerEnv, buildRestartEnv, extractGlobalFlags, proxyMismatchLines, withLoopbackNoProxy } from '../src/cli'; describe('resolveProxy precedence', () => { @@ -64,20 +64,89 @@ describe('resolveProxy precedence', () => { } }); - test('a standard variable that does not parse warns and falls back to direct, without echoing it', () => { - for (const value of ['proxy.corp:8080', 'user:secret@proxy.corp:8080', 'ftp://proxy.corp:21', 'http://proxy.corp:99999']) { + test('a scheme-less standard variable is read as an http:// proxy, as curl and Chromium read it (#3440), with creds kept and redactable', () => { + expect(resolveProxy(null, { HTTPS_PROXY: 'proxy.corp:8080' })).toMatchObject({ url: 'http://proxy.corp:8080/', source: 'HTTPS_PROXY', warning: null }); + const withCreds = resolveProxy(null, { https_proxy: 'USER:PASSWORD@proxy.corp:8080' }); + expect(withCreds).toMatchObject({ source: 'https_proxy', warning: null }); + expect(withCreds.url).toBe(['http://USER', 'PASSWORD@proxy.corp:8080/'].join(':')); + expect(resolveProxy(null, { ALL_PROXY: '[::1]:3128' })).toMatchObject({ url: 'http://[::1]:3128/', source: 'ALL_PROXY', warning: null }); + expect(resolveProxy(null, { HTTP_PROXY: 'proxy.corp' })).toMatchObject({ url: 'http://proxy.corp/', source: 'HTTP_PROXY', warning: null }); + expect(resolveProxy(null, { HTTPS_PROXY: 'socks5h://proxy.corp:1080' }).url).toBe('socks5://proxy.corp:1080'); + }); + + test('surrounding whitespace, a one-slash scheme and a bare number keep the behaviour a scheme-bearing value had', () => { + for (const value of [' http://h:3128', 'http://h:3128 ', ' h:3128 ', ' socks5://h:1080']) { + const resolved = resolveProxy(null, { HTTPS_PROXY: value }); + expect(resolved.warning, value).toBeNull(); + expect(resolved.url, value).toMatch(/^(http|socks5):\/\/h:/); + } + expect(resolveProxy(null, { HTTPS_PROXY: 'http:/host:3128' }).url).toBe('http://host:3128/'); + expect(resolveProxy(null, { HTTPS_PROXY: '1234' })).toMatchObject({ url: null }); + expect(resolveProxy(null, { HTTPS_PROXY: '1234' }).warning).toStartWith('HTTPS_PROXY is not a proxy URL browse can use ('); + }); + + test('a scheme-less value whose credential holds / \\ ? # or bad percent-encoding is not rewritten to http:// and warns without echoing the password or crashing', () => { + for (const value of ['bob:1234?x@proxy.corp:3128', 'bob:1234#x@proxy.corp:3128', 'bob:1234/x@proxy.corp:3128', 'CORP\\jdoe:1234@proxy.corp:3128', 'bob:\\1234@proxy.corp:3128', 'bob:1234\\abc@proxy.corp:3128']) { + const resolved = resolveProxy(null, { HTTPS_PROXY: value }); + expect(resolved.url, value).toBeNull(); + expect(resolved.warning, value).toStartWith('HTTPS_PROXY is not a proxy URL browse can use ('); + expect(resolved.warning, value).not.toContain('1234'); + } + const percent = resolveProxy(null, { HTTPS_PROXY: 'bob:100%@proxy.corp:3128' }); + expect(percent.url).toBeNull(); + expect(percent.warning).toContain('credentials are not valid percent-encoding'); + expect(percent.warning).not.toContain('100%'); + expect(() => resolveProxy(['http://bob', '100%@proxy.corp:3128'].join(':'), {})).toThrow(/percent-encoding/); + }); + + test('a proxy URL with a path, query or fragment is refused for every source, so a DOMAIN\\user login never prints its password', () => { + for (const value of [['http://CORP\\jdoe', '1234@proxy.corp:3128'].join(':'), 'http://proxy.corp:3128/path', 'http://proxy.corp:3128?x=1', 'socks5://proxy.corp:1080#f', 'http://proxy.corp:3128?', 'http://proxy.corp:3128#']) { + expect(() => resolveProxy(value, {}), value).toThrow(/unexpected path, query or fragment/); + const resolved = resolveProxy(null, { HTTPS_PROXY: value }); + expect(resolved.url, value).toBeNull(); + expect(resolved.warning, value).not.toContain('1234'); + } + expect(resolveProxy('http://proxy.corp:3128/', {}).url).toBe('http://proxy.corp:3128/'); + expect(resolveProxy(null, { HTTPS_PROXY: 'http://proxy.corp:3128/' })).toMatchObject({ url: 'http://proxy.corp:3128/', warning: null }); + expect(resolveProxy('socks5://proxy.corp:1080', {}).url).toBe('socks5://proxy.corp:1080'); + }); + + test('a userinfo that opens with a scheme is a slash-less URL, not scheme-less credentials, so the protocol it names is kept', () => { + expect(resolveProxy(null, { HTTPS_PROXY: ['https:bob', 'pw@proxy.corp:3128'].join(':') }).url).toBe(['https://bob', 'pw@proxy.corp:3128/'].join(':')); + const socks = resolveProxy(null, { HTTPS_PROXY: 'socks5:bob@proxy.corp:1080' }); + expect(socks.url).toBeNull(); + expect(socks.warning).toContain('missing host'); + }); + + test('a scheme-less standard variable with credentials beside BROWSE_PROXY_USER is refused like its http:// spelling: mixed credential sources stay a hard error', () => { + for (const value of ['bob:pw@proxy.corp:3128', ['http://bob', 'pw@proxy.corp:3128'].join(':')]) { + expect(() => resolveProxy(null, { HTTPS_PROXY: value, BROWSE_PROXY_USER: 'alice' }), value).toThrow(/pick one source/); + } + }); + + test('scheme-less and http:// spellings of the same proxy share a config hash, so either shell reuses the daemon', () => { + const a = extractGlobalFlags(['status'], { HTTPS_PROXY: 'h:3128' }, 'linux'); + const b = extractGlobalFlags(['status'], { HTTPS_PROXY: 'http://h:3128' }, 'linux'); + expect(a.configHash).toBe(b.configHash); + expect(a.redactedProxyUrl).toBe(b.redactedProxyUrl); + }); + + test('a standard variable that does not parse even as http:// warns and falls back to direct, without echoing it', () => { + const cred = ['user', 'secret@'].join(':'); + for (const value of [`ftp://${cred}proxy.corp:21`, `http://${cred}proxy.corp:99999`, `${cred}proxy.corp:notaport`, `http://${cred}:8080`]) { const resolved = resolveProxy(null, { https_proxy: value }); expect(resolved).toMatchObject({ url: null, source: null, bypass: null }); expect(resolved.warning).toStartWith('https_proxy is not a proxy URL browse can use ('); expect(resolved.warning).toContain('fix: export https_proxy=http://host:port (or socks5://), or set BROWSE_PROXY_URL=direct'); expect(resolved.warning).toContain('BROWSER.md#proxy-from-environment'); - expect(resolved.warning).not.toContain('secret'); + expect(resolved.warning, value).not.toContain('secret'); } }); - test('--proxy and BROWSE_PROXY_URL are explicit and still fail closed', () => { + test('--proxy and BROWSE_PROXY_URL are explicit and still fail closed, scheme required', () => { expect(() => resolveProxy('proxy.corp:8080', {})).toThrow(/unsupported proxy scheme/); expect(() => resolveProxy(null, { BROWSE_PROXY_URL: 'proxy.corp:8080' })).toThrow(/unsupported proxy scheme/); + expect(() => resolveProxy(null, { BROWSE_PROXY_URL: 'user:secret@proxy.corp:8080' })).toThrow(/unsupported proxy scheme/); }); }); @@ -114,6 +183,21 @@ describe('config hash', () => { expect(extractGlobalFlags(['text'], { NO_PROXY: 'corp.internal' }).configHash).toBe(computeConfigHash({ proxyUrl: null, headed: false })); }); + test('BROWSE_PROXY_URL=direct is part of the hash only where it bypasses the system proxy settings (#3440)', () => { + for (const platform of ['darwin', 'win32'] as const) { + const plain = extractGlobalFlags(['text'], {}, platform); + const direct = extractGlobalFlags(['text'], { BROWSE_PROXY_URL: 'direct' }, platform); + expect(plain.explicitDirect).toBe(false); + expect(direct.explicitDirect).toBe(true); + expect(direct.configHash).not.toBe(plain.configHash); + expect(plain.configHash).toBe(computeConfigHash({ proxyUrl: null, headed: false })); + } + const linux = extractGlobalFlags(['text'], { BROWSE_PROXY_URL: 'direct' }, 'linux'); + expect(linux.explicitDirect).toBe(false); + expect(linux.configHash).toBe(computeConfigHash({ proxyUrl: null, headed: false })); + expect(extractGlobalFlags(['--proxy', 'http://p:1', 'text'], { BROWSE_PROXY_URL: 'direct' }, 'darwin').explicitDirect).toBe(false); + }); + test('the env-resolved proxy is part of the hash', () => { expect(extractGlobalFlags(['text'], base).configHash).not.toBe(extractGlobalFlags(['text'], {}).configHash); expect(extractGlobalFlags(['text'], base).configHash).toBe(extractGlobalFlags(['--proxy', 'http://proxy.example:3128', 'text'], {}).configHash); @@ -133,13 +217,24 @@ describe('server env carries the proxy source and bypass', () => { expect(buildHeadedServerEnv(flags)).toMatchObject(expected); expect(buildRestartEnv(extractGlobalFlags(['text'], {}), null).BROWSE_PROXY_URL).toBeUndefined(); }); + + // Added by: /ship test coverage audit (Capy), 2026-10-25, for #3440 item 8. + // Value: protects=a headed connect with BROWSE_PROXY_URL=direct on macOS/Windows hands its daemon the opt-out, so the headed daemon hashes and launches direct; + // fails_when=buildHeadedServerEnv spreads only proxyUrl/source/bypass and drops explicitDirect, or proxyServerEnv stops re-emitting it; + // why_new=restart-env.test.ts covers buildRestartEnv only and the headed case above is a proxied session; seam=none; + // no_claim=the headed daemon's launch itself (unit: only the env it is handed; on Linux the opt-out hashes like no proxy, so the env is empty by design) + test('a headed launch keeps BROWSE_PROXY_URL=direct where it bypasses the system proxy settings, and a --proxy still wins (#3440)', () => { + expect(buildHeadedServerEnv(extractGlobalFlags(['connect'], { BROWSE_PROXY_URL: 'direct' }, 'darwin')).BROWSE_PROXY_URL).toBe('direct'); + expect(buildHeadedServerEnv(extractGlobalFlags(['connect'], { BROWSE_PROXY_URL: 'direct' }, 'linux')).BROWSE_PROXY_URL).toBeUndefined(); + expect(buildHeadedServerEnv(extractGlobalFlags(['connect', '--proxy', 'http://p:1'], { BROWSE_PROXY_URL: 'direct' }, 'darwin')).BROWSE_PROXY_URL).toBe('http://p:1/'); + }); }); describe('serverProxyEnv (the daemon side)', () => { test('direct means no proxy, and a launch without the CLI still keeps loopback and NO_PROXY', () => { expect(serverProxyEnv({ BROWSE_PROXY_URL: 'direct' }).url).toBeUndefined(); expect(serverProxyEnv({ BROWSE_PROXY_URL: 'http://p:1', NO_PROXY: 'corp.internal' })).toEqual({ - url: 'http://p:1', source: 'BROWSE_PROXY_URL', bypass: '*.corp.internal,corp.internal,localhost,127.0.0.1,[::1]', + url: 'http://p:1', source: 'BROWSE_PROXY_URL', bypass: '*.corp.internal,corp.internal,localhost,127.0.0.1,[::1]', direct: false, }); expect(serverProxyEnv({ BROWSE_PROXY_URL: 'http://p:1', BROWSE_PROXY_SOURCE: 'ALL_PROXY', BROWSE_PROXY_BYPASS: 'x,localhost' })).toMatchObject({ source: 'ALL_PROXY', bypass: 'x,localhost' }); }); @@ -149,13 +244,18 @@ describe('proxyMismatchLines', () => { const state = (extra: Record = {}) => ({ pid: 1, port: 1, token: 't', startedAt: '', serverPath: '', mode: 'launched' as const, configHash: 'x', ...extra }); const proxied = { proxy: 'http://proxy.example:3128/', proxySource: 'HTTPS_PROXY', proxyBypass: 'localhost,127.0.0.1,[::1]' }; - test('an env proxy meeting a direct daemon names the variable and offers stop or direct', () => { - const lines = proxyMismatchLines(extractGlobalFlags(['text'], { HTTPS_PROXY: 'http://proxy.example:3128' }), state(), {})!; + test('an env proxy meeting a direct daemon names the variable and offers stop, then direct on Linux or unsetting the variable elsewhere', () => { + const env = { HTTPS_PROXY: 'http://proxy.example:3128' }; + const lines = proxyMismatchLines(extractGlobalFlags(['text'], env, 'linux'), state(), {}, 'linux')!; expect(lines[0]).toContain('(HTTPS_PROXY differs)'); expect(lines[1]).toBe(' daemon: no proxy'); expect(lines[2]).toBe(' session: proxy http://proxy.example:3128/ from HTTPS_PROXY, bypassing localhost,127.0.0.1,[::1]'); expect(lines[3]).toBe("fix: run 'browse stop' to restart the daemon with this session's proxy settings, or set BROWSE_PROXY_URL=direct to use the running daemon without a proxy."); expect(lines[4]).toBe('see https://github.com/garrytan/gstack/blob/main/BROWSER.md#proxy-from-environment'); + for (const platform of ['darwin', 'win32'] as const) { + const other = proxyMismatchLines(extractGlobalFlags(['text'], env, platform), state(), {}, platform)!; + expect(other[3], platform).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."); + } }); test('a direct session meeting an env-proxied daemon names the daemon variable', () => { @@ -165,6 +265,33 @@ describe('proxyMismatchLines', () => { expect(lines[3]).toContain("or run this session with the daemon's HTTPS_PROXY"); }); + test('an explicit direct session meeting a system-proxy daemon (macOS/Windows) names BROWSE_PROXY_URL and offers stop or unset (#3440)', () => { + const env = { BROWSE_PROXY_URL: 'direct' }; + const lines = proxyMismatchLines(extractGlobalFlags(['text'], env, 'darwin'), state(), env)!; + expect(lines[0]).toContain('(BROWSE_PROXY_URL differs)'); + expect(lines[1]).toBe(' daemon: no proxy'); + expect(lines[2]).toBe(' session: no proxy (BROWSE_PROXY_URL=direct)'); + expect(lines[3]).toBe("fix: run 'browse stop' to restart the daemon with this session's proxy settings, or unset BROWSE_PROXY_URL to use the running daemon with the system proxy settings."); + }); + + test('a default session meeting an explicit-direct daemon (macOS/Windows) shows the daemon opt-out and offers stop or direct (#3440)', () => { + const lines = proxyMismatchLines(extractGlobalFlags(['text'], {}, 'darwin'), state({ proxyDirect: true }), {})!; + expect(lines[0]).toContain('(BROWSE_PROXY_URL differs)'); + expect(lines[1]).toBe(' daemon: no proxy (BROWSE_PROXY_URL=direct)'); + expect(lines[2]).toBe(' session: no proxy'); + 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'); @@ -186,4 +313,19 @@ describe('BROWSER.md documents the proxy messages', () => { expect(section).toContain(needle); } }); + + // Added by: /ship test coverage audit (Capy), 2026-10-25, for #3440 item 8. + // Value: protects=the section names the Chromium switch and the daemon-log line a user greps for, as the code emits them; + // fails_when=DIRECT_PROXY_ARG or the '[proxy] none:' log prefix is renamed without BROWSER.md, or the direct-mode paragraph is dropped; + // why_new=the #915 needles above predate the switch and the log line; seam=none; + // no_claim=prose accuracy beyond those two literals (doc-contract check only) + test('the direct-mode paragraph names the switch Chromium gets and the daemon-log line that records it (#3440)', async () => { + const doc = await Bun.file(new URL('../../BROWSER.md', import.meta.url)).text(); + const section = doc.slice(doc.indexOf('\n### Proxy from environment\n')); + expect(section).toContain(DIRECT_PROXY_ARG); + for (const needle of ['browse-daemon.log', '[proxy] using from', '[proxy] none: direct connection (', '[proxy] none: system proxy settings']) { + expect(section).toContain(needle); + } + expect(directProxyLogLine({ HTTPS_PROXY: 'http://b:2' }, 'linux')).toContain('[proxy] none: direct connection (' + DIRECT_PROXY_ARG + '; ignoring HTTPS_PROXY)'); + }); }); diff --git a/browse/test/restart-env.test.ts b/browse/test/restart-env.test.ts index 5cf7502e1..a48eb1a32 100644 --- a/browse/test/restart-env.test.ts +++ b/browse/test/restart-env.test.ts @@ -32,6 +32,17 @@ describe("buildRestartEnv (#1781 headed persistence)", () => { expect(env.BROWSE_CONFIG_HASH).toBe("abc"); }); + test("explicit direct (macOS/Windows, #3440) is re-emitted from this invocation", () => { + const env = buildRestartEnv({ explicitDirect: true, configHash: "d" } as any, launchedState as any); + expect(env.BROWSE_PROXY_URL).toBe("direct"); + expect(env.BROWSE_CONFIG_HASH).toBe("d"); + }); + + test("the opt-out comes only from this invocation's flags, never from the persisted state, so the env never contradicts the hash", () => { + expect(buildRestartEnv({ explicitDirect: false, configHash: "plain" } as any, { ...launchedState, proxyDirect: true } as any).BROWSE_PROXY_URL).toBeUndefined(); + expect(buildRestartEnv({ proxyUrl: "socks5://x" } as any, { ...launchedState, proxyDirect: true } as any).BROWSE_PROXY_URL).toBe("socks5://x"); + }); + test("configHash falls back to persisted state", () => { const env = buildRestartEnv({} as any, { ...launchedState, configHash: "fromstate" } as any); expect(env.BROWSE_CONFIG_HASH).toBe("fromstate"); diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 2220be27c..f52914a76 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -514,7 +514,7 @@ describe('Server auth security', () => { // parent-PID watchdog. const connectBlock = sliceBetween(CLI_SRC, 'Launching headed Chromium', 'Terminal agent started'); expect(connectBlock).toContain('startServer(buildHeadedServerEnv(globalFlags))'); - expect(buildHeadedServerEnv({ proxyUrl: null, configHash: '' }).BROWSE_PARENT_PID).toBe('0'); + expect(buildHeadedServerEnv({ proxyUrl: null, configHash: '', explicitDirect: false }).BROWSE_PARENT_PID).toBe('0'); }); // Regression: newtab returned 403 for scoped tokens because the tab ownership diff --git a/package.json b/package.json index e2e0f4ea0..557499848 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.247", + "version": "1.91.248", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",