Skip to content

Spec: Baseline release #33

Open
32 of 33 issues completed
Open

Description

@Fly-Playgroud

Spec for wand's Baseline release, synthesized from the Wayfinder map #1, ADR-0001 to ADR-0010, CONTEXT.md and the resolutions of every child ticket. Vocabulary is CONTEXT.md's. Everything is stated relative to the pins in the pins package at the release commit, so a Roll does not rewrite this spec.

Problem Statement

Go developers who automate Chrome through go-rod are stuck.

  • Upstream's code has not changed since December 2024 and its maintainer is not continuing. Twenty-nine community pull requests with real bug fixes sit unmerged.
  • go get -u breaks the build: two of upstream's own dependencies (fetchup, got) changed their APIs, and the fix only exists in unmerged pull requests. The test framework is linked into every user binary because one page method uses a helper from it.
  • The browser upstream downloads by default is a Chromium trunk build from mid-2024. Its arm64 fallback URL is dead, so linux/arm64 users cannot obtain a browser at all. Nothing is downloadable on loong64, windows/arm64 or Alpine, and the launcher never looks for an installed browser unless asked.
  • NewUserMode() fails on every Chrome since 136, because branded Chrome refuses remote debugging on its default profile. Upstream's suite stays green while the feature is broken.
  • The process guard upstream ships (leakless) is a prebuilt binary dropped into the temp directory. Windows Defender, 360 and Tencent PC Manager flag it intermittently, Falco alerts on it in Kubernetes, it exists for only five targets, and its port lock can hang forever.
  • Chrome 152 changed an error message, so errors.Is(err, cdp.ErrCtxDestroyed) no longer matches an evaluation interrupted by navigation.
  • Users on Domestic platforms (Kylin, UOS, openEuler on x86-64, ARM64 and LoongArch) and behind restricted networks have no supported build or download path.
  • There is no maintained module path anyone can depend on, and go-rod users are already scattering to unmaintained forks.

Solution

