Commit Graph
2 Commits
Author SHA1 Message Date
Garry Tanandcapy-ai[bot] 5755601dac 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>
2026-10-26 13:56:12 +00:00
Garry TanandClaude Opus 4.7 443bde054c v1.28.0.0 feat: browse --headed/--proxy/--navigate + gstack/llms.txt + webdriver-only stealth (#1363)
* feat(browse): SOCKS5 bridge with auth + cred redaction helper

Adds browse/src/socks-bridge.ts: a 127.0.0.1-only SOCKS5 listener that
accepts unauthenticated connections from Chromium and relays them through
an authenticated upstream proxy. Chromium does not prompt for SOCKS5 auth
at launch, so this bridge is the workaround for using auth-required
residential SOCKS5 upstreams.

- startSocksBridge({ upstream, port: 0 }) → ephemeral 127.0.0.1 listener
- testUpstream({ upstream, retries: 3, backoffMs: 500, budgetMs: 5000 })
  pre-flight that connects to a known endpoint (default 1.1.1.1:443)
- Stream-error policy: kill affected client + upstream sockets on any
  error mid-stream; no transport retries (a transport-layer retry can
  corrupt browser traffic)

Adds browse/src/proxy-redact.ts: single source of truth for redacting
credentials in any logged proxy URL or upstream config. Every code path
that prints proxy config goes through this helper.

Adds the socks npm dep (~30KB) and 16 tests covering: 127.0.0.1-only
bind, byte-for-byte round trip through the bridge, auth rejection,
mid-stream upstream drop kills client conn, listener teardown,
testUpstream success + retry-exhaust paths, redaction of every
credential shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(browse): --proxy and --headed flags wire bridge into daemon

Adds the global --proxy <url> and --headed flags to the browse CLI.
Resolves cred policy and routes the daemon launch through the SOCKS5
bridge (or pass-through for HTTP/HTTPS) before chromium.launch().

CLI (cli.ts):
- extractGlobalFlags() strips --proxy/--headed from argv, parses URL via
  Node URL class, validates D9 cred-mixing (env BROWSE_PROXY_USER/PASS
  + URL creds → exit 1 with hint), composes canonical proxy URL with
  resolved creds, computes a stable configHash for daemon-mismatch
- ensureServer() now reads existing daemon's configHash from state file
  and refuses (exit 1 with disconnect hint) if --proxy/--headed mismatch
  the existing daemon. No silent restart that would drop tab state.
- All proxy-related stderr lines go through redactProxyUrl

proxy-config.ts (new):
- parseProxyConfig() — URL parser + D9 cred-mixing detector + scheme allowlist
- computeConfigHash() — stable hash of (proxy URL minus creds + headed flag)
- toUpstreamConfig() — map ParsedProxyConfig → socks-bridge.UpstreamConfig

Server (server.ts):
- Reads BROWSE_PROXY_URL at startup; for SOCKS5+auth, runs testUpstream
  pre-flight (5s budget, 3 retries, 500ms backoff) and exits 1 on failure
  with redacted error
- Spawns startSocksBridge() on 127.0.0.1:<ephemeral> and points
  Chromium at it via socks5://127.0.0.1:<port>
- HTTP/HTTPS or unauth SOCKS5 → pass-through to chromium.launch
  proxy.server (with username/password if present)
- State file gains optional configHash for daemon-mismatch check
- Bridge tears down via process.on('exit')

Browser manager (browser-manager.ts):
- New setProxyConfig({ server, username, password }) called by server.ts
  before launch
- chromium.launch() and both launchPersistentContext sites pass the
  proxy config through when set

Tests: 22 new across proxy-config (parse + cred-mixing + hash stability)
and extractGlobalFlags (flag stripping + cred-mixing rejection + cred
rotation hash stability + redaction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(browse): Xvfb auto-spawn with PID + start-time validation

Adds browse/src/xvfb.ts: a Linux-only Xvfb auto-spawn module for
running headed Chromium in containers without DISPLAY. The module
walks a display range to pick a free one (never hardcodes :99) and
validates orphan PIDs by BOTH /proc/<pid>/cmdline matching 'Xvfb' AND
start-time matching the recorded value before sending any signal.
Defends against PID reuse — refuses to kill anything that doesn't
match both checks.

- shouldSpawnXvfb(env, platform) — pure decision: skip on macOS/Windows,
  on Linux skip when DISPLAY or WAYLAND_DISPLAY is set (codex F2)
- pickFreeDisplay(99..120) — probes via xdpyinfo
- spawnXvfb(display) — returns { pid, startTime, display } handle
- isOurXvfb(pid, startTime) — both-checks validator
- cleanupXvfb(state) — best-effort, validates ownership before SIGTERM

Wired into server.ts startup: when shouldSpawnXvfb says yes, picks a
free display, spawns Xvfb, sets DISPLAY for chromium.launchHeaded, and
records xvfbPid/xvfbStartTime/xvfbDisplay in the state file. Cleanup
runs on process.on('exit'). The CLI's disconnect path also runs
cleanupXvfb() in the force-cleanup branch when the server is dead.

Disconnect now applies to any non-default daemon (headed mode OR
configHash-tagged daemon — i.e. one started with --proxy/--headed),
not just headed mode.

Adds xvfb + x11-utils to .github/docker/Dockerfile.ci so CI exercises
the Linux container --headed path on every run. Without it the most
common production path would go untested.

Tests: 17 new across decision logic, PID validation defenses
(cmdline mismatch, start-time mismatch), no-op safety on bad inputs,
and a Linux+Xvfb-installed gate for the spawn → validate → cleanup
round trip. Tests skip on macOS/Windows automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(browse): webdriver-mask stealth + Chromium-through-bridge e2e

D7 (codex narrowing): mask navigator.webdriver only via addInitScript.
The wintermute approach (fake plugins=[1..5], fake languages=['en-US',
'en'], stub window.chrome) is intentionally NOT applied — modern
fingerprinters check consistency between plugins.length, languages,
userAgent, and platform, and synthesizing fixed values can flag MORE
bot-like, not less. The honest minimum is webdriver, which Chromium
exposes as a known automation tell.

Adds browse/src/stealth.ts: single source of truth for the stealth
init script and launch args. Both browser-manager.launch() (headless)
and launchHeaded() (persistent context with extension) call
applyStealth(context) and pass STEALTH_LAUNCH_ARGS into chromium.launch.

The pre-existing launchHeaded stealth that did fake plugins/languages
is removed for the same reason. The cdc_/__webdriver runtime cleanup
and Permissions API patch are kept — they remove automation-injected
artifacts, not synthesize fake natural-browser values.

Adds bridge-chromium-e2e.test.ts (codex F3): the test that proves the
FEATURE works. Real Chromium with proxy.server = 'socks5://127.0.0.1:
<bridgePort>' navigates to a local HTTP fixture; the auth upstream's
connect counter and the HTTP fixture's hit counter both increment,
proving traffic actually traversed bridge → auth-upstream → destination.
Without this test, we could ship a working byte-relay and a broken
Chromium integration and never know.

Adds bridge-port-restart.test.ts (codex F1, reframed): old test
assumed two daemons coexist, which contradicts D2 single-daemon model.
Reframed as restart-then-restart, asserting fresh ephemeral ports
(never the hardcoded 1090) on each spin-up.

Adds stealth-webdriver.test.ts: navigator.webdriver=false in both
fresh contexts and persistent contexts; navigator.plugins/languages
are NOT replaced with the wintermute fake list (D7 verification).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(gstack): generate llms.txt — single-file capability index for AI agents

Adds scripts/gen-llms-txt.ts: produces gstack/llms.txt at repo root,
indexing every skill (47), every browse command (75), and design
commands when the design CLI is present. Per the llmstxt.org
convention, agents can read one file to learn what gstack offers
instead of crawling 47 SKILL.md files.

Sources:
- skill SKILL.md.tmpl frontmatter (name + description block scalar)
- browse/src/commands.ts COMMAND_DESCRIPTIONS (sorted by category)
- design/src/commands.ts COMMAND_DESCRIPTIONS if present (best-effort)

Wired into scripts/gen-skill-docs.ts as a post-step so it regenerates
on every `bun run gen:skill-docs` (the same script that re-emits all
SKILL.md files). Failures are non-fatal warnings, not build breaks —
the generator never blocks SKILL.md regen.

Strict mode (--strict, also used by tests) throws when a skill is
missing name or description in its frontmatter, catching missing
metadata before it ships.

Tests: shape (top-level sections, sort order, single-line summary
discipline), every-skill-and-command-appears, strict-mode rejection of
incomplete frontmatter, and freshness check that the committed
gstack/llms.txt matches what the generator produces now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(browse): --navigate flag on download for browser-triggered files

Adds the --navigate strategy from community PR #1355 (originally from
@garrytan-agents). When set, download navigates to the URL with
waitUntil:'commit' and captures the resulting browser download via
page.waitForEvent('download'), then saves via download.saveAs().
Handles URLs that trigger files via Content-Disposition headers,
multi-hop CDN redirects requiring browser cookies, or anti-bot CDN
chains where page.request.fetch() can't follow the auth/redirect
chain.

Defaults still use the existing direct-fetch strategy. --navigate is
opt-in.

Goes through the same validateNavigationUrl SSRF gate as goto, so
download --navigate cannot reach IPv4 metadata endpoints (AWS IMDSv1,
GCP/Azure equivalents) or arbitrary internal hosts.

Inferred content type from suggested filename for common extensions
(epub, pdf, zip, gz, mp3/mp4, jpg/jpeg/png, txt, html, json) — falls
back to application/octet-stream. Same 200MB cap as Strategy 1.

Frames the use case generically (anti-bot CDN, Content-Disposition,
redirect chains) rather than naming any specific site, per project
voice rules.

Co-Authored-By: @garrytan-agents
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: v1.28.0.0 — browse SKILL section + VERSION + CHANGELOG

VERSION 1.27.1.0 → 1.28.0.0 (MINOR — substantial new capability:
five new flags/features, ~600 LOC added, new socks dep, multiple
new modules).

browse/SKILL.md.tmpl: new "Headed Mode + Proxy + Anti-Bot Sites"
section between User Handoff and Snapshot Flags. Documents
--headed (auto-Xvfb on Linux), --proxy (with embedded SOCKS5
bridge for auth), download --navigate, the cred-mixing policy,
daemon-discipline (refuse-on-mismatch), the narrowed
webdriver-only stealth, container support caveats, and the
fail-fast/no-retry failure modes.

CHANGELOG entry follows the release-summary format from CLAUDE.md:
two-line headline, lead paragraph, "The numbers that matter"
table tied to specific test files that prove each capability,
"What this means for AI agents" closing tied to a real workflow
shift, then itemized Added/Changed/Fixed/For-contributors
sections.

Browse SKILL.md regenerated via bun run gen:skill-docs.
gstack/llms.txt regenerated automatically from the same pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(browse): integration coverage for daemon mismatch + proxy fail-fast

Adds two integration tests that exercise the full process boundary,
not just the module-level wiring.

daemon-mismatch-refuse.test.ts (D2):
- Stubs a healthy state file with a fake configHash and a fake /health
  HTTP server, runs the actual cli.ts binary with a mismatching
  --proxy, asserts exit 1 + 'different config' / 'browse disconnect'
  hint in stderr.
- Same shape with the plain-daemon-meets---headed case.
- Positive case: matching configHash → CLI does NOT emit the mismatch
  hint (regardless of whether the actual command succeeds).

server-proxy-fail-fast.test.ts:
- Starts the rejecting SOCKS5 upstream, spawns server.ts with
  BROWSE_PROXY_URL pointing at it, BROWSE_HEADLESS_SKIP=1 to skip
  Chromium launch.
- Asserts exit 1, 'FAIL upstream' in stderr (testUpstream pre-flight
  ran), no raw credential leakage in any output (redaction works on
  the failure path), and exit within 30s upper bound.

Both tests use the existing spawn-bun-cli pattern from
commands.test.ts so they run on the same CI infrastructure as the
rest of the bun test suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gen-skill-docs): keep module sync so test require() still works

Two regressions caught by the full test suite after the v1.28.0.0
landing pass:

1) package.json version mismatch — VERSION was bumped to 1.28.0.0
   but package.json still pinned to 1.27.1.0.
   test/gen-skill-docs.test.ts asserts they match.

2) Top-level await in scripts/gen-llms-txt.ts (CLI entry block) and
   scripts/gen-skill-docs.ts (post-step) made gen-skill-docs an
   async module. test/gen-skill-docs.test.ts uses require() to pull
   extractVoiceTriggers/processVoiceTriggers from gen-skill-docs,
   which Bun rejects on async modules with:
     "TypeError: require() async module ... unsupported.
      use 'await import()' instead."

Fix: wrap the await blocks in void IIFEs so the modules remain sync
from a require() perspective.

After fix: all 379 gen-skill-docs tests pass, all 77 new feature
tests pass (3 skipped on macOS — Linux+Xvfb gates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(browse): apply codex adversarial findings on the new lifecycle

Codex outside-voice review caught five real production-failure modes in
the v1.28.0.0 proxy/headed lifecycle. Fixed:

1) `browse disconnect` skip-graceful for proxy-only daemons
   (browse/src/cli.ts). The graceful /command POST went out with stray
   `domains,` shorthand and (even fixed) the server's disconnect handler
   only tears down headed mode — proxy-only daemons returned 200 "Not
   in headed mode" while leaving the bridge running. Now disconnect
   short-circuits to force-cleanup for non-headed daemons, which kicks
   process.on('exit') in server.ts to close the bridge + Xvfb.

2) sendCommand crash retry preserves --proxy / --headed
   (browse/src/cli.ts). The ECONNRESET retry path called startServer()
   with no extraEnv, silently dropping the proxied flags. A daemon that
   died mid-command would silently restart in default direct/headless
   mode and bypass the SOCKS bridge. Now reapplies BROWSE_PROXY_URL,
   BROWSE_HEADED, and BROWSE_CONFIG_HASH from the resolved global flags.

3) `connect` honors --proxy (browse/src/cli.ts). The headed-mode
   `connect` command built its own serverEnv that didn't include
   BROWSE_PROXY_URL, so `browse --proxy <url> connect` launched headed
   Chromium without the proxy. Now threads proxyUrl + configHash into
   the connect serverEnv.

4) SOCKS5 bridge handles fragmented TCP frames
   (browse/src/socks-bridge.ts). Previously used once('data') and
   parsed each chunk as a complete SOCKS5 frame — TCP doesn't preserve
   message boundaries and split greetings/CONNECT requests caused
   intermittent handshake failures. Replaced with a single state
   machine that buffers chunks and uses size predicates on the SOCKS5
   header to know when a complete frame has arrived. Pauses the client
   socket during upstream connect and replays any remainder bytes
   into the upstream on success.

5) Xvfb cleanup-then-state-delete ordering
   (browse/src/server.ts). emergencyCleanup() previously deleted the
   state file BEFORE any Xvfb cleanup could read it, orphaning Xvfb
   on uncaughtException / unhandledRejection. Now reads the state
   file first, calls cleanupXvfb() (which validates cmdline +
   start-time before kill), then deletes the state file.

Adds a regression test for #4: writes the SOCKS5 greeting + CONNECT
one byte at a time with 5ms ticks, asserts a clean round trip after
the fragmented handshake.

Codex's sixth finding (bridge advertises NO_AUTH on 127.0.0.1, so any
co-located process can use the authenticated upstream) is documented
as a known limitation — gstack's threat model assumes single-user
hosts. Adding bridge-side auth is a separate change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update BROWSER.md + TODOS.md for v1.28.0.0

BROWSER.md picks up a "Headed mode + proxy + browser-native downloads
(v1.28.0.0)" subsection inside Real-browser mode plus the new source-map
entries (socks-bridge.ts, proxy-config.ts, proxy-redact.ts, xvfb.ts,
stealth.ts). TODOS.md anti-bot-stealth item updated to reflect the v1.28
narrowing — the "fake plugins" line is no longer accurate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ci): include bun.lock in image build for deterministic install

CI evals all failed on PR #1363 with:
  error: Could not resolve: "smart-buffer". Maybe you need to "bun install"?
  error: Could not resolve: "ip-address". Maybe you need to "bun install"?
  at /opt/node_modules_cache/socks/build/client/socksclient.js:15

The cached node_modules layer in the pre-baked Docker image had
`socks` (the new dep) but was missing its transitive deps (smart-buffer,
ip-address). The image build copied only package.json into the build
context — without bun.lock, `bun install` resolved a different tree
than local `bun install` did, dropping required transitive deps.

Reproduces locally as 229 packages (correct) when bun.lock is present
or absent. Why CI diverged isn't fully understood — possibly Docker
layer cache reuse across image rebuilds — but the deterministic fix is
to include the lockfile in the image build context and use
`--frozen-lockfile`, matching what every CI doc recommends.

Changes:
- .github/docker/Dockerfile.ci: COPY bun.lock alongside package.json,
  switch `bun install` → `bun install --frozen-lockfile` so any future
  lockfile drift fails loudly during image build instead of producing
  a partially-installed cache that breaks downstream eval jobs.
- .github/workflows/evals.yml: include bun.lock in the image-tag hash
  so adding/removing a dep invalidates the image, AND copy bun.lock
  into the docker context alongside package.json.
- .github/workflows/evals-periodic.yml: same updates.
- .github/workflows/ci-image.yml: rebuild trigger now fires on bun.lock
  changes too; build context includes bun.lock.

Image hash changes → fresh image gets built on next CI run → install
matches the lockfile exactly → no missing transitive deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): use hardlink copy instead of symlink for node_modules cache

After the bun.lock fix landed, the eval matrix STILL failed identically:
  Could not resolve: "smart-buffer" / "ip-address"
  at /opt/node_modules_cache/socks/build/client/socksclient.js

But the hash-tagged image actually contains smart-buffer + ip-address +
socks all flat in /opt/node_modules_cache (verified by pulling and
inspecting the image). 207 packages, all present.

Root cause: the workflow used `ln -s /opt/node_modules_cache node_modules`
to restore deps. Bun build (and Node module resolution generally) walks
a file's realpath to find sibling deps. From the symlinked
/workspace/node_modules/socks/build/client/socksclient.js, realpath
resolves to /opt/node_modules_cache/socks/build/client/socksclient.js,
and walking up to find a node_modules/smart-buffer dir fails — there's
no `node_modules` segment in the realpath.

Switch `ln -s` → `cp -al` (hardlink-copy). Each file in the cache becomes
a hardlink at /workspace/node_modules/<pkg>, sharing inodes (no data
copy). Realpath of /workspace/node_modules/socks/.../socksclient.js
stays inside /workspace/node_modules, so sibling deps resolve correctly.

Speed is comparable to symlink — `cp -al` on ~200 packages on tmpfs is
sub-second. Same caching story preserved.

Both evals.yml and evals-periodic.yml updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): cp -r instead of cp -al — /opt and /workspace are different filesystems

The hardlink-copy fix landed and immediately broke with:
  cp: cannot create hard link 'node_modules/<file>' to
      '/opt/node_modules_cache/<file>': Invalid cross-device link

GitHub Actions runners mount the workspace volume at /workspace
(overlay-fs layered onto the runner image), and /opt is the runner
image's own filesystem. Cross-filesystem hardlinks aren't supported.

Switch `cp -al` → `cp -r`. Cost: ~5s for ~200 packages of small JS
files vs ~0s for the broken symlink. Still cheaper than the ~15s
`bun install` fallback. Realpath of /workspace/node_modules/<pkg>/...
stays inside /workspace, so bun build's sibling-dep resolution works.

Both evals.yml and evals-periodic.yml updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:14:59 -07:00