garrytan / garrytan/gstack

browse: opt-in persistent User-Agent suffix + default headers (BROWSE_UA_SUFFIX / BROWSE_EXTRA_HEADERS)

Open
#2,459 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
133k
Forks
19.9k
Avg merge
18h 46m
Merged PRs (30d)
26

Description

### Problem

Teams that dogfood their own product with /qa, /browse and /benchmark generate real traffic against their own production and preview deployments. In platform edge metrics (Vercel Observability, Cloudflare analytics and friends) that traffic is indistinguishable from human visitors, because the daemon browses with a stock UA.

Concrete case: on 2026-08-04 Vercel flagged a 6.16x edge-request spike on one of our production projects. Attributing it to our own QA loops took a multi-step investigation (deploy timeline plus product-analytics cross-check) that a single user-agent filter would have replaced. We now tag every session by hand, but the tag cannot be made durable from userland:

- `browse header` and `browse useragent` exist, but their state lives only in daemon memory. It is not persisted to `.gstack/browse.json`, and the daemon exits after 30 idle minutes (`BROWSE_IDLE_TIMEOUT`), so the next command auto-starts an untagged daemon.
- The daemon is per project root, so every repo and every worktree spawns its own instance, and every session must remember to re-assert the tag.
- Both commands are admin scope (`token-registry.ts`), so scoped browser-skill tokens (read+write) cannot self-tag.
- Patching a default into the install is not viable: `auto_upgrade` moves `~/.claude/skills/gstack` forward and local edits die silently.

### Proposal

Two opt-in daemon-startup settings, read from env when the server starts:

```sh
BROWSE_UA_SUFFIX="MyTeamQA/1.0" # appended, space-separated, to the UA the
# context would otherwise use; idempotent
# (never doubled)
BROWSE_EXTRA_HEADERS="X-My-Team-Qa: 1" # seeded into setExtraHTTPHeaders defaults;
# runtime `browse header` wins on collision
```

Semantics:

- An explicit `browse useragent ` keeps winning verbatim: a user spoofing an exact UA must get exactly that string. (Open to the alternative, suffix-always; we prefer explicit-wins for least surprise.)
- Both settings change daemon behaviour at startup, so they should join the existing config-mismatch guard (`computeConfigHash` in `cli.ts` already covers `--proxy`/`--headed`): a running daemon with a different suffix refuses with the usual `browse disconnect` hint instead of silently differing.
- Stealth interplay: a UA suffix is a deliberate, opt-in fingerprint. Anti-bot workflows simply do not set it; the default stealth posture does not change.

### Implementation points (verified at v1.60.1.0; `browser-manager.ts` byte-identical on current main)

All four context-creation sites live in `browse/src/browser-manager.ts`:

1. `launch()` (headless): `contextOptions` carries no `userAgent` unless `customUserAgent` is set (~line 397), so the context uses Playwright's default (which includes the `HeadlessChrome` token). The default is not known pre-launch; options: (a) read `navigator.userAgent` once from the initial tab and re-apply via the existing `recreateContext()` machinery, (b) per-page `Emulation.setUserAgentOverride` over CDP (the method is already in `cdp-allowlist.ts`), or (c) construct the UA from the binary version the way `launchHeaded()` already does, keeping the `HeadlessChrome` token in headless.
2. `launchHeaded()`: already detects the Chromium version and builds a stock-Chrome UA (~line 553); append the suffix to that string.
3. `recreateContext()`: re-applies `customUserAgent` when rebuilding (~line 1421); append the suffix when building the options.
4. `handoff()`: passes no `userAgent` to `launchPersistentContext` at all (~line 1578), so today even an explicit `browse useragent` is silently dropped across a handoff. The suffix should cover this site too, and the pre-existing drop may be worth fixing regardless.

Default headers are simpler: seed `this.extraHeaders` from env at construction. Three of the four sites already re-apply `extraHeaders` after context creation; `launchHeaded()` never does. The gap is invisible today because a fresh daemon starts with an empty map and the runtime `browse header` command applies live through `setExtraHTTPHeaders()`, but env-seeded defaults would make it observable on every headed start. The error-fallback branch of `recreateContext()` also rebuilds its blank context without re-applying headers. Both want the same one-line apply the other sites use.

### Tests the change would want

- Suffix applied exactly once (no doubling across `recreateContext()` cycles).
- Explicit `useragent` respected verbatim.
- Env-seeded headers merged under runtime `header` commands.
- Daemon config-mismatch refusal when the suffix changes while a daemon runs.

Happy to send a PR if this direction sounds right.

Contributor guide

Open the contributing guide

Research direction

Start in browse/src/browser-manager.ts at launch(), launchHeaded(), recreateContext(), and handoff(), then inspect computeConfigHash in cli.ts and the existing header application paths. Done means startup suffix and headers persist across context creation and handoff, explicit user agents remain verbatim, headers merge correctly, suffixes are not doubled, and a changed suffix triggers the daemon mismatch refusal; add coverage for each listed behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
cli, devtools
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.