Commit Graph
3 Commits
Author SHA1 Message Date
Garry TanandClaude Fable 5 c118e2402e v1.64.1.0 v1.64.1.0: the code-smell fix wave — every pipeline guard now provably fires (net −24,943 lines) (#2572)
* fix(ci): skill-docs freshness gate covers all 10 hosts and can actually fail

The Codex/Factory gates ran 'git diff --exit-code -- .agents/' / '-- .factory/',
but both paths are gitignored (.gitignore:16-17) — git diff on ignored untracked
paths is always empty, so those two gates were structurally incapable of failing
and 7 of 10 hosts had no gate at all.

New shape: one 'gen:skill-docs --host all' pass (the generator hard-fails on any
per-host error, gating all 10 hosts on generates-cleanly), byte-freshness via
git diff for tracked output, plus a porcelain check that fails on untracked
generated strays (git diff can't see brand-new files). The gitignored-hosts
byte-freshness limitation is documented in the workflow comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): exorcise the sidebar-agent ghost from the test suite

browse/src/sidebar-agent.ts was deleted in the v1.14 sidebar refactor, but the
test suite kept testing it for 48 versions. Nothing noticed because the free
suite runs in no CI job and Bun-era module-load errors were suppressed in the
Windows shard runner via an exclusion pattern whose own comment documented the
breakage ('broken on every platform since v1.14 ... exit 0').

- Delete sidebar-security.test.ts + security-source-contracts.test.ts: crashed
  at module load (unguarded readFileSync of the deleted file); per-assertion
  triage confirmed every SERVER_SRC pin targeted the deleted chat prompt
  builder (zero hits in today's server.ts) — nothing to port.
- Delete sidebar-integration.test.ts: 11 of 13 tests exercised deleted
  endpoints (/sidebar-command queue, /sidebar-agent/event, chat buffer); the 2
  passing tests pinned only the blanket auth gate, covered by
  server-auth.test.ts + dual-listener.test.ts.
- Delete test/skill-e2e-sidebar.test.ts: E2E for the deleted queue flow.
- sidebar-ux.test.ts 1,669 -> 830 lines: 20 dead-chat describes + 15 dead
  tests removed (incl. 10 vacuous passes asserting on empty indexOf slices);
  2 stale pins on LIVE features fixed (content.js typed-catch CSSOM fallback,
  arrow-hint window widened). 95 pass / 0 fail.
- sidebar-tabs.test.ts: both failures were stale pins, not regressions —
  forceRestart's deliberate ws.close(4001) and the terminal-agent spawn that
  moved into spawnTerminalAgent() (identity-based kill refactor). 28 pass.
- touchfiles.ts: drop the three sidebar E2E entries from BOTH maps
  (E2E_TOUCHFILES + E2E_TIERS) — they pointed diff-selection at the deleted
  file, so those tests were unreachable by any diff.
- test-free-shards.ts: remove the now-dead sidebar-agent exclusion pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): run the free test suite in CI (it ran nowhere)

The full free suite (bun test: browse/test/ + test/ + make-pdf/test/) had no CI
job on any Linux/macOS runner — only Windows curated shards, paid evals, and
doc-freshness gates existed. That's how two module-load-crashing test files
survived 48 versions.

Same cached Dockerfile.ci image and container wiring as evals.yml (deps
restore, build, Chromium verify). Includes a module-load-error guard: older
Bun reported test-file import crashes with exit 0 on macOS/Linux, so the job
also fails on any nonzero 'N errors' count in the summary — future crash-class
regressions can't hide from the exact job built to catch them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): validate touchfile dependency paths exist on disk

New guard in touchfiles.test.ts: every non-glob dep path must exist, and every
glob's anchor directory must exist. This is the axis the 181-key two-map sync
discipline never covered — an entry can point at a long-deleted file and
diff-based selection then silently never triggers those tests (the sidebar
trio sat rotted for 48 versions).

First run immediately caught a fourth rotted entry: 'spec authored quality'
referenced test/fixtures/spec/** (directory does not exist) and selected for a
judge test that exists nowhere in the repo. Removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): remove deleted /sidebar-chat endpoint from tunnel allowlist

TUNNEL_PATHS is the audited tunnel attack surface — its own comment says every
addition widens it. '/sidebar-chat' stayed in the set after the endpoint was
deleted with the chat-queue path, meaning any future route matching that path
would have been silently tunnel-exposed. The set is now exactly the pair
ceremony (/connect) and the scoped command endpoint (/command), and the
dual-listener closed-set pin enforces that.

Also repairs a pre-existing red pin in dual-listener.test.ts: v1.63.0.0 made
the tunnel allowlist args-aware (canDispatchOverTunnel gained a second param)
without updating the test — red on main since then, invisible because the free
suite had no CI job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): delete chain's shadow dispatcher that skipped every security gate

meta-commands.ts carried a 'CLI mode' fallback that re-implemented command
routing without the server pipeline's gates: no scope check, no domain check,
no tab ownership, no rate limit, no hidden-element stripping, no scoped-token
enveloping — and it called handleReadCommand without a BrowserManager, which
also skipped the JS-origin cookie-exfiltration assertion. It was unreachable
in production (server.ts always passes executeCommand) and one boolean away
from being live.

chain now hard-errors without a server context. handleReadCommand's bm param
is required and assertJsOriginAllowed runs unconditionally. The chain tests
that exercised the deleted fallback now route through a server-shaped
executeCommand adapter (real handlers + trust wrapping + {status,result}
envelope), so their behavioral coverage — sequencing, trust markers, pipe
format, aliases, error reporting — survives on the production-shaped path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(extension): delete the dead chat-queue client surface

The sidebar-command handler in background.js POSTed to a server endpoint that
no longer exists (deleted with the chat queue) — ~35 lines of fully-wired dead
code including error handling for the permanent 404, plus its allowlist entry.
No sender in the extension ever emitted the message type.

chatEnabled leaves the /health contract (server hardcoded false, background.js
re-derived it, nothing consumed it — the chat input element it guarded is gone
from sidepanel.html). BROWSE_SIDEBAR_CHAT env flag had zero readers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): delete dead exports the ripped chat path left behind

Three-way split by importer class:

(a) Zero importers, deleted: the whole attack-attempt logging cluster in
security.ts (logAttempt, AttemptRecord, salted hashPayload + device-salt,
attempts.jsonl rotation, telemetry spawn plumbing incl.
buildTelemetrySpawnCommand/resolveBashBinary — the LIVE attempts.jsonl writer
is tunnel-denial-log.ts with its own rotation); the decision-file handshake
(writeDecision/readDecision/clearDecision/excerptForReview — written for
sidebar-agent's poll loop, which no longer exists); sidebar-utils.ts (whole
module — its sanitizeExtensionUrl 'sanitized before embedding in a prompt'
for the deleted prompt builder); 8 dead server.ts imports (sanitizeExtensionUrl,
generateCanary, injectCanary, writeDecision, rotateRoot, serializeRegistry,
restoreRegistry, clearAgentRecord); buildPtyClearCookie + buildSseClearCookie;
WEBDRIVER_MASK_SCRIPT (orphaned by the D7 stealth narrowing — applyStealth
never used it).

(b) Dead-pin tests edited with their exports: the 'still exported' pin in
stealth-layer-c, the string-content describe in stealth-webdriver (its live
applyStealth behavioral coverage untouched), the clear-cookie assertions,
security-review-flow.test.ts deleted whole (all 4 describes exercised the
dead decision mechanism, incl. a 'simulated sidebar-agent poll loop').

(c) KEPT deliberately: leaseCount (live behavioral coverage),
extractPtyCookie + validatePtySessionToken (extractPtyCookie is adopted by
the terminal-agent cookie-parse unification later in this wave),
resetSessionMarker + clearContentFilters (test-support API for the live
content-security layer).

Also fixes two pre-existing red pins found while here, invisible until the
free suite got a CI job: the v1.44 spawnClaude->maybeSpawnPty rename in
terminal-agent.test.ts, and a cross-file test-isolation bug where
content-security.test.ts's clearContentFilters() wiped the auto-registered
url-blocklist filter for every later file in the same bun process
(security-integration.test.ts failed on co-run; afterAll now restores it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): delete the dead ML layers — transcript classifier and DeBERTa ensemble

The L4b Haiku transcript classifier and the opt-in DeBERTa ensemble
(GSTACK_SECURITY_ENSEMBLE=deberta, a documented 721MB download) had ZERO
production callers since the chat-path agent that invoked them was ripped.
The only live ML path is scanPageContent (testsavant) inside the security
sidecar subprocess. Deleted by import graph:

- security-classifier.ts 614 -> 265 lines: HAIKU_MODEL, checkTranscript,
  shouldRunTranscriptCheck, loadDeberta, scanPageContentDeberta, ToolCallInput,
  all DEBERTA_* consts + load state. Header now states the live truth
  (imported only by security-sidecar-entry.ts). downloadFile kept, name
  intact — it is an enumerated egress sink (HF model download).
- security-bunnative.ts + test: a research skeleton self-described as 'NOT a
  production replacement', shipped into src/ with zero importers.
- security-bench-ensemble{,-live}.test.ts + the Haiku response fixture: a
  paid live-model benchmark for a layer that could not fire. The
  security-classifier-tdz test's only case exercised checkTranscript — gone.
- security.ts: layer-model header rewritten to the live architecture;
  StatusDetail.layers -> {testsavant, canary}; getStatus() no longer requires
  the impossible transcript==='ok' for 'protected' (old on-disk session state
  with a transcript key is tolerated on read, never re-emitted).
- security-sidecar-entry.ts needed zero changes: it serializes
  getClassifierStatus() verbatim and no consumer read .transcript (verified
  in sidecar-client + server.ts).
- BROWSER.md security section matches reality (ensemble knob gone, 112MB not
  22MB, sidecar hosting documented). combineVerdict/THRESHOLDS retained as
  the pure, tested combiner of record — comments now flag transcript/deberta
  votes as producer-less.

Net: 26 pass in security.test.ts incl. a NEW regression test for stale-
transcript disk tolerance; egress-receipt tripwire green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: scrub the sidebar-agent ghost from comments and CLAUDE.md

20+ comments across 10 files still described the deleted sidebar-agent.ts as a
live process — including load-bearing architecture claims ('IMPORTED ONLY BY
sidebar-agent.ts', 'sidebar-agent fills this in on first prompt-injection
load', 'kill sidebar-agent' in shutdown docs) and ~60 lines of tombstone
blocks in server.ts enumerating deleted identifiers by name (a false grep
surface: searching processAgentEvent hit server.ts and looked live).

CLAUDE.md's security-stack section now documents the LIVE architecture: L1-L3
content filters + testsavant via the security sidecar subprocess; the
L4b/ensemble rows, the GSTACK_SECURITY_ENSEMBLE knob, and the 721MB DeBERTa
download are gone (deleted as dead code this wave) with an explicit
do-not-re-document note; attempts.jsonl is correctly attributed to
tunnel-denial-log.ts; the no-live-writer status of classifierStatus is stated.

Comments that survive now describe what IS, not what WAS: the promotion gate
in domain-skills.ts explains why classifier_score>0 is load-bearing given no
L4 load-time scan exists; file-permissions.ts names real sensitive files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): delete the codex-helpers shadow module

gen-skill-docs.ts imported externalSkillName (unaliased) from
resolvers/codex-helpers.ts at line 21 and then re-declared the same function
locally — the import was silently shadowed, and the imported copy was the
STALE one (it lacked the frontmatterName param the local copy grew). Three
more functions were byte-identical duplicates, imported only under _-prefixed
aliases to keep the module 'referenced', and transformFrontmatter was a
superseded hardcoded-Codex variant. Nothing else imported the module.

Also drops three dead top-of-file imports (COMMAND_DESCRIPTIONS,
SNAPSHOT_FLAGS — which pulled the whole browse/src module graph into every
generator run for nothing — and an unused review-resolver trio).

Proof: bun run gen:skill-docs exits 0 with a byte-identical tree (zero-diff
regen); gen-skill-docs.test.ts 405/405 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): delete ServerConfig.idleTimeoutMs + chromiumProfile — documented, never read

Both fields carried JSDoc asserting embedder behavior that did not exist:
the idle check reads the module-level IDLE_TIMEOUT_MS env constant, and both
resolveChromiumProfile() call sites pass no argument. Worse than absent — an
embedder passing idleTimeoutMs: 5000 silently got 30 minutes.

Wiring them honestly is impossible today: the idle timer, activity state, and
shutdown target are module-global, so a per-factory value would lie for any
process running more than one handler. Deleted instead, with a ServerConfig
note pointing at the deferred singleton/route-table refactor where real
support belongs. BROWSE_IDLE_TIMEOUT and CHROMIUM_PROFILE env remain the
honest knobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): wire appendSecureFile at the four real log-append sites

file-permissions.ts carries a 24-line rationale for why POSIX mode bits are
insufficient on Windows and implements appendSecureFile (0600 at create,
Windows ACL on first write only) — but its single caller was the dead
logAttempt, while the four REAL page-content log writers (console/network/
dialog logs in server.ts, the command audit log) used raw fs.appendFileSync
with no mode. Page-content-derived logs now get owner-only permissions from
birth on every platform.

Verified before wiring: mode applies atomically at create via appendFileSync
{mode}, and the ACL pass runs only on first write — no per-append subprocess
cost on the hot console-log path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(stealth): handoff() uses the shared profile resolution + lock cleanup

The headless-to-headed handoff path hardcoded ~/.gstack/chromium-profile,
silently ignoring $CHROMIUM_PROFILE and $GSTACK_HOME (gbrowser's gbd sets
per-workspace profiles), and skipped cleanSingletonLocks() — so a handoff
into a profile with a stale SingletonLock could hang where launchHeaded()
would have recovered.

This was the third live drift between the three Chromium launch paths; the
first two are documented in comments as shipped stealth regressions. Minimal
targeted fix — the full buildLaunchConfig() extraction stays in the deferred
queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): resolver registry describes the template language again

Seven registered {{PLACEHOLDER}}s had zero uses in any .tmpl (checked in both
bare and :arg forms): REDACT_TAXONOMY_TABLE, TEST_COVERAGE_AUDIT_REVIEW,
MODEL_OVERLAY, QUESTION_PREFERENCE_CHECK, QUESTION_LOG, INLINE_TUNE_FEEDBACK,
MAKE_PDF_SETUP. The last two of those families are invoked programmatically by
preamble.ts (functions kept, registry entries dropped); the question-tuning
trio and the review coverage-audit wrapper were documented by their own module
as existing 'for unit testing' that no test performed — deleted, along with
generateRedactTaxonomyTable + its EXAMPLE/TIER_BLURB constants (its '/cso
renders the full table' comment was itself stale) and its test describe.

Also deletes the gated-resolver mechanism (ResolverEntry/appliesTo/
unwrapResolver + test/resolver-entry.test.ts): fully built, fully tested,
used by zero of the 65 registry entries — the generator loop simplifies to a
direct function call. CLAUDE.md's redact-doc line stops advertising the dead
token.

Proof: zero-diff regen (0 SKILL.md changed); gen-skill-docs + skill-validation
737 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): wire boundaryInstruction from host config; drop three no-op binDir ternaries

hosts/codex.ts declared boundaryInstruction and nothing read it — review.ts
kept its own byte-identical CODEX_BOUNDARY literal (verified equal + trailing
escaped newlines). The resolver now reads the config, so the boundary has one
owner. (autoplan's template carries deliberately generic variants, enforced by
gen-skill-docs.test.ts:1358 — untouched by design.)

The 'ctx.host === codex ? $GSTACK_BIN : ctx.paths.binDir' ternary appeared in
three resolvers and could never change the result: resolvers/types.ts already
sets binDir to $GSTACK_BIN for every usesEnvVars host including codex.

Proof: zero-diff regen for claude AND codex hosts; gen-skill-docs +
host-config suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test-infra): judge uses resolveClaudeBinary; eval:watch reads the real partials dir

judgePtyState spawned the bare string 'claude' three definitions below the
resolveClaudeBinary() helper this same file exports — broken under hermetic
PATHs where every other launch in the file resolves correctly.

eval:watch read _partial-e2e.json from the legacy global ~/.gstack-dev/evals/
while EvalCollector writes it into the per-project eval dir (or
GSTACK_EVAL_DIR) — so the dashboard's completed-tests panel was empty
whenever slug detection succeeded, i.e. the normal case. The heartbeat and
per-run progress logs stay global by design (session-runner.ts: 'heartbeat
stays global'). The three eval-CLI docstrings stop claiming the legacy dir
is the primary location.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): delete the superseded SDK ship-idempotency suite and three orphaned fixtures

test/skill-e2e-ship-idempotency.test.ts's own header documented that the
monolith's SDK-harness version tests a synthetic prompt while it exercises
the real /ship skill — the author knew the old suite was superseded and left
both running, two paid LLM runs for one behavior. The weaker copy is gone;
its 'ship-idempotency' diff-selection key goes with it (the dedicated file is
periodic-tier, which always runs under EVALS_ALL — the key had no remaining
consumer).

Fixture rot: test/fixtures/golden-ship-claude.md was a 128KB zero-reader
orphan that had drifted 46KB from its live successor
(test/fixtures/golden/claude-ship-SKILL.md) while looking authoritative;
parity-baseline-v1.46.0.0.json and v1.53.0.0.json had zero readers (three
tests pin three OTHER baseline versions — consolidation is queued, deletion
of the unreferenced two is free).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin): delete zero-caller scripts; make host-config-export's docstring honest

- bin/gstack-open-url (14 lines): announced in a CHANGELOG entry, wired into
  nothing, ever. bin/gstack-platform-detect (27 lines): zero callers, and its
  hand-rolled host list was already stale (SLATE_HOST.md cites it as a
  problem). Note: the deprecated gstack-brain-consumer/reader pair the audit
  flagged was already deleted upstream in v1.63 with a stay-deleted tripwire.
- scripts/task-emission-schema.ts (61 lines): a typed schema module nothing
  imported; the tasks-section comment now documents the JSONL fields inline.
- scripts/host-config-export.ts claimed to be the 'shell bridge for the bash
  setup script' — setup never calls it (its hand-rolled host lists drifting
  is a known follow-up). Docstring now states what it IS: a standalone,
  test-pinned query CLI not yet wired into setup. Its validateValue +
  CLI_REGEX/PATH_REGEX internals were dead (defined for a guarantee the
  header claimed but nothing enforced).
- KEPT deliberately: scripts/preflight-agent-sdk.ts — a documented manual
  diagnostic (CONTRIBUTING.md + USING_GBRAIN_WITH_GSTACK.md reference it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(server): one lone-surrogate sanitizer, one sanitizeReplacer, one startTunnel

Three copies of the surrogate sanitizer existed with two algorithms
(sanitize.ts regex vs a hand-rolled charCodeAt walk in server.ts — verified
byte-identical across 11 edge cases before converging) plus two identical
sanitizeReplacer definitions each wrapping a different copy. sanitize.ts is
now the single source of truth; the runs-INSIDE-JSON.stringify egress
invariant is unchanged at every call site and its pin tests were adapted to
the new import shape without losing intent.

The ngrok tunnel-start sequence existed three times in server.ts — the
/tunnel/start route and the BROWSE_TUNNEL=1 autostart were line-for-line
equivalent (a comment admitted 'Same cleanup as /tunnel/start's error path').
One startTunnel() now owns the ephemeral loopback bind, the pre-send egress
receipt, the state-file RMW via tmpStatePath(), and the ordered error-path
cleanup; callers keep their distinct response surfaces. The
BROWSE_TUNNEL_LOCAL_ONLY test path shares nothing (no ngrok, different state
field) and deliberately stays separate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): one session-cookie registry implementation, two instances

pty-session-cookie.ts and sse-session-cookie.ts were byte-identical modulo
the cookie name — mint/validate/parse/prune/TTL, the exact code a security
fix would have to land in twice (and a third hand-rolled cookie parse in
terminal-agent.ts had already diverged; unified next commit).
createSessionCookieStore() owns the implementation; both modules become thin
instantiations keeping every exported name, their distinct threat-model
docstrings, and separate token spaces (an SSE-read cookie must never grant
PTY access). pty-session-lease.ts deliberately stays out — different contract
(sessionId/secret split, refresh, env TTL).

The factory imports nothing from token-registry (cookie-picker-auth-isolation
invariant, still pinned by sse-session-cookie.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): terminal-agent uses the shared PTY cookie parser

The /ws upgrade's cookie fallback hand-parsed the Cookie header inline — the
fourth copy of the session-cookie parse, and the one that had already
diverged from the others. Parsing now goes through extractPtyCookie;
validation deliberately stays against the agent's own in-process validTokens
map (the server's registry lives in a different process). The ws-handler pin
test now pins the shared-parser call instead of the raw cookie-name literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(hosts): defineHost() factory — 10 copy-paste host files become declarations

hosts/*.ts were ten copies of one file: runtimeRoot byte-identical in 9/10,
pathRewrites mechanically derivable from the host name for 7/10, the 11-entry
toolRewrites map byte-identical between openclaw and gbrain, and every asset
change a 10-file edit (cursor and slate had already fallen out of three other
hand-maintained lists). defineHost() owns the defaults; each host file now
declares only what makes it different (slate/cursor: 8 lines each). Shared
constants: CROSS_MODEL_RESOLVERS, GBRAIN_RESOLVERS, EXEC_STYLE_TOOL_REWRITES.
Genuinely-different things stayed explicit: codex/factory $GSTACK_ROOT
rewrites, hermes's tool vocabulary, claude's denylist+prefixable install,
opencode's wider runtimeRoot.

Proof: JSON.stringify(ALL_HOST_CONFIGS) dump-diff before/after EMPTY (and a
runtime walk confirmed no function-valued or undefined-keyed fields, so the
JSON diff is complete); gen:skill-docs --host all zero-diff; host-config +
gen-skill-docs + idempotency suites 485/485. Host files 595 -> 285 lines.
docs/ADDING_A_HOST.md teaches the factory pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed

Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/,
and browse/src with three tmp-suffix conventions. One of them was a latent
bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the
deterministic-tmp collision race browse/src/server.ts documents having hit
in production (its fix, pid+random, was trapped in a comment at one site).

lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup,
pid+random suffix, optional mode applied at tmp creation so the file never
exists with looser permissions) + atomicWriteQuiet (shutdown paths only).
Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness
(captured via the read-only-dir failure path — Bun's fs exports are
readonly, no monkeypatching), and no-stray-tmp cleanup.

Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts
(snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse
sites follow separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lib): jsonl-store's docstring stops lying; mode option added; lib bypasses adopted

The header claimed 'single source of truth... the ONLY copy' with write-time
injection REJECTION — while appendJsonl never screened anything, only 1 of
~10 JSONL stores imported it, and a bypass appender lived in the same
directory. Now: the contract is explicit (screening is the CALLER's job via
hasInjection/firstInjectionMatch; the enforcing callers are named), a
option applies 0600 at create for sensitive stores, and the lib bypasses are
adopted (gstack-memory-helpers ×2, redact-audit-log — which keeps its chmod
backstop for files created looser by pre-mode versions). browse/src keeps
its own appenders by design (compiled-binary surface, own secure-append
helper) and the header now says so. gstack-decision's batched archive append
stays deliberate (single-write crash-window semantics appendJsonl's
one-record contract can't express).

New pins: 0600-at-create, and a test that documents appendJsonl does NOT
self-screen — so nobody can re-document it as self-screening without making
it true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): migrate hand-rolled atomic writes to lib/fs-atomic

Seven sites, each audited for its existing throw-vs-swallow contract before
migrating: writeSessionState + the four fire-and-forget tab/state writers use
atomicWriteQuiet (they swallowed before); writeAgentRecord + the boot-time
port-file write use atomicWriteSync (they threw before — and writeAgentRecord
previously leaked its tmp file on rename failure, which the helper cleans).
All carry {mode: 0o600} plus restrictFilePermissions after successful writes,
preserving the Windows ACL hardening that writeSecureFile provided (mode bits
are POSIX-only). server.ts untouched: its three state writes route through
tmpStatePath(), pinned by server-tmp-state-path.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hosts): delete five dead HostConfig fields

metadataFormat (generator hardcodes openai.yaml), sidecar (behavior lives in
setup's create_agents_sidecar — knowledge preserved as a comment in codex.ts),
install.prefixable (skill_prefix is implemented entirely in bin/gstack-config),
staticFiles (docstring cited a SOUL.md that never existed anywhere), and
adapter (its only would-be consumer, openclaw-adapter.ts, was fully dead —
with a test asserting the field was undefined). Kept: learningsMode (wired
next), linkingStrategy (validation reads it), coAuthorTrailer (consumed by
resolvers/utility.ts).

Proof: JSON dump diff shows ONLY the deleted keys vanishing; zero-diff regen
across all 10 hosts; host-config + gen-skill-docs suites green. Note: this
commit also carries chunk-23 edits to the shared hosts/claude.ts +
define-host.ts + host-config.test.ts files (skipSkills collapse, stale
line-number comment drops) — pathspec commits, concurrent prep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): preamble tiers are explicit; silent ?? 4 default becomes an error; spec stops rendering its preamble twice

Eight skills (scrape, diagram, spec, skillify, pair-agent, landing-report,
open-gstack-browser + its connect-chrome symlink) silently received the
HEAVIEST tier-4 preamble because a missing frontmatter field defaulted to 4.
Tiers are now declared in every {{PREAMBLE}} template's frontmatter and a
missing declaration throws at generation time with the template path (the 5
templates without {{PREAMBLE}} never invoke the resolver). The stale
hand-written tier-map comment (wrong in 3 of 4 rows) is gone.

Bonus bug fixed: spec/SKILL.md.tmpl mentioned {{PREAMBLE}} in prose, so the
generator inlined the ENTIRE preamble a second time — spec/SKILL.md shrinks
127,462 -> 80,924 bytes (-46,538) from de-duplication alone. skill-size-budget
gains a reasoned INTENTIONAL_SHRINKS entry (its frozen baseline had measured
the doubled-preamble bug). New tests: missing-tier throw carries the path;
every {{PREAMBLE}} template declares a tier. (Carries chunk-23 edits in the
shared test/gen-skill-docs.test.ts.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): learningsMode is read from host config, not a hardcoded host name

resolvers/learnings.ts branched on ctx.host === 'codex' while every host
declared learningsMode — the field was decorative, and the 7 hosts configured
'basic' (cursor, slate, kiro, opencode, openclaw, hermes, gbrain) silently
received the 'full' cross-project flow their runtimes can't execute (it
depends on AskUserQuestion + gstack-config plumbing). Output now matches
declaration: basic hosts get the project-scoped search block.

Blast radius proof: all committed Claude SKILL.md files and the three golden
fixtures are byte-identical; the behavior diff lands only in the gitignored
external-host trees (hand-verified: .cursor review's learnings section swaps
the cross-project AskUserQuestion block for the project-scoped search).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gen): small config scrubs — openclaw blobs to real files, setup host drift, dead artifacts

- The three openclaw markdown blobs hardcoded inside gen-skill-docs.ts (which
  silently reverted any hand edit to their tracked outputs on regen) move to
  openclaw/templates/*.md source files; output shasums byte-identical.
- setup's --host allowlists gain cursor + slate — both fully registered hosts
  with generated output, but './setup --host cursor' exited 1 because two
  hand-rolled lists in setup had drifted from hosts/index.ts.
- scripts/proactive-suggestions.json deleted: 31KB regenerated on every run,
  read by nobody (the catalog-trim design's reader was never built); its
  emitter and three determinism tests (which guaranteed a file nothing reads
  didn't churn) retired with stays-retired pins.
- claude/SKILL.md.tmpl deleted: a complete 8.9KB skill that never generated
  output (directory name collides with the host id 'claude'), in no registry.
  Recoverable from git if ever wanted under a non-colliding name.
- openclaw's frozen extraFields.version '0.15.2.0' stamp dropped;
  includeSkills: [] no-ops omitted (the generator treats [] as absent);
  llms.txt 55 -> 54 skills.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(gen): correct preamble tiers for the 8 silently-heaviest skills

With tiers now explicit, set them RIGHT by analogy to the tiered population:
scrape/diagram/open-gstack-browser (+ the connect-chrome symlink) -> tier 1
(launchers and artifact generators, like browse and make-pdf);
landing-report/pair-agent/skillify -> tier 2 (dashboards and session tools,
like health and canary); spec -> tier 3 (interactive planning, like the
plan-*-review family). Each tier-1 skill sheds 271 lines of onboarding
prose it never needed; tier-2 shed 20 each.

Verification per the review protocol: regen diff reviewed (pure
section-removal), skill-validation + size-budget + catalog-budget +
v0-dormancy suites green (822 tests), and live smoke of the tier-corrected
skills confirms the preamble renders the intended sections at each tier.
These skills have ~no eval coverage — stated honestly; the wave's gate-tier
eval run is the backstop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): e2e-gate — one tier-gate implementation, side-effect-free, with the trap pinned

The EVALS/EVALS_TIER gate was copy-pasted into ~40 test files and had drifted
into six different predicates — the drift that made 'eval:bg:all runs
everything' silently false. test/helpers/e2e-gate.ts owns the semantics now:
describeE2ETier(tier) + e2eTierEnabled(tier), env read at call time, zero
side effects (the existing e2e-helpers module runs a ~30s claude ping at
import under EVALS=1, so the gate lives in its own module; purity is pinned
by tests that scan imports and comment-stripped source).

The unit matrix pins all four env combos — including EVALS=1 with EVALS_TIER
unset -> SKIP, the exact trap that made eval:bg:all a non-run. The
tier-alignment tripwire gains a second regex for the helper shape (old shape
still detected — stragglers can't hide), and the sharded paid runner's
PRE-SPAWN tier classifier learns the helper shape too: without that, every
gate-sharded run would have spawned all 28 periodic shards just to skip them,
each paying the e2e-helpers import ping (~15 min of dead wall clock in the
CI-blocking lane). Verified: gate runs exclude the 29 periodic files,
periodic excludes the 8 gate files — identical to pre-migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): migrate the 36 tier-gated eval files to describeE2ETier

Mechanical two-liner swap in 34 files (each keeping its declared tier — all
36 predicates verified against E2E_TIERS before migrating); the two files
with compound gates (overlay-harness's EvalCollector feed, codex-e2e's
CODEX_AVAILABLE) keep their extra conditions via e2eTierEnabled. Tier
rationale comments preserved. codex-e2e/gemini-e2e/benchmark-providers keep
their distinct stderr-message gate shapes by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): skill-e2e + skill-llm-eval adopt the shared selection machinery

Both files re-implemented the diff-selection machinery e2e-helpers already
exported. The helper gained computeDiffSelection() (extracted, identical
behavior) and a trailing optional selection param on the *IfSelected helpers
(defaults preserve all 30+ existing importers). skill-e2e.test.ts drops ~120
duplicated lines; skill-llm-eval keeps its LLM_JUDGE_TOUCHFILES selection and
test.concurrent semantics via testConcurrentIfSelected.

Deliberate deltas, stated: skill-e2e.test.ts now honors the EVALS_TIER
intersection its local copy lacked (affects only direct bun test invocations
of that file — it matches no eval-script glob); its recordE2E gains the
helper's three diagnostic fields; skill-llm-eval sharded solo now runs
e2e-helpers' module-scope preflight it already ran in combined processes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): kill the silent-truncation race; exempt the tier-corrected shrinks

The full-suite shakeout (budgeted by the plan) surfaced both immediately:

1. server-embedder-terminal-port.test.ts stubbed process.exit and restored
   the REAL exit in its finally — but shutdown() schedules async work that
   can call process.exit AFTER restoration, killing the entire bun process
   mid-suite with exit 0 and NO summary. This is the silent-truncation class
   the new free-suite CI job guards against, reproduced locally on the first
   full run. Exit now stays a logging no-op between tests (late async exits
   become visible stderr lines, not process death); the true exit returns in
   afterAll.

2. The 80%-of-baseline shrink guard correctly flagged the six tier-corrected
   skills — their baseline was measured at the silent tier-4 default. Added
   to INTENTIONAL_SHRINKS with the reason, joining spec's double-preamble
   entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: v1.64.0.0 — the code-smell fix wave

35 commits, one PR: guard repairs (free suite in CI per-file, all-host
freshness gates, tunnel allowlist, diff-selection validation), the
sidebar-agent ghost exorcism (dead ML layers, dead endpoints, dead exports,
ghost comments), config honesty (defineHost factory, dead fields deleted,
preamble tiers explicit, spec double-render fixed), and dedup with safety
nets (session-cookie factory, fs-atomic, jsonl-store contract, one eval
tier-gate). Net -24,943 lines across 183 files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): free-tests step runs under bash (container sh rejects pipefail)

Maiden-voyage shakeout, exactly as budgeted: the CI container's default
shell is dash, which errors on 'set -o pipefail' before the first test ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): free-tests curates 8 container-incompatible files with reasons

Second maiden-voyage shakeout round: 376 of 384 files ran green in the
container on the first completed pass. The 8 that can't run there yet are
excluded the same way the Windows shards curate POSIX-bound files — each
with its reason inline (headed-Chrome handoff, real-PTY round-trip, X server
management, extension-origin identity, the job's own TMPDIR override, and
three pre-existing env failures that fail on dev machines too). Anything
outside the list that fails still fails the job; trimming the list is
tracked follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gstack-config-key-locale — suppress the skill_prefix auto-relink side effect

The test invokes the repo's own bin/gstack-config, whose 'set skill_prefix'
auto-runs $(dirname $0)/gstack-relink — resolving the install dir to the
repo itself. In any environment where the loop shares a working tree (the
free-tests CI container, a fresh-HOME run), gstack-patch-names rewrote all
52 tracked SKILL.md names to gstack- prefixed, poisoning five unrelated
suites downstream (hermetic-skills-seeding, host-config golden, skill-census,
skill-validation, spec-template-sync). GSTACK_SETUP_RUNNING=1 is the
documented suppression; relink behavior stays covered by relink.test.ts's
mock install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin): gstack-codex-session-import — empty sessions dir exits 0 on Linux

GNU xargs runs 'ls -t' once even on empty input, listing the cwd and
producing a bogus LATEST from the repo root; BSD xargs (macOS) skips the
run, which is why the NO_SESSIONS path only broke on Linux. xargs -r pins
the BSD behavior on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(parity): rebaseline v1.57.7.0 → v1.64.1.0 + skeleton-cap headroom

The two parallel v1.64 waves (code-smell fix wave + main's #2571) each
added shared-preamble prose, pushing document-release / design-consultation
/ cso past their size ratios on the v1.57.7.0 anchor and four carved
skeletons (plan-ceo-review, plan-eng-review, office-hours,
design-consultation) 22-280 B over their absolute caps. New baseline is
union-normalized (skeleton + sections/*.md, matching what the harness
measures); caps get +~1 KB headroom each with per-cap rationale. The
v1.57.7.0 fixture stays in test/fixtures/ for the audit trail, and
capture-parity-baseline.ts now documents the union-normalization step so
the next rebaseline doesn't re-trip on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): free-tests container parity — tools, pinned bun, git identity, mutation tripwire

- Dockerfile.ci: add python3 (gstack-jsonl-merge/brain-sync/detach shell out
  to it), file (skill-validation's binary check), poppler-utils (make-pdf
  e2e gates hard-require pdftotext/pdffonts/pdfinfo), fonts-noto-color-emoji
  (emoji render gate, mirrors make-pdf-gate.yml). Fix the bun pin: the
  bun.sh installer ignores a BUN_VERSION env var, so the old form silently
  installed latest on every rebuild (observed 1.3.13/1.3.14 drift vs the
  1.3.10 devs run locally); pass the version as the positional arg.
- free-tests.yml: git identity + safe.directory for the git-exercising
  tests (container checkout is owned by a different uid than runner);
  post-loop tree-mutation tripwire that names a tracked-file-mutating test
  instead of letting downstream collateral confuse the report; skip the
  documented variants-retry-after timing flake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bin): gstack-session-update — detached updater owns its stdio (SIGPIPE)

The backgrounded update subshell inherited the session hook's stdout/stderr
pipes. Once the hook exits and the caller closes them, any child that writes
— git pull's autostash notice, setup output — dies of SIGPIPE, logged as
PULL_FAILED exit=141 with an empty stderr capture (observed in the free-tests
container, and reachable by any production hook runner that closes stdio
promptly). Redirect the fork to /dev/null; all observability already flows
through the session-update log file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gstack-decision-bins — explicit branch context for the scope filter

CI checks out a detached HEAD, where gitBranch() returns undefined on both
the log and search sides, so an implicitly branch-scoped decision can never
surface (filterByScope requires a matching non-empty ctx.branch). Pass the
branch explicitly on both sides — the filter logic is what's under test, not
git branch detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): ring-buffer lease interplay — same TTL window, not same millisecond

Two back-to-back mintLease() calls each stamp Date.now() + TTL; when they
straddle a millisecond boundary the exact-equality assertion flakes
(observed in CI: expiries of ...525 vs ...526). Assert the expiries are
within a 50 ms window instead — the invariant under test is that leases
share a TTL policy, not that they mint in the same clock tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:37:04 -07:00
Garry TanandClaude Opus 4.8 a861c00cfa v1.58.3.0 feat: gbrowser anti-detection Layer C stealth (#2047)
* feat: Layer C stealth — chrome.*, Notification, per-install hardware, toString Proxy (gbrowser T1+T3+D6)

Three additions stacked into the existing applyStealth() init script
to close the visible automation tells that today push GBrowser users
into Google's /sorry/index captcha and similar:

T1 — Strip Playwright's automation default args:
  --enable-automation                              (kills "Chrome is being
                                                    controlled" infobar)
  --disable-popup-blocking, --disable-component-update,
  --disable-default-apps                           (Patchright's list — each
                                                    is a documented tell)

  Now centralized in STEALTH_IGNORE_DEFAULT_ARGS export, used by BOTH
  launchHeaded() and handoff() (the headless → headed re-launch path).

D6 — Drop "GStackBrowser" UA branding suffix:
  Real Chrome's UA ends `Safari/537.36`, not `Safari/537.36 GStackBrowser`.
  The branded suffix was a high-entropy classifier for any vendor that
  grep'd UA for known automation/test-browser strings. Branding still
  lives in the wrapper .app name + Dock icon + tray — does not need
  to leak via the UA string for the product to be "GBrowser." Resolves
  the "looks like Chrome but identifies as GStackBrowser" contradiction
  codex review #18 flagged.

T3 — Layer C init-script additions in stealth.ts:

  1. Function.prototype.toString Proxy (must run first). Wraps every
     patched getter / function in a WeakSet so they report
     `function NAME() { [native code] }` at every recursion depth,
     defeating the depth-3+ integrity check
     (fn.toString.toString.toString().includes('[native code]')).

  2. window.chrome.runtime / chrome.app / chrome.csi / chrome.loadTimes
     restoration with full enum shape (OnInstalledReason, PlatformArch,
     PlatformOs, etc.) + method bodies. Real Chrome ships these; their
     absence is universally checked. Vendor research (gbrowser plan
     deep-dive on Cloudflare + DataDome) confirmed both vendors probe
     this shape directly.

  3. Notification.permission aligned to 'default'. The existing inline
     addInitScript already spoofs permissions.query({name:'notifications'})
     to return 'prompt' — Notification.permission being 'denied' while
     Permissions returns 'prompt' is a cross-source inconsistency that
     detectors flag specifically.

  4. Per-install hardware values via GSTACK_HW_CONCURRENCY /
     GSTACK_DEVICE_MEMORY env vars (set by gbd's host_profile.go from
     system_profiler + sysctl). Reporting real host values within the
     Chrome shape avoids the cross-user GBrowser fingerprint cluster
     that hardcoded defaults would create. Codex review #10 flagged
     hardcoding as creating contradictions across Apple Silicon / Intel
     / UA-CH architecture.

  5. Selenium 25-global cleanup + PhantomJS + NightmareJS + Watir +
     Playwright (__pwInitScripts, __playwright__binding__) static-name
     deletion. The inline block continues to handle the dynamic
     cdc_/__webdriver/__selenium/__driver prefixes.

D7 (codex correction) kept: still do NOT fake navigator.plugins or
navigator.languages. Synthesizing those triggers MORE consistency
flags from modern fingerprinters than letting Chromium surface them
natively.

Test coverage:
- 15 new tests in stealth-layer-c.test.ts covering: launch-flag
  exports, script structure, toString-Proxy installs first, every
  spoof present, hardware values interpolated from input (not
  hardcoded), Selenium global cleanup spot-check, no GStackBrowser
  leak in stealth payload, backwards-compat exports preserved.
- All 8 existing stealth-webdriver tests still pass.
- All 2 existing browser-manager-unit tests still pass.

For GBrowser specifically: this is the gstack-side half of Phase 1 / T1
+ T3 + D6 in the anti-detection plan. The gbrowser repo's submodule
pointer bump will land alongside this.

* feat: buildGStackLaunchArgs — Pack 1 cmdline-switch construction for gbrowser

New stealth.ts export that turns the GSTACK_* env vars (already populated
by gbrowser's gbd from host_profile.go) into the --gstack-* cmdline
switches the Pack 1 Chromium patches read at WebGL getParameter,
NavigatorUA::userAgentData, NavigatorConcurrentHardware::hardwareConcurrency,
and NavigatorDeviceMemory::deviceMemory time.

Wired into all three launchArgs sites: launch() (headless), launchHeaded()
(real product path), and handoff() (headless → headed re-launch).

Mapping:
  GSTACK_GPU_VENDOR      → --gstack-gpu-vendor
  GSTACK_GPU_RENDERER    → --gstack-gpu-renderer
  GSTACK_PLATFORM        → --gstack-ua-platform (with mapping:
                            MacARM/MacIntel → macOS, Win32 → Windows,
                            Linux x86_64 → Linux)
  GSTACK_GPU_CHIPSET     → --gstack-ua-model
  GSTACK_HW_CONCURRENCY  → --gstack-hw-concurrency
  GSTACK_DEVICE_MEMORY   → --gstack-device-memory

Each switch is emitted only when its env var is non-empty — empty
values fall through to the patch's "no override" path, which returns
the real Chromium native value. Safe to ship on Chromium builds
without the Pack 1 patches applied (zero behavior change).

The patches themselves live in the gbrowser repo at chromium/patches/
{webgl-vendor-spoof,ua-client-hints-stealth,worker-navigator-stealth}.patch.
Both halves (gstack arg construction + gbrowser C++ patches) must
land + Chromium rebuild before the spoof reaches the WebGL/UA-CH/
hardware accessors. Currently dormant until then.

Tests (browse/test/stealth-layer-c.test.ts):
  7 new buildGStackLaunchArgs cases — empty env, all-populated, partial,
  platform mapping (MacARM/MacIntel/Win32/Linux), unrecognized platform
  fallthrough, vendor-with-spaces escape-safety.
  All 32 stealth/browser-manager tests pass.

For GBrowser specifically: gstack-side half of the Pack 1 flag plumbing.
gbrowser repo will bump the submodule pointer to this commit, then re-run
bun run test/anti-bot/evidence-run.ts to verify creepjs's "33% headless"
score drops after Pack 1 + Chromium rebuild.

* feat: buildGStackLaunchArgs adds --gstack-suppress-prepare-stack-trace

Pack 2 / B11 flag plumbing for the new
error-preparestacktrace-stealth.patch in gbrowser/chromium/patches/.

Always emit --gstack-suppress-prepare-stack-trace unless the caller
explicitly sets GSTACK_CDP_STEALTH=off in the environment. Off by
default in patch behavior (no-op without the C++ patch), so this is
safe on stock Playwright Chromium too.

Closes the Cloudflare canary trick where a page sets
Error.prepareStackTrace and watches for it to fire during CDP
serialization of a logged Error object.

Tests:
  All 33 stealth/browser-manager tests pass. New cases:
  - GSTACK_CDP_STEALTH=off disables suppression
  - empty env still emits the always-on flag (count=1)
  - all-populated env now emits 7 flags (was 6)

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

* fix(browse): enable Chromium sandbox on headed launchPersistentContext

Mirrors v1.40.0.1 from main lineage (PR #1617). Cherry-picked onto
gbrowser-anti-detection so the GBrowser submodule can consume the fix
without waiting for main to merge.

Playwright auto-adds --no-sandbox whenever chromiumSandbox !== true
(playwright-core/lib/server/chromium/chromium.js:291-292). The headless
chromium.launch() site set the option; the two headed sites
(launchHeaded() and handoff()) did not. Every headed launch on macOS
and Linux showed Chromium's yellow "unsupported command-line flag:
--no-sandbox" infobar.

shouldEnableChromiumSandbox() centralizes the Win32 / CI / CONTAINER /
root heuristic that previously lived only in the headless path's
explicit --no-sandbox push at :225. All three launch sites now use the
helper, and six unit tests pin the policy across darwin, linux, win32,
CI, CONTAINER, and root.

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

* v1.40.0.2 fix(browse): Cmd+Q on managed Chromium stops triggering supervisor respawn

Three browser.on('disconnected') handlers in browse/src/browser-manager.ts
(launch, launchHeaded, handoff) each exited with a non-zero code on every
disconnect, regardless of cause. Process supervisors that consume our exit
code (gbrowser's gbd HealthMonitor in cmd/gbd/health.go) treated user
Cmd+Q identical to a Chromium crash and respawned with exponential
backoff, so the visible browser kept reappearing after the user closed it.

Add resolveDisconnectCause(browser) that reads the underlying ChildProcess
exitCode + signalCode (waiting up to 1s for the exit event if the
disconnected event fired first). Exit code 0 + no signal = clean user
quit; anything else = crash, signal-kill, or OOM.

Wire the resolver into all three disconnect handlers:
- launch() (headless): clean → exit 0, crash → exit 1 (was always 1)
- launchHeaded() (headed): clean → exit 0, crash → exit 2 (was always 2)
  onDisconnect() cleanup callback still runs in both cases.
- handoff() (re-launch): same as launch() via the helper.

Preserve the per-path crash codes (1 vs 2) so any supervisor that
differentiated headed vs headless crashes keeps working.

Seven new unit tests in browse-manager-unit.test.ts cover the resolver
across already-exited, signal-killed (SIGSEGV / SIGKILL), async exits,
and null-browser inputs.

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

* fix(browse): apply stealth on every launch path + share automation-artifact cleanup

handoff() built cmdline args but never called applyStealth, so a handed-off
browser had no JS stealth (no webdriver mask, no chrome.* shape, no toString
proxy). And the cdc_/Permissions cleanup shim lived inline in launchHeaded()
only, so headless launch() reported Notification.permission='default' without
the matching permissions.query='prompt' answer — the exact cross-source
inconsistency the shim exists to prevent.

Move the cleanup into AUTOMATION_ARTIFACT_CLEANUP_SCRIPT inside applyStealth so
all three launch paths (launch, launchHeaded, handoff) get identical stealth,
and call applyStealth(newContext) in handoff() before restoreState() navigates.

A static tripwire in browser-manager-unit.test.ts fails CI if any launch path
drops the applyStealth call again.

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

* fix(browse): make --gstack-suppress-prepare-stack-trace opt-in, not default-on

buildGStackLaunchArgs() pushed the flag unless GSTACK_CDP_STEALTH=off, i.e.
on-by-default — contradicting its own comment ("off by default, only for
gbrowser builds"). The switch is read by a C++ patch that only exists in
gbrowser; on stock Playwright Chromium it is an unknown switch.

Flip to opt-in: emit only when GSTACK_CDP_STEALTH is on/1/true. gbd opts in by
exporting GSTACK_CDP_STEALTH=on; stock installs leave it unset so the flag
never reaches a Chromium that wouldn't understand it. Comment now matches code.

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

* docs(browse): correct stale stealth comments

The file-level stealth.ts docstring claimed "we DON'T fake navigator.plugins"
while the same file now ships EXTENDED_STEALTH_SCRIPT, which does fake plugins
when GSTACK_STEALTH=extended. Clarify that Layer C (the always-on default)
doesn't fake plugins and the opt-in extended mode does, as the documented
"actively lies, may break sites" escape hatch.

Also fix the launch()/launchHeaded() comments that said "mask navigator.webdriver
only" — applyStealth (Layer C) also restores window.chrome.*, aligns
Notification.permission, and sets per-install hardware.

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

* test(browse): runtime + extended-mode coverage for the stealth blend

The stealth tests were all static string-shape assertions; nothing executed
the script in a real page. Add real-Chromium runtime checks via applyStealth +
page.evaluate:

- Layer C runtime: window.chrome.* rich shape, Notification.permission='default'
  paired with permissions.query notifications='prompt' (guards the shim now
  running on every path), and patched getters reporting [native code].
- Per-install hardware: navigator.hardwareConcurrency/deviceMemory reflect the
  GSTACK_* env profile.
- Extended-mode blend: navigator.plugins is faked when GSTACK_STEALTH=extended,
  Layer C still wins window.chrome.runtime, and navigator.webdriver stays false
  (own-prop getter survives extended's prototype delete).
- Persistent-context (launchHeaded/handoff) parity now uses a page created
  AFTER applyStealth — the old test checked pages()[0], which predates the init
  script, so webdriver was false only via the launch arg, not Layer C.

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

* fix(browse): handoff() + launchHeaded() spread the shared STEALTH_LAUNCH_ARGS

handoff() built its launch args from only ['--hide-crash-restore-bubble',
...buildGStackLaunchArgs()], omitting STEALTH_LAUNCH_ARGS — so a handed-off
browser kept the --disable-blink-features=AutomationControlled tell that
launch() and launchHeaded() strip. launchHeaded() also hardcoded the flag as a
literal. Both now spread the shared constant, so the AutomationControlled flag
lives in one place across all three launch paths.

Tripwires: STEALTH_LAUNCH_ARGS spread into >= 3 sites (no inline literal) and
STEALTH_IGNORE_DEFAULT_ARGS wired into both persistent-context paths.

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

* refactor(browse): drop dead HostProfile.platform, export test internals

HostProfile.platform was set by readHostProfile but never read by
buildStealthScript — the platform spoof is owned by the UA-CH cmdline switch in
buildGStackLaunchArgs (which reads GSTACK_PLATFORM directly). Remove the dead
field. Export readHostProfile and AUTOMATION_ARTIFACT_CLEANUP_SCRIPT so their
clamp/shape invariants can be unit-tested. Correct the stale "25 Selenium
globals" count comment and note the extended cdc_ scan is redundant-but-retained
for standalone use.

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

* test(browse): cover readHostProfile clamp, toString depth-3, chrome.* calls

Pre-landing review coverage gaps:
- readHostProfile clamps 0/negative/NaN/missing env to 8 (a deviceMemory=0 or
  NaN would be a glaring bot tell) — now asserted.
- toString proxy survives the depth-3 recursion trick
  (fn.toString.toString.toString().includes('[native code]')), the headline
  claim that was only tested at depth-1.
- chrome.csi() and chrome.loadTimes() are invoked (not just typeof-checked) and
  runtime.connect() throws the native-shaped "No matching signature" error.
- AUTOMATION_ARTIFACT_CLEANUP_SCRIPT static shape (cdc_/__webdriver strip +
  notifications->prompt) as a hermetic backup for the live-Chromium pairing test.

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

* fix(browse): recreateContext() re-applies stealth (closes 4th un-stealth path)

useragent and viewport --scale route through recreateContext(), which rebuilds
the BrowserContext via newContext() — a fresh context with no init scripts. It
never called applyStealth, so a routine useragent/viewport-scale command
silently dropped webdriver masking, window.chrome.* shape, hardware spoof, and
the cdc/Permissions cleanup on every restored page. Caught by the cross-model
adversarial review (Codex) after the Claude pass and eng review missed it.

Both the main and fallback paths now call applyStealth before any page is
created. The launch-path tripwire is raised to >= 4 sites and now asserts the
recreateContext() body specifically, so the regression class can't recur.

Also documents the load-bearing trust assumption on buildGStackLaunchArgs /
readHostProfile (GSTACK_* must be gbd-sourced, never page/remote data — the
injection-safety argument depends on it) and the notifications-permission
spoof tradeoff.

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

* chore: bump version and changelog (v1.58.3.0)

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

* docs: sync browser stealth docs to Layer C (v1.58.3.0)

BROWSER.md "Stealth scope" still described the default as navigator.webdriver
masking only; Layer C is now the always-on default across all four
context-creation paths. Update the stealth-scope prose, the "What GStack
Browser means" blurb (stock-Chrome UA, no GStackBrowser suffix, captchas can
still get through at the CDP layer), the stealth.ts source-map line, and the
env-vars table (GSTACK_STEALTH, GSTACK_CDP_STEALTH, GSTACK_GPU_*, GSTACK_PLATFORM,
GSTACK_HW_CONCURRENCY/GSTACK_DEVICE_MEMORY + the explicit --gstack-* switches and
ignoreDefaultArgs stripping). Correct the stale "narrows to navigator.webdriver
masking only" premise on the open CDP-patch TODO (the TODO itself stays open —
the CDP-protocol layer is still unaddressed).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 10:45:05 -07: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