wand's Baseline release: the go-rod Snapshot renamed to github.com/headlesslab/wand, building and passing its full test suite with -race against the Target Chrome on every Tier 1 platform, with no API redesign (ADR-0002). A go-rod user swaps one import prefix (plus one for gson) and gets:

  • A runtime dependency graph with no ysmood/* module: five Satellite modules plus golang.org/x/sys. go get -u ./... keeps building, and no test framework ships in user binaries.
  • A protocol layer regenerated from devtools-protocol at the Protocol roll of the Target Chrome, and a Roll that moves the Target Chrome, the Protocol roll, the Companion Chromium and every archive hash together once per Chrome stable milestone.
  • Browser resolution that finds a System browser first and downloads a Managed browser (the Target Chrome from Chrome for Testing, or the Companion Chromium) only as a fallback, SHA-256 verified, from Google's buckets or npmmirror, whichever answers first.
  • An Orphan guard built on OS primitives and the Pipe tether; leakless is gone.
  • User mode that works on Chrome 136 and later through a wand-owned persistent profile directory.
  • Fourteen harvested upstream pull requests and a list of Confirmed fixes, each backed by a named regression test that fails on the Snapshot.
  • Tier 1 CI with a real browser on linux/amd64, linux/arm64, windows/amd64 and darwin/arm64; Tier 2 builds for darwin/amd64, windows/arm64 and linux/loong64; Go floor go 1.21.
  • A multi-arch container image, a workflow_dispatch release workflow, the Roll, Nightlies, and the security posture around them.
  • v0.1.0-rc.1, announced through its Release preamble and cross-posted where go-rod users already are, promoted unchanged to v0.1.0 after the soak; a migration guide with a Known limitations section; English and Chinese READMEs.

User Stories

  1. As a go-rod user, I want to move to wand by changing one import prefix, so that my existing code keeps compiling without a rewrite.
  2. As a go-rod user who imported github.com/ysmood/gson, I want the same identifiers under github.com/headlesslab/lazyjson, so that the switch is a single import path.
  3. As a go-rod user, I want a migration guide listing every change I must make and every known limitation, so that I can plan the switch in one sitting.
  4. As a go-rod user, I want to be told plainly that the API will change in a later minor, so that I can decide whether to migrate now or wait for the API modernization.
  5. As a go-rod user of the remote-launch manager and the pre-fetch command, I want them under go install-able cmd/ paths, so that the two commands are one install away.
  6. As a Go developer, I want go get -u ./... on a project depending on wand to keep building, so that a dependency upgrade never breaks my build the way fetchup and got did.
  7. As a Go developer, I want no test framework linked into my production binary, so that my binary carries no test-only code.
  8. As a Go developer building on openEuler 24.03 with the distro's Go 1.21, I want wand to build with that toolchain and no toolchain download, so that I build on the platform I deploy to.
  9. As a developer on Kylin V10 or openEuler 22.03, I want documented cross-compile and tarball paths, so that I know how to build for a platform whose system Go is older than the Go floor.
  10. As a deployer on a Domestic platform, I want linux/loong64 binaries to cross-compile and vet in CI, so that I can deploy to openEuler on Loongson hardware with confidence in the build.
  11. As a loong64 deployer, I want the documentation to say the claim covers new-world (ABI 2.0) distributions only, so that I do not deploy an official Go binary to Kylin V10 loong64 and watch it crash before main.
  12. As a developer who already has Chrome installed, I want the launcher to find and use it without downloading anything, so that the first run works offline and fast.
  13. As a developer on a fresh CI container with no browser, I want the launcher to download the Target Chrome automatically, so that the first run works without setup.
  14. As a developer in mainland China, I want managed-browser downloads to come from npmmirror when Google is unreachable, without waiting for a timeout, so that the download is fast wherever I am.
  15. As an operator behind a private mirror, I want to point wand at my own Download host, so that air-gapped builds still obtain a browser.
  16. As an operator, I want every managed-browser download verified against a SHA-256 recorded in the reviewed Roll, whichever host served it, so that a mirror cannot hand me a tampered browser.
  17. As an operator, I want to switch the download off and pre-fetch the browser with a command, so that Docker builds and offline bundles are deterministic.
  18. As a deployer who cannot accept Google Chrome's terms, I want the Companion Chromium as an alternative Managed browser, so that I ship a BSD-licensed binary.
  19. As a developer on windows/arm64, loong64 or Alpine, I want a clear error naming my platform and the way to point wand at a browser, so that I do not chase a dead download URL.
  20. As a developer, I want to choose the browser through a code option, a CLI flag or environment variables with a fixed precedence, so that CI and local runs override each other predictably.
  21. As a developer, I want the browser cache under my OS's cache directory rather than a roaming profile, so that Windows roaming profiles stop carrying browsers around.
  22. As a developer running a browser other than the Target Chrome, I want wand to log the version mismatch and continue, so that a newer or older Chrome in the Support window still works.
  23. As a developer, I want a browser wand launched to die when my process is killed hard, so that no orphaned Chrome eats my server's memory.
  24. As a developer on Windows, I want no guard binary written to the temp directory, so that Windows Defender never quarantines part of my run.
  25. As an operator on Kubernetes with Falco, I want no guard process at all, so that wand raises no security alert.
  26. As a developer, I want the launcher's Leakless option to keep its name and defaults, so that my existing launcher configuration compiles and means the same thing.
  27. As a User mode user, I want NewUserMode() to launch visible Chrome 136 or later on a persistent wand-owned profile, so that logins and extensions survive between runs.
  28. As a User mode user, I want to override the profile directory, so that I can keep several profiles, and I want Chrome's own refusal unchanged if I point it at Chrome's default profile.
  29. As a developer who checks errors.Is(err, cdp.ErrCtxDestroyed), I want it to match Chrome 152's message as well as the old one, so that navigation-interrupted evaluations are handled the same on every Chrome in the Support window.
  30. As a developer who sets a page timeout, I want WaitRepaint to honour it, so that a background or frozen tab cannot hang my program for hours.
  31. As a developer who opens and closes many pages in a long-running service, I want per-session CDP state cleared on page close, so that memory stays flat.
  32. As a developer taking element screenshots on a HiDPI display, I want the crop box scaled by the device pixel ratio, so that the screenshot shows the element.
  33. As a developer connecting through Browserless, Lightpanda or a gorilla/websocket proxy, I want a real Sec-WebSocket-Key and control frames skipped, so that the handshake and the session survive.
  34. As a developer hijacking requests over HTTP/2, I want the loaded response to be retryable by the transport, so that a retry does not fail because the body was already written.
  35. As a developer whose page's load event carries a circular object, I want WaitLoad to resolve, so that Bootstrap pages do not fail with "object reference chain is too long".
  36. As a developer using a pool, I want Get to return an error after Cleanup, so that a concurrent goroutine cannot block forever.
  37. As a developer attaching to an already running browser, I want the launcher's exit channel closed, so that cleanup does not hang.
  38. As a developer behind a misconfigured proxy, I want ResolveURL to fail on a non-200 response, so that I get an error instead of a nil URL.
  39. As a developer typing Cyrillic or CJK characters, I want multi-byte printable keys typed, so that non-ASCII input works.
  40. As a developer on FreeBSD, I want the launcher to look for chrome and chromium in the FreeBSD paths, so that my installed browser is found.
  41. As a developer, I want a bad hijack pattern to return an error instead of panicking, so that a user-supplied pattern cannot crash my process.
  42. As a developer, I want the protocol layer to match the Target Chrome with deprecated entities carrying Go's // Deprecated: marker, so that my editor warns me before Chrome removes something.
  43. As a developer, I want each release to state its Target Chrome, Protocol roll and Companion Chromium, so that I can match my browser fleet to a wand version.
  44. As a developer, I want a documented Support window, so that I know which Chrome versions are tested and which are best-effort.
  45. As a developer, I want go get -u=patch to be always safe, so that I can take fixes without reading release notes.
  46. As a Docker user, I want ghcr.io/headlesslab/wand as one multi-arch manifest with the tested Target Chrome inside, so that the same tag works on amd64 and arm64 nodes.
  47. As a Docker user, I want a :dev image with Go and Node, so that I can run wand's own suite or my tests inside a container.
  48. As a security reviewer, I want provenance and SBOM attestations on every published image that gh attestation verify accepts, so that I can verify what I deploy.
  49. As a security reviewer, I want a published OpenSSF Scorecard, SHA-pinned actions, immutable releases and a SECURITY.md with private vulnerability reporting, so that I can assess a new module path before trusting it.
  50. As a security reviewer, I want a Chrome fix for a vulnerability exploited in the wild to ship as a patch release through a Security roll, so that the browser my image carries is patched quickly.
  51. As a Debian packager, I want wand to depend on no removed package version, so that I can package it against current module versions.
  52. As a contributor, I want every push and pull request to run the full Gate on every Tier 1 platform, so that a change cannot merge red.
  53. As a contributor, I want the generated protocol and the pins checked for zero diff, so that a regeneration cannot drift silently.
  54. As a contributor, I want a suite with no flaky skips, no public-network access and no fixed ports, so that a red run means a real bug.
  55. As a contributor, I want every CI job to prove no browser process or temporary profile was left behind, so that leaks are caught in CI rather than in production.
  56. As a contributor on Windows, I want the lib/cdp and lib/launcher suites to pass, so that I can run the whole suite locally.
  57. As a contributor, I want the coverage Gate to hold at 100 %, so that harvested and rewritten code is exercised.
  58. As the driver, I want a Roll that opens one pull request per Chrome stable milestone with all pins, hashes and a symbol-level change summary, so that keeping up with Chrome is a review rather than a chore.
  59. As the driver, I want a workflow_dispatch release workflow that validates, appends the versions table, tags and publishes, so that cutting a release is one action.
  60. As the driver, I want Nightlies that open or update an issue when red and never block, so that I learn of drift without blocking merges.
  61. As the driver, I want the release candidate announced by its own Release body and cross-posted to go-rod's Discussions, Discord and each resolved issue, so that go-rod users hear about wand where they already are.
  62. As the driver, I want one pinned feedback issue for the release candidate, so that feedback arrives in one place and closes at promotion.
  63. As the driver, I want the automation to act through an organisation-owned GitHub App, so that no personal token expires under the Roll.
  64. As an AFK agent, I want every Confirmed fix defined as a named test that fails on the Snapshot and passes on wand, so that "fixed" is verifiable and recorded.
  65. As a go-rod/stealth user, I want the roadmap to say Stealth is planned, so that I know it is not in the baseline and nothing is promised.
  66. As a Chinese-reading user, I want a Chinese README and a Chinese section of the announcement, so that I can read about wand in my language.
  67. As a satellite maintainer, I want each Satellite module to run one reusable CI workflow and carry its own tags, so that satellites are released independently of wand.
  68. As a downstream library author whose exported types use gson.JSON, I want lazyjson.JSON to be the same type shape, so that my own API only changes an import path.
  69. As a headful-automation user, I want --headless to mean new Headless on the Target Chrome and the old-Headless shell available on request, so that I know which rendering mode I get.

Implementation Decisions

1. Snapshot import and provenance (ADR-0001, #16)

  • The Snapshot is upstream commit 393ac0d60b53f3c4a9b2a6504d250cbada55b546 (2024-12-07), upstream's last code commit; not the v0.116.2 tag.
  • The import lands as two commits. The first is the upstream tree byte-identical minus the exclusions: .github/, the root README, fixtures/design.sketch, lib/utils/check-issue, lib/utils/rename, lib/launcher/revision/, lib/examples/anti-bot-detection and lib/examples/debug-deadlock, with exactly one deviation, the removal of the check-issue use line from go.work so the commit builds. Its message carries the upstream URL, the full hash, the exclusion list and the deviation. The second commit renames: module github.com/headlesslab/wand, root package wand, nested example module paths, go.work lowered to go 1.21 and tracked, LICENSE (upstream's MIT text with Copyright 2019 Yad Smood and Copyright 2026 headlesslab), a root NOTICE (attribution and the Snapshot hash; grows one entry per later internalized package), fresh English and Chinese READMEs, and package README wording.
  • Verification recipe: clone upstream at the Snapshot, delete the excluded paths, diff -r against the first commit; the only difference is the one go.work line.

2. Module and package layout (ADR-0002, ADR-0009, #17)

  • No API compatibility promise with go-rod. The baseline keeps go-rod's method surface only because redesign is deferred to the API modernization.
  • Upstream's lib/ tree is kept under the new prefix: proto, launcher, launcher/flags, cdp, input, devices, js, utils, defaults stay public with unchanged import paths below the prefix.
  • One new zero-import leaf package, lib/launcher/pins, holds the Target Chrome (version and branch position), the Protocol roll, the Companion Chromium and the SHA-256 of every managed-browser archive (six Chrome for Testing platforms times chrome and chrome-headless-shell, five Chromium bucket prefixes). It is imported by the launcher and by the protocol generator, rewritten only by the Roll, and read by the release workflow through a small Go printer, never by parsing source. Its generator is the Roll tool, descended from upstream's revision generator with its rule changed to "largest position at or below the branch point present for every prefix".
  • Non-library code moves: assets to internal/assets; the seven dev-only utils helpers (Exec, ExecLine, UseNode, S, EscapeGoString, ReadString, TestEnvs) to internal/devutil, used only by generators and tools; the remote-launch manager to cmd/wand-manager; the pre-fetch command to cmd/wand-fetch-browser; the setup, lint, ci-test, simple-check, shell tools and the image build script to internal/tools/<name>; the Dockerfiles to docker/. Generators stay beside the packages they generate. The nested example modules and go.work stay, every go directive at 1.21.
  • internal/ sits at the module root. Commands carry the wand- prefix because go install names the binary after the last path element. A single wand CLI is out of scope.
  • The -rod= CLI flag becomes -wand=. The manager's binary-path whitelist follows the new browser cache location.
  • Layout facts CI may assert: go install ./cmd/... yields exactly the two commands; lib/utils exports none of the seven helpers; the pins package has zero imports; internal/assets exists and lib/assets does not.

3. Go floor and platform tiers (ADR-0003, #22, #28)

  • go 1.21 in every go.mod and go.work, no toolchain directive. Code stays at the Go 1.21 language level: generics, min/max, clear, slices, maps, cmp, log/slog are in; range-over-int, range-over-func, generic type aliases, omitzero, testing.B.Loop and go tool directives are out. The floor moves only when a new openEuler LTS ships a newer Go.
  • Tier 1 (built and tested with a real browser in CI): linux/amd64, linux/arm64, windows/amd64, darwin/arm64. Tier 2 (built and vetted in CI, natively where a hosted runner exists, cross-compiled otherwise, never browser-tested): darwin/amd64, windows/arm64, linux/loong64. Tier 3: no promise.
  • Domestic platforms ship as "cross-compiles, runtime unverified". linux/loong64 means new-world (ABI 2.0) distributions only: openEuler 22.03 LTS and 24.03 LTS SP1 and later. Kylin V10 and UOS 20 loong64 builds are old-world and not targets. Pre-1.21 distros (Kylin V10, openEuler 22.03) are served by cross-compilation or tarball installs, documented as such.

4. Protocol layer (ADR-0004, #9)

  • The protocol package is generated from ChromeDevTools/devtools-protocol's browser and JS JSON at tag v0.0.<rev>, where <rev> is the largest roll not above the Target Chrome's branch position (Puppeteer's rule). The schema is not committed; the generator downloads by tag and accepts a local schema path for offline use.
  • Upstream's generator stays with four changes: it downloads and merges the two JSON files by tag instead of launching a browser; it restores []byte for fields whose description carries the base64 marker plus a hand-kept list of marker-less fields, and fails when that count disagrees with the binary occurrences in the same tag's PDL files; deprecated entities gain Go's // Deprecated: marker; and it emits a symbol-level summary of removed, renamed and newly deprecated Go identifiers for the Roll pull request and the release notes.
  • Experimental and deprecated entities are generated. Removed entities disappear with no compatibility stubs. Older browsers get the same generated code best-effort; no numeric Chrome floor exists anywhere in code.
  • Known limitation, documented and not changed: optional booleans use bool with omitempty, so a false that differs from Chrome's default is never sent (rod #1196). Changing field types is API modernization.

5. Browser resolution and managed browsers (ADR-0005, #13)

  • Browser resolution order: the code option on the launcher, then the -wand=bin= flag, then WAND_BROWSER_BIN, then System browser discovery, then a cached Managed browser, then a download. Download is the fallback, never the default, and happens only on platforms where the pinned build exists. It can be switched off (WAND_BROWSER_DOWNLOAD=0 or a launcher option).
  • Environment variables: WAND_BROWSER_BIN, WAND_BROWSER_CACHE, WAND_BROWSER_HOSTS, WAND_BROWSER_DOWNLOAD, WAND_BROWSER_SOURCE, WAND_BROWSER_BINARY. Precedence is code option, then flag, then environment, then discovery.
  • Discovery list: upstream's plus /opt/google/chrome/chrome, Homebrew prefixes, Google Chrome for Testing.app, Edge on Linux, and the FreeBSD paths from upstream PR #1233. First hit wins, no version comparison. Domestic browsers (lbrowser, Qianxin, 360, UOS) are not in the list; the docs list their known paths for WAND_BROWSER_BIN.
  • Browser sources: Chrome for Testing at the Target Chrome (URL built from the exact version, no index JSON at run time), chrome by default and chrome-headless-shell on request; and Chromium trunk builds at the Companion Chromium, the newest position at or below the Target Chrome's branch point present for all five upstream bucket prefixes. Selected by a launcher option or WAND_BROWSER_SOURCE / WAND_BROWSER_BINARY. The launcher gains a version option beside the revision option. Playwright's host and revision are dropped.
  • Download hosts: Google's buckets and npmmirror by default, all probed concurrently, the first responder wins and the others remain fallbacks; Launcher.Hosts() and WAND_BROWSER_HOSTS take URL templates for private mirrors.
  • Every download is verified against the hash in the pins package before extraction, whichever host served it. A user-supplied version or revision has no recorded hash, is downloaded unverified, and says so in the log.
  • Cache: os.UserCacheDir()/wand/browser, overridable by WAND_BROWSER_CACHE; subdirectories chrome-<version>, chrome-headless-shell-<version>, chromium-<revision>. go-rod's cache is not reused.
  • Platforms with no Target Chrome build (windows/arm64, loong64, musl) never download; the error names the platform and the WAND_BROWSER_BIN / launcher option way out.
  • After connecting, when the browser's major version differs from the Target Chrome's, one line goes through the browser logger with both versions; wand never refuses.
  • The pre-fetch command remains for Docker builds and offline bundles.

6. Dependencies and Satellite modules (ADR-0006, #11)

  • No ysmood/* module in wand's runtime graph. Runtime dependencies: github.com/headlesslab/eventbus (snapshot of goob), lazyjson (gson, identifiers unchanged), seqdiff (got's lib/lcs, names unchanged for now), leakcheck (gotrace; test-only for wand), fetch (new), and golang.org/x/sys. got and gop stay test-only at their latest versions; the coverage tool is pinned to the same got version as go.mod, never @latest.
  • Each satellite is one repository, snapshot-imported the way ADR-0001 imported rod (verbatim at the pinned upstream tag, tests included, LICENSE with both copyright lines, NOTICE naming the upstream tag and commit), versioned v0.x on its own starting at v0.1.0, no breaking change without a major bump, pinned exactly by wand. All five are tagged v0.1.0 before rc.1 is cut.
  • fetch is a generic archive fetcher: probe every host concurrently, first responder wins, SHA-256 verified before extraction, zip only, a gofrs/flock file lock held around download and extraction, extraction into a temporary directory renamed into place. All Chrome knowledge (pins, URL templates, platform names) stays in wand's launcher. Upstream's LockPort goes with leakless; fetch owns the download lock.
  • Upstream's three dependency-bump pull requests (#1201, #1231, #1236) are superseded, not harvested.
  • Order of work: create and tag the five satellites, then the mechanical import swap in wand under the unchanged suite, then the got/gop upgrade, then the download rewrite on fetch together with the browser-resolution changes, then the Orphan guard.

7. Orphan guard (ADR-0007)

  • leakless is deleted. No guard process, no embedded binary, nothing written to the temp directory at launch.
  • Windows: the browser is assigned to a kill-on-close job object through golang.org/x/sys/windows. Linux, every GOARCH: Pdeathsig SIGKILL plus Setpgid, set from a goroutine that locks its OS thread for the browser's lifetime, because the signal fires when the creating thread exits. Every POSIX platform: --remote-debugging-pipe is passed with descriptors 3 and 4 created and held unused by wand (the Pipe tether), so Chromium 89 and later exits by itself when wand dies. Windows passes no pipe.
  • CDP traffic stays on the WebSocket; Launch() still returns a URL. The flag is passed only with both descriptors open, since Chrome 113 and later aborts otherwise.
  • Launcher.Leakless(bool) keeps its name and defaults (on in New(), off in NewUserMode()) as the switch for the guard. Process-group kill on normal exit remains for browsers that reject the pipe.
  • rod #720, #1214, #1229, #1232 and #865 are resolved by removal.

8. User mode (ADR-0010)

  • NewUserMode() defaults --user-data-dir to os.UserConfigDir()/wand/user-mode, a persistent directory wand owns and creates, separate from Chrome's own profile and from the browser cache. UserDataDir() overrides it; pointing it at Chrome's real profile directory yields Chrome's refusal unchanged.
  • The guard stays off by default in User mode so a browser the user keeps working in is not killed on exit.

9. Chrome-side and platform-side behaviour changes

  • cdp.ErrCtxDestroyed matches both "Execution context was destroyed." and Chrome 152's "Inspected target navigated or closed" (Puppeteer's rewrite rule). ErrCtxNotFound is not merged into it.
  • The CDP client reports a connection closed by the browser as io.EOF on every Tier 1 OS, normalizing Windows' connection-reset error, so callers have one sentinel; upstream's crash test then passes on Windows unchanged in intent. (Spec-time clarification of Decide the CI and platform matrix #20's "fixed, not skipped".)
  • The launcher's open-in-background helper is tested without a zero-value os.Process, so the suite passes on Go 1.23 and later. (Spec-time clarification of Decide the CI and platform matrix #20.)
  • Bare --headless is new Headless on the Target Chrome; HeadlessNew() is kept; the old-Headless rendering is available through the chrome-headless-shell binary option. Documented, not claimed as a fix.
  • The Windows Page.PDF hang (rod #1193) did not reproduce and is not claimed.

10. Harvested upstream pull requests (#7, #15)

Fourteen land, each as its own commit naming the upstream pull request:

upstream PR what landing condition
#1241 WaitRepaint evaluates under the page context its three regression tests pass on wand's CI
#1228 + #1187 real Sec-WebSocket-Key; control frames skipped in the WebSocket client land together
#1235 per-session CDP state cleared on page close regression test added
#1200 element screenshot scaled by the device pixel ratio regression test added
#1128 hijack LoadResponse sets GetBody for HTTP/2 retry as is; #1163 not taken
#1150 WaitLoad no longer resolves with the (possibly circular) event object fixed-port test rewritten on an ephemeral server
#1117 pool Get returns an error after Cleanup the Put-after-Cleanup panic is documented
#1221 exit channel closed when attaching to a running browser fixed-port test rewritten
#1176 ResolveURL errors on a non-200 version endpoint live-proxy test replaced by a local mock
#1220 multi-byte Key.Printable as is
#1233 FreeBSD binary paths as is (Tier 3)
#1182 WaitEnabled / WaitWritable doc links as is
#983 hijack pattern: regexp error instead of panic as is

Superseded by this spec's own decisions: #1201, #1231, #1236 (fetchup replaced), #1213 (cache directory), #1222 (Playwright host dropped), #1219 and #1225 (taken as #1241). Deferred to API modernization: #1110, #1152, #1163, #1183, #1194. Dropped: #1186, #1192, #1227, #1231.

11. Confirmed fixes and known limitations (#15)

A Confirmed fix is an upstream issue backed by a named test in wand's suite that fails on the Snapshot and passes on wand; the proof run on the Snapshot is recorded in the landing pull request. Documentation-only items are known limitations.

Confirmed fixes claimed by rc.1:

  • rod #1189 / #1184 (NewUserMode() on Chrome 136 and later): a unit test on the flag set plus a launch test against the branded Google Chrome on ubuntu-latest, windows-latest and macos-latest, skipped with a reason where no branded Chrome exists (ubuntu-24.04-arm), repeated in the Nightly system-browser suite. Chrome for Testing is exempt from the rule and cannot reproduce it.
  • rod #1203 / #1195 / #1202 / #1237 (fetchup and got build breaks): the go get -u ./... job is green and no ysmood/* module is in the graph.
  • rod #1179 (WaitRepaint), #1226 (state leak), #1198 / #1034 (DPR screenshot), #982 (hijack pattern), #828 (FreeBSD lookup): the tests of section 10.
  • rod #1092, partially (Sec-WebSocket-Key, control frames): the #1228 / #1187 tests; header ergonomics and non-integer error codes stay deferred.
  • rod #1045 (linux/arm64 managed-browser download): the Tier 1 linux/arm64 job downloads and runs the Target Chrome.
  • rod #865 / #1214 / #1232 (leakless): resolved by removal; the kill-the-driver tests per Tier 1 OS plus the zero-leftover CI step.
  • cdp.ErrCtxDestroyed on Chrome 152: upstream's promise-leak test unchanged and green across the Support window.
  • The Windows CDP crash test and the Go 1.23 launcher test: fixed, not skipped.

Known limitations, documented in the migration guide and not claimed as fixes: omitempty booleans in generated protocol structs (#1196); OOPIF Element.Frame() and ControlURL() users (#1234); Ubuntu 24.04 needs NoSandbox (#1070); Alpine's Chromium hangs (#1114); no managed-browser download on windows/arm64, loong64 and musl; new-Headless semantics since Chrome 132 (#818). Not claimed: #1193.

12. Test harness and suite rules (#15, #20)

  • No test-only browser knob. Tests launch through launcher.New(); CI selects the browser with the WAND_BROWSER_* variables (Tier 1 forces the managed Target Chrome or Companion Chromium and bypasses the System browser; the Nightly system-browser suite points WAND_BROWSER_BIN at the runner's Chrome). Upstream's init() preload is replaced by a TestMain step that runs Browser resolution once and fails with the resolved path in the message. Every Tier 1 job log prints the browser path and version actually used, and asserts it equals the expected pin.
  • The pooled tester count follows GOMAXPROCS, so hosted 4-vCPU runners launch three or four browsers per job. The ci-test wrapper stays as the entry point that sets GODEBUG=tracebackancestors=100 for leakcheck.
  • Cleanup of pooled browsers on a failing run comes from the Orphan guard, not from a guard process.
  • No skip for flakiness survives on Tier 1. Upstream's two flaky-skipped tests (page navigate error on Windows, hijack mock whole response without body) are root-caused and un-skipped on every Tier 1 OS, deleted only if covered elsewhere. Dead tests are deleted (native drag, old browser, lint ignore, mass browser close). Environment guards stay: fonts (container only), binary size (non-Windows, non-container; threshold lowered to lock in the leakless removal), profile dir (flag-gated, headful).
  • No test or example reaches the public internet except the launcher's managed-browser download, and no test uses a fixed port. The three live-site examples (basic, search, customize retry strategy) are rewritten against local fixtures. Every example that can run gets an output comment so it executes.
  • Zero leftover: every Tier 1 job, in-container included, ends with an always-run step that waits up to 30 s and fails on any surviving chrome or chromium process or leftover temporary user-data directory.

13. CI: runners, Gates and Nightlies (#20, #31)

  • Tier 1 runners: ubuntu-latest (Go stable and 1.21.x; Target Chrome and, on stable, Companion Chromium), ubuntu-24.04-arm (stable and 1.21.x), windows-latest (stable), macos-latest (stable). Every Tier 1 job runs every package from the workspace root with -race. Tier 2: macos-15-intel and windows-11-arm natively, linux/loong64 cross-compiled on ubuntu-latest; each runs build, vet and compile-tests-only on 1.21.x and stable.
  • CI obtains browsers through the launcher's own download path on every run, no cross-run cache, so every run exercises download and hash verification.
  • Gates on every push and pull request, all required status checks on main, direct pushes forbidden: the seven Tier 1 jobs; the three Tier 2 jobs; the generate job (upstream's full go generate from the pinned schema, JS, assets, devices, pins, lint with the Node linters at pinned versions and golangci-lint at a pinned version, then a clean-tree check covering the protocol and pins packages, plus re-derivation of the Protocol roll from the branch position); the coverage Gate at 100 % on the linux/amd64 stable job; the image job (build plus the in-container full suite on both architectures, no push); govulncheck on the linux/amd64 stable job; the CodeQL pull-request check (new high/error alerts only, pre-existing count zero before rc.1); dependency review with the license allowlist (MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, MPL-2.0); Trivy in the image job (--ignore-unfixed, fixable CRITICAL fails). Superseded runs of the same ref are cancelled; CDP logs and coverage output are uploaded always.
  • Nightlies (cron plus workflow_dispatch, never gating; a red run opens an issue keyed by job name or comments on the open one, through the GitHub App): full Tier 1 matrix rerun; system-browser suite on ubuntu-latest and windows-11-arm through the discovery path; examples (-run Example plus the e2e-testing example module) on the Target Chrome; go get -u ./... rerun on linux/amd64 at Go 1.21.x; the Support-window suite on linux/amd64 against Chrome for Testing's last known-good build of the oldest milestone in the window; govulncheck on main; Trivy on the published :latest and :dev; OpenSSF Scorecard weekly and on push to main.
  • Every job: GOTOOLCHAIN=local; GOPROXY and GOSUMDB at defaults; every action SHA-pinned with a version comment under the repository SHA policy; workflow-level permissions: contents: read with per-job elevation; checkout without persisted credentials unless the job pushes; no pull_request_target; no @latest and no bare npx anywhere in a Gate (Node linters through a committed lockfile run with npm ci).
  • Dependabot: github-actions and docker weekly, npm weekly grouped for the lint lockfile, gomod security updates only (no version pull requests; bumps arrive as hand pull requests prompted by a satellite release or the go get -u Nightly).
  • Upstream's workflows are reference only: the two test workflows become the matrix, check-examples becomes the examples Nightly, check-issues is dropped, check-revision is superseded by the Roll, docker is rebuilt.

14. Container image (#20, ADR-0005)

  • ghcr.io/headlesslab/wand, linux/amd64 and linux/arm64 in one manifest, each built natively on its Linux runner. Base ubuntu:noble with upstream's runtime dependencies, fonts, tzdata, dumb-init, xvfb; the manager command stays the entrypoint. The browser inside is the Target Chrome, pre-fetched by the pre-fetch command at build time, the same binary CI tested.
  • :dev stays, with Go and Node copied from the official golang:<stable> and node:<lts> images; it runs the full suite in-container on both architectures on every push. Base images pinned by digest and moved by Dependabot.
  • Tags mirror release tags (vX.Y.Z, vX.Y.Z-rc.N, latest = newest non-rc) and are pushed only by the release workflow. Every published manifest carries a build-provenance attestation and an SPDX SBOM attestation in Sigstore's public log; no cosign signature on top. Published image tags are never rebuilt in place.

15. Versioning, the Roll and the release workflow (ADR-0008, #19, #31, #32)

  • A fresh series from v0.1.0; upstream's numbering is not continued. The baseline ships first as v0.1.0-rc.1 (GitHub pre-release) and is promoted unchanged to v0.1.0 from the same commit once at least 14 days have passed since rc.1, at least 7 since the latest rc, and no issue labelled release-blocking is open. Any change makes rc.N+1, a Roll landing mid-soak included.
  • Pre-1.0 rule: a minor may break (exported-symbol removals including protocol entities a Roll drops, Target Chrome milestone moves, Go floor, dependency swaps); a patch never does. wand's own API carries // Deprecated: naming the replacement for at least one minor before removal; protocol removals follow Chrome with no window. One Milestone release per Chrome stable milestone, cut after the Roll merges; bug-fix patches on demand. v1.0.0 belongs to the API modernization map.
  • The Roll: a scheduled workflow that reads Chrome for Testing's last-known-good Stable, and only when its milestone is newer than the Target Chrome's, rewrites the pins package (Target Chrome, branch position, Protocol roll, Companion Chromium, every archive hash computed from a download off Google's origin bucket only), regenerates the protocol, and opens a pull request titled roll to Chrome <version> (protocol r<rev>) carrying the symbol-level summary. A workflow_dispatch input forces a specific version; its one in-milestone use is a Security roll (patch number and hashes only, shipped as a patch release). The Roll fails loudly when its token is rejected.
  • The release workflow: workflow_dispatch with a version input; validates semver, greater-than-last-tag, required checks green on main, and zero diff of the protocol against the pin; appends one row (wand version, Target Chrome, Protocol roll, Companion Chromium, read through the pins printer) to the root versions.json; commits on main; tags that commit; creates the GitHub Release with title = tag, first line the three pins, then the Release preamble when docs/releases/<tag>.md exists at the tagged commit, then GitHub's label-grouped notes from .github/release.yml (a roll label gives Rolls their own section); rc marked pre-release; pushes the image tags with attestations. Merging a Roll never tags. No version constant in code; no changelog file.
  • Tags are never deleted or moved: immutable releases plus a v* tag ruleset restricted to bypass actors; a bad release gets a retract directive and a fixed patch, and its Release is marked withdrawn in the notes.
  • Automation identity: a headlesslab-owned GitHub App (contents, pull requests, issues) whose per-run token drives the Roll, the release workflow and the Nightly issue opener, and which is the main and v* ruleset bypass actor beside repository admins. No personal access token exists.

16. Security posture and repository settings (#31)

  • One SECURITY.md in a new headlesslab/.github repository (organisation default): supported = the latest Milestone release and its rc; fixes as patch releases; reports through private vulnerability reporting; advisories as GHSA with a CVE requested; acknowledgement within 7 days; no bounty.
  • An idempotent gh api script under internal/tools/ applies to wand and each satellite: secret scanning with push protection, Dependabot alerts and security updates, private vulnerability reporting, immutable releases, the Actions SHA-pinning policy, the main ruleset (required Gates, no direct pushes, App and admins bypass) and the v* tag ruleset; organisation-wide 2FA required.
  • Satellites run one reusable workflow_call workflow from headlesslab/.github: build and test with -race on Go 1.21.x and stable on ubuntu-latest (fetch also on Windows and macOS), vet, golangci-lint with upstream's config, govulncheck on stable, a coverage ratchet at no less than the imported snapshot's level, the same hardening bundle, Dependabot github-actions plus gomod security updates. Satellites release by hand only when they change; wand takes a new version through a hand pull request.
  • OpenSSF Scorecard published to the OpenSSF API with the badge in both READMEs; the README carries the gh attestation verify line for the image.

17. Announcement of the release candidate (#32)

  • The announcement is the v0.1.0-rc.1 Release body: a Release preamble committed as docs/releases/v0.1.0-rc.1.md through a reviewed pull request before dispatch, inserted by the release workflow after the pins line. One screen, eight paragraphs: what wand is with ADR-0001's neutral lineage wording and thanks; the five migration changes linking the migration guide; the four behaviour changes (User mode profile, cache location, leakless gone, the second ErrCtxDestroyed message); tested Tier 1 facts versus the best-effort Support window and Tier 2; one image line; one trust line (Scorecard, attestation verify, SECURITY.md, immutable tags); one roadmap line (API modernization and Stealth planned, no dates); soak and where to report. It states plainly that the API modernization will change the API in a later minor and that go get -u=patch is the safe upgrade. English first, then a collapsed Chinese section. No "abandoned", "dead", "successor" or "official continuation".
  • Promotion: docs/releases/v0.1.0.md re-carries the preamble under a promotion header; the rc's notes get a one-line banner pointing at v0.1.0; nothing else is edited.
  • Channels, all posted by the driver personally the day the rc is tagged: one post in go-rod's Discussions (General), one message in go-rod's Discord, one comment on each go-rod issue a Confirmed fix resolves (one line: what wand is, the release, the regression test's name, the link). At promotion each gets one reply, and the issue comments are edited to name v0.1.0. Upstream issues are never labelled or closed. No r/golang, Slack or external Chinese channels.
  • Feedback: one pinned issue "v0.1.0 release candidate feedback" opened with rc.1, spanning every rc, confirmed bugs split into their own issues, closed and unpinned at promotion with a summary. go-rod/stealth users get the roadmap line and nothing else.

18. Documentation

  • English and Chinese READMEs: the lineage paragraph (two lines linking go-rod's repository and NOTICE, one sentence linking the migration guide; no link to any Release), a Roadmap section (the baseline first, then API modernization and Stealth, no dates or ordering promises), the Scorecard badge, the image verification line, a link to versions.json, the Support tier table and Support window, the Domestic platform build paths, the known domestic browser paths for WAND_BROWSER_BIN.
  • docs/migrating-from-go-rod.md ships with rc.1: the import prefix swap; gson to lazyjson; the removed assets package and seven utils helpers; the two cmd/ commands; the browser cache location; leakless to Orphan guard and the Leakless() semantics; User mode's profile directory; -rod= to -wand= and WAND_BROWSER_*; the second ErrCtxDestroyed message; the container image rename and multi-arch tag; a Known limitations section (section 11).
  • Package READMEs carried over from upstream are reworded for wand in the rename commit. No documentation site in the baseline.

19. Order of work

  1. Snapshot import (two commits) and the layout moves of section 2, suite green on Go 1.22+ with the pinned graph.
  2. The five Satellite modules created, CI'd through the reusable workflow, tagged v0.1.0; import swap in wand; got/gop upgrade; seqdiff replaces the runtime got import.
  3. Pins package and its generator; protocol regenerated from the pinned schema; generate Gate with zero diff.
  4. Browser resolution, sources, hosts, cache, hash verification and the pre-fetch command on fetch; manager whitelist follows.
  5. Orphan guard replacing leakless; User mode profile directory; ErrCtxDestroyed; the Windows and Go 1.23 test fixes.
  6. Harvested pull requests, one commit each with the proof run on the Snapshot recorded.
  7. Test harness rewrite (resolution in TestMain, WAND_BROWSER_* selection, skips and dead tests, live-site examples rewritten, zero-leftover step).
  8. CI matrix, Gates, Nightlies, image, Roll, release workflow, security posture, settings script, headlesslab/.github, GitHub App.
  9. The first Roll to a Target Chrome of 153 or later; three consecutive green Nightlies.
  10. READMEs, migration guide, Release preamble, feedback issue; v0.1.0-rc.1; cross-posts; soak; promotion.

Testing Decisions

  • A good test drives wand through its public API against a real browser and asserts observable behaviour: what the page shows, what the launcher returns, which error a caller gets, whether a process is alive. It does not assert on internal state, CDP message sequences or private functions. Fixture pages are local, served from an ephemeral port by the test framework's server helper or loaded from the fixtures directory; nothing reaches the public internet except the launcher's managed-browser download.
  • One primary seam, existing: the public API with a real browser, through upstream's got-based suites carried over with the Snapshot. The root package's tester pool launches browsers with launcher.New() and connects with the root constructor; its MockClient wrapper around the CDP client is the one stubbing point, used to inject a CDP error at the nth call of a given method. The lib/launcher, lib/cdp, lib/input, lib/utils, lib/defaults, lib/devices and lib/js package suites and the two example modules use the same seam. No fake CDP server is introduced.
  • The browser under that seam is chosen by WAND_BROWSER_* in CI, never by a test-only knob (section 12); Tier 1 runs on the Target Chrome, linux/amd64 additionally on the Companion Chromium, the Nightly on the runner's System browser.
  • Launcher behaviour that needs no browser process uses the seam's existing sub-patterns: an ephemeral HTTP server serving a zip for download, host-race and hash-mismatch tests (upstream's download test), fixture fake-browser binaries built from the launcher's fixtures directory for validation tests, and a flag-set assertion for User mode and the guard flags.
  • Process-level guarantees (the Orphan guard) are tested inside the lib/launcher suite with Go's helper-process pattern: the test re-executes its own binary as the driver, the driver launches a browser and reports its PID, the test kills the driver hard (SIGKILL, or TerminateProcess on Windows) and asserts the browser is gone within a bound. One test per mechanism: job object on Windows, Pdeathsig on Linux, the Pipe tether on macOS and Linux.
  • The User mode Confirmed fix has two tests: a flag-set unit test and a launch test against the branded Google Chrome on the three runners that ship one, skipped with a reason elsewhere.
  • Confirmed-fix protocol: each claimed fix names its test; the landing pull request records the test's failing run on the Snapshot (same test file applied to the Snapshot tree, or the Snapshot's own test where one exists) and its passing run on wand. A fix without a named failing test is documented as a known limitation instead.
  • Satellite modules keep the test suites they were imported with, at no less coverage. fetch is new and gets its own suite in its repository against ephemeral HTTP servers: first responder wins, slow and failing hosts fall back, a hash mismatch fails before any extraction, the file lock serializes two concurrent fetchers, extraction lands atomically by rename. wand's launcher tests cover only wand's URL, platform and pins logic on top of it.
  • CI Gates are the acceptance harness for what Go tests cannot assert: reproducible generation (zero diff, roll re-derivation), Tier 2 buildability, the in-container run, the printed browser path equal to the pin, zero leftover processes, coverage at 100 %, and the security scans.
  • Prior art: upstream's root-package tester pool and MockClient stubbing; the launcher package's download test on an ephemeral server and its fake-browser validation test; the CDP package's crash and basic tests against a real browser; the launcher User mode flag-set test; Go's standard library helper-process pattern for subprocess tests.

Out of Scope

  • API modernization toward Playwright / Puppeteer ergonomics, including a richer page-side evaluation API (handles, exposeFunction, waitForFunction) and an embedded JS runtime; its own map once the baseline exists.
  • Stealth: no evasion code, no stealth documentation, no default-flag change for detection reasons (--enable-automation stays); a later effort designed fresh.
  • Firefox / WebKit support; recording, REPL, CLI and similar long-standing upstream feature requests; upstream's peripheral repositories.
  • The remote-debugging pipe as the protocol transport; replacing lazyjson.JSON in the public API; flattening lib/ or further splitting lib/utils; a single wand CLI with subcommands.
  • Rehosting upstream's documentation site; a wand chat room or GitHub Discussions on the wand repository (issues are the only support surface).
  • Domestic browsers in the discovery list; domestic-platform runtime verification; old-world LoongArch, MIPS64, sw64.
  • Chrome for Testing as the only browser source; substitute builds where the Target Chrome is missing.
  • Any numeric Chrome floor in code.

Further Notes

Activity

  1. 26 remaining items

  2. Fly-Playgroud commented on Sep 7, 2026

    @Fly-Playgroud
    ContributorAuthor

    Amendment to section 13 (CI: runners, Gates and Nightlies), 2026-09-07. "Gates on every push and pull request" becomes "Gates on every pull request and on every push to main". Reason: both events checked every push to a branch under a pull request, the push run on the branch's head and the pull-request run on its merge result, a full matrix each (eleven jobs after #54), and a red push run beside a green pull-request run blocked the merge (#71, #88). A branch is checked by its pull request alone; the push run on main stays and is what the three consecutive green runs of #54's last criterion count. Decided by the driver after #88 merged; the change is #91. Everything else in the section stands, the concurrency rule included.

  3. Fly-Playgroud commented on Sep 8, 2026

    @Fly-Playgroud
    ContributorAuthor

    Amendment to section 17 (Announcement of the release candidate), 2026-09-08. The channel list gains a fourth entry: an index issue on go-rod/rod, beside the Discussions post, the Discord message and the per-issue comments. Reason: the issue tracker is an audience the other three do not reach — it is where someone who hits one of these bugs searches first, and a Discussions post is not indexed alongside the issues. The issue is an index, not a second announcement: it says so in its first line, lists the open upstream issues a Confirmed fix resolves, and points at the Discussions post, the Release notes and the migration guide for everything else. Like the others it is edited rather than re-commented at promotion, and it carries no label. Decided by the driver on the day rc.1 was tagged; the decision and the issue's shape are recorded on #32. Everything else in the section stands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions