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

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

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

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Name the direct-connection switch once (DIRECT_PROXY_ARG in proxy-config.ts) so the
launch argument and the daemon log line cannot drift apart.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): force direct:// only where Chromium would pick its own proxy; review fixes

Red team (ship Step 9.2): the first cut passed --proxy-server=direct:// on every
platform, but Chromium reads HTTPS_PROXY/HTTP_PROXY/ALL_PROXY itself only on
Linux. On macOS and Windows it follows the system proxy settings (PAC/WPAD),
which GStack Browser.app users behind a corporate proxy depend on, and
BROWSE_PROXY_URL cannot name a PAC. forcesDirect(env, platform) now gates the
switch: Linux always, elsewhere only with BROWSE_PROXY_URL=direct. The daemon
log says what is really ignored per platform, or that the system settings
apply.

Review pass 2: the CLI e2e asserts on the daemon-log slice its own invocation
wrote (the log is append-mode and shared across cases); connectProxy moves to
browse/test/helpers/connect-proxy.ts for both e2e files; the CHANGELOG Tests
bullets cover the generated cases. Adds the coverage-audit cases
(unparseable HTTPS_PROXY; extension, headed and handoff paths under Xvfb).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): assert the direct switch on the launched Chromium command line per platform (#3440 item 8)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship review pass 3 (red team, GStack Minder's decision B, 2026-10-26): on macOS
and Windows an explicit BROWSE_PROXY_URL=direct is the only thing that makes the
daemon bypass the system proxy settings, so explicitDirect() puts it in the
daemon config hash, server.ts records it as proxyDirect in the state file, and
buildRestartEnv re-emits it on a crash auto-restart. A reused daemon started on
the system proxy is now refused (the refusal names BROWSE_PROXY_URL and offers
browse stop or unsetting it) instead of silently keeping the system proxy while
the CLI reports direct. Linux hashes as before. BROWSER.md says the daemon log
records the mode each time the daemon starts, not on every launch. The Linux
desktop (GNOME/KDE) system-proxy question is filed as #3450.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): daemon-state and refusal e2e for the explicit direct opt-out; headed env and BROWSER.md contract checks (#3440 item 8)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship rerun review pass: proxy-direct-launch-args.test.ts joins the macOS
named-regressions job so its omission arm (no switch without the opt-out) runs
in CI; serverProxyEnv takes the platform so the state-file predicate is pinned
per platform instead of mirroring process.platform; buildHeadedServerEnv's
parameter type requires explicitDirect so a narrowed caller cannot drop the
opt-out silently; BROWSER.md's precedence item 6 and the reuse sentence say
what each platform does (a connect daemon gets the generic flags refusal);
CHANGELOG notes the one-time refusal against a pre-release daemon.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Ship adversarial pass: a crash restart re-emits BROWSE_PROXY_URL=direct from the
daemon state only when the invocation has no flags, so the env it hands the new
daemon never contradicts the config hash it also hands over; an explicit-direct
session is refused against a daemon with no recorded config hash, like --proxy
and --headed already were; the unparseable-variable hint says that the opt-out
also bypasses the system proxy settings on macOS and Windows; BROWSER.md notes
that a managed Chromium proxy policy still overrides the switch; CHANGELOG names
both files that joined the macOS named-regressions job.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): read scheme-less proxy variables as http:// so the explicit direct launch keeps those sessions proxied (#3440 item 8)

A standard proxy variable without a scheme (curl-style host:port or
user:pass@host:port) was rejected by browse's parser with a "connects
directly" warning while Chromium on Linux read the variable itself, so the
session was proxied anyway. With the launch now explicit
(--proxy-server=direct://) that environment went genuinely direct. resolveProxy
now reads such values as http://, the way curl and Chromium's own environment
reader take them; --proxy and BROWSE_PROXY_URL still require the scheme, and a
value that does not parse even as http:// still warns and connects directly.

Also drops buildRestartEnv's persisted-state re-emit branch, which production
never reached (_globalFlags is set before any restart), names the four
BrowserManager launch sites in the CHANGELOG and excludes the cookie-import
worker, and carries the docs audit's one-line BROWSER.md project-tree note.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

The lenient scheme-less proxy read is now gated on the plain
[user:pass@]host[:port] shape (SCHEMELESS_PROXY_RE, trimmed): a scheme'd value
with surrounding whitespace, a one-slash typo and a bare number keep the
behaviour they had, and a credential that would spill past the userinfo
(?, #, /) is not rewritten, so nothing can land outside the redacted part.
Credentials that are not valid percent-encoding are a ProxyConfigError for
every source instead of a URIError crash. buildRestartEnv's docblock, BROWSER.md
and the CHANGELOG describe the parse (port 80 default, macOS/Windows now treat
such a value as a configured proxy).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

A backslash is excluded from the scheme-less proxy shape and parseProxyConfig
refuses any proxy URL with a path, query or fragment, for every source: WHATWG
parsing ends an http authority at a backslash, so a DOMAIN\user login or a /
in a password landed in the path, which the redactor does not mask, and was
printed in the clear on the start line, in the daemon log and in the state
file. Such values now warn (standard variables) or error (--proxy,
BROWSE_PROXY_URL) without echoing the credential. Docs updated.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(browse): keep a slash-less scheme spelling, refuse a bare ? or #, and tighten the proxy docs

A standard proxy variable whose userinfo opens with a known scheme
(https:bob:pw@host:3128, which the URL parser reads as https://) is no
longer taken as scheme-less credentials, so the protocol it names is kept.
parseProxyConfig refuses a literal ? or # anywhere in the value, not only
a non-empty query or fragment. The unparseable-variable test now puts the
credential in every looped value, so its no-echo assertion is real; new
cases pin the slash-less scheme spelling, the bare ?/#, an env-sourced
trailing slash, and that mixed credential sources stay a hard error for
the scheme-less spelling as for the http:// one (the cred-mixing refusal
predates this branch and its test on main pins it, so it is documented
rather than softened).

CHANGELOG and BROWSER.md: single backslashes inside code spans, "a path
other than a bare /", the socks5 bridge log lines in the effective-mode
list, and the character list applies anywhere in the value.

Decision: GStack Minder (2026-10-26), option B.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): keep a basic-auth URL out of the scheme-guard comment so the pregate secret scan stays clean

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(browse): pin the mismatch remedy per platform so the Windows subset does not take process.platform

proxyMismatchLines defaulted to process.platform in the env-proxy-meets-direct-daemon case, so on the windows-free-tests lane the remedy read 'or unset HTTPS_PROXY' and the assertion failed. The case now passes 'linux' explicitly and pins the darwin/win32 remedy beside it. Test only; source unchanged since 1f6c85af.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): the one-slash http:/host:3128 spelling was never a diagnostic; say what the parser does with it

The CHANGELOG sentence and the SCHEMELESS_PROXY_RE docblock claimed a one-slash scheme 'keeps its diagnostic'; the URL parser has always read it as http://host:3128/ and browse/test/proxy-env.test.ts:83 pins that. Wording only, found by the Step 14.5 docs audit on 1f6c85af.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): percent-encoding advice per scheme, and state the credential-free-path narrowing and the new scheme-less cred-mix refusal

BROWSER.md said to percent-encode / \ ? # @ in http(s) credentials; ? and # are refused for every scheme, @ needs no encoding, and only the backslash is http(s)-specific. The CHANGELOG now states the narrowing that a credential-free standard-variable value with a path (http://proxy.corp:3128/pac) used to be read with the path dropped and now warns and goes direct, and that the cred-mix refusal is new for the scheme-less spelling. Wording only.

Decision: GStack Minder (2026-10-26), option A.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(browse): only host:port was Chromium's own reading; user:pass@host:port is newly proxied

Chromium's proxy spec parser (net/base/proxy_string_util.cc, ProxySchemeHostAndPortToProxyServer) returns an invalid server for any userinfo, so a scheme-less user:pass@host:port in HTTPS_PROXY was never honoured by Chromium on Linux and went direct; reading it as http:// follows curl and is new behaviour. BROWSER.md, the CHANGELOG, the resolveProxy docblock and the Linux e2e test comment now say so, and the scheme-less spill test is named for what it proves (those values never reach the path refusal; the http:// cases in the next test do). Wording only.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 13:56:12 +00:00
1 parent b9222a57b7
commit 5755601dac
20 files changed
+876 -62

No files matched your search

+1 -1
View File
@@ -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[@]}"
+56 -6
View File
@@ -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=<url>`, 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 <url> from
<source>`, `[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:<port>` 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 (<VAR> 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
+19
View File
@@ -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 <host>: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 <url> from <source>` 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 <platform>; 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:
+1 -1
View File
@@ -1 +1 @@
1.91.247.0
1.91.248.0
+1 -1
View File
@@ -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,
+16 -2
View File
@@ -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}`);
+37 -18
View File
@@ -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<Pick<GlobalFlags, 'proxyUrl' | 'proxySource' | 'proxyBypass'>> | null | undefined): Record<string, string> {
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<Pick<GlobalFlags, 'proxyUrl' | 'proxySource' | 'proxyBypass' | 'explicitDirect'>> | null | undefined): Record<string, string> {
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<Pick<GlobalFlags, 'proxyUrl' | 'proxySour
}
/**
* Build the env for an auto-restart after a crash. headed/proxy/configHash are
* Build the env for an auto-restart after a crash. headed and configHash are
* reapplied from THIS invocation OR the persisted server state, so a restart
* triggered by a plain command (goto/status, no --headed flag) never silently
* downgrades a headed session to headless (#1781). Pure + exported for tests.
* downgrades a headed session to headless (#1781). The proxy settings,
* including the explicit direct opt-out, come only from this invocation's
* flags, which the config-hash gate already matched against that daemon.
* Pure + exported for tests.
*/
export function buildRestartEnv(
globalFlags: GlobalFlags | null | undefined,
@@ -544,7 +559,7 @@ export function buildRestartEnv(
* the same port, watchdog setting, proxy and config hash. Pure + exported for tests.
*/
export function buildHeadedServerEnv(
globalFlags: Pick<GlobalFlags, 'proxyUrl' | 'configHash'> & Partial<Pick<GlobalFlags, 'proxySource' | 'proxyBypass'>>,
globalFlags: Pick<GlobalFlags, 'proxyUrl' | 'configHash' | 'explicitDirect'> & Partial<Pick<GlobalFlags, 'proxySource' | 'proxyBypass'>>,
): Record<string, string> {
return {
BROWSE_HEADED: '1',
@@ -935,8 +950,8 @@ async function ensureServer(flags?: GlobalFlags): Promise<ServerState> {
// 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,
};
+122 -14
View File
@@ -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 });
+4 -4
View File
@@ -49,7 +49,7 @@ import { isProcessAlive } from './error-handling';
import { allowedExtensionOrigin } from './extension-id';
import { sanitizeBody, stripLoneSurrogates } from './sanitize';
import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge';
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError, serverProxyEnv } from './proxy-config';
import { parseProxyConfig, toUpstreamConfig, ProxyConfigError, serverProxyEnv, directProxyLogLine } from './proxy-config';
import { writeReceipt } from '../../lib/egress-receipt';
import { redactProxyUrl } from './proxy-redact';
import { type XvfbHandle } from './xvfb';
@@ -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.
+2 -2
View File
@@ -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<string, string>[], 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' });
});
});
+28
View File
@@ -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<ConnectProxy> {
const connects: string[] = [];
const server = http.createServer((_req, res) => { res.writeHead(502); res.end(); });
server.on('connect', (req, socket) => {
connects.push(req.url ?? '');
socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', () => resolve()));
const entry = { server, url: `http://127.0.0.1:${(server.address() as { port: number }).port}`, connects };
opened.push(entry);
return entry;
}
export async function closeConnectProxies(): Promise<void> {
for (const { server } of opened.splice(0)) await new Promise<void>(resolve => server.close(() => resolve()));
}
+63 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { parseProxyConfig, computeConfigHash, ProxyConfigError } from '../src/proxy-config';
import { parseProxyConfig, computeConfigHash, directProxyLogLine, 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)");
});
});
@@ -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<string, string | undefined>;
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<string[]> {
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);
});
@@ -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<string, string | undefined>;
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<string> {
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);
});
+129
View File
@@ -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<string, string>) {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !/^(BROWSE_|GSTACK_|NO_PROXY$|no_proxy$|HTTPS?_PROXY$|https?_proxy$|ALL_PROXY$|all_proxy$)/.test(key)) env[key] = value;
}
Object.assign(env, {
HOME: scratch,
GSTACK_HOME: path.join(scratch, '.gstack'),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), ...(process.platform === 'darwin' ? ['Library', 'Caches'] : ['.cache']), 'ms-playwright'),
BROWSE_STATE_FILE: stateFile,
BROWSE_PARENT_PID: '0',
}, extraEnv);
// 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);
});
+45 -2
View File
@@ -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);
});
+150 -8
View File
@@ -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<string, unknown> = {}) => ({ 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 <url> 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)');
});
});
+11
View File
@@ -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");
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",