Playwright-based browser automation harness. Fresh-context factory + probe + selftest. Designed for AI-native invocation: machine-parseable contract sentinels, deterministic resolver priority, no hidden global state.
hx install need-singularity/browser-harness
browser-harness probe # → ready| Method | Command | Notes |
|---|---|---|
| hx (canonical) | hx install need-singularity/browser-harness |
clones + runs install.sh + symlinks ~/.hx/bin/browser-harness |
| hx update | hx update browser-harness |
git pull; deps auto-resync on next invocation (see "Update tracking") |
| direct git | git clone https://github.com/need-singularity/browser-harness && cd browser-harness && ./install.sh |
manual path |
hx install need-singularity/browser-harness@<sha> is not supported by current
hx (regex ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ rejects @). Pin to a tag/sha by
manually checking out inside ~/.hx/packages/browser-harness/ if needed.
browser-harness <subcommand> [options]
| Subcommand | --target=mac (default) |
--target=ubu1|ubu2 |
--target=fleet |
Exit | Sentinel (stdout) |
|---|---|---|---|---|---|
probe |
check installability + factory loadable | ssh + remote node check | fan out across fleet hosts | 0 ready / 1 absent | ready (mac) or ready (remote=<host> node=<v> playwright=<v>) (remote) or absent: <why> |
selftest |
F1-F9 structural fixtures (no live browser, no SSH) | ssh + remote payload selftest (F1/F2/F3/F6 + F5-remote/F7-remote/F8-remote) | fan out | 0 PASS / 5 FAIL | __BROWSER_HARNESS_SELFTEST__ PASS|FAIL fails=<N> |
oauth-login --slot N [--headless] |
OAuth flow → slot-isolated storageState (see docs/oauth-login.md) |
scp state + remote launch + scp back (mode 0600) | NA — refused (slot state can only live on one host) | 0 / 1 / 4 / 51 / 52 | oauth-login: … (per-result) |
version |
print version | ssh + remote print | NA — prints Mac version | 0 | <X.Y.Z> |
help |
usage | — | — | 0 | — |
Exit code conventions: 0 PASS, 1 absent/probe-fail/oauth-fail, 2 fatal,
4 usage, 5 selftest fail, 51 oauth manual-required-but-headless,
52 oauth idempotent-already-logged-in.
| Var | Default | Effect |
|---|---|---|
BROWSER_HARNESS_ENGINE |
firefox |
chromium | firefox | webkit (selects Playwright engine) |
BROWSER_HARNESS_EXECUTABLE |
(auto) | path to browser binary; bypasses bundled |
BROWSER_HARNESS_STATE |
~/.browser-harness/state |
per-slot storage state dir |
BROWSER_HARNESS_HOME |
(unset) | override resolver root for hexa wrappers |
BROWSER_HARNESS_NO_BOOTSTRAP |
0 |
1 disables auto npm ci (CI hermetic mode; fail loud if deps missing) |
BROWSER_HARNESS_FLEET_HOSTS |
ubu2 |
comma-separated host list for --target fleet |
BROWSER_HARNESS_OAUTH_START_URL |
(required for oauth-login) |
the OAuth authorize URL to navigate to |
BROWSER_HARNESS_OAUTH_SUCCESS_PATTERN |
^https?://platform\.claude\.com/oauth/code/callback |
regex matched against page.url() to declare success |
BROWSER_HARNESS_OAUTH_TIMEOUT_MS |
300000 |
ceiling on the user-click phase (oauth-login) |
BROWSER_HARNESS_OAUTH_ENGINE |
chromium |
Playwright engine for the headed oauth-login window |
NODE |
$(command -v node) |
override Node binary path |
hx update does git pull --ff-only and refreshes the shim, but does not
re-run install.sh. The CLI handles dep drift itself by checking on every
invocation:
trigger npm install if any of:
- node_modules/ missing
- node_modules/playwright-core/ missing
- package.json newer than node_modules/.package-lock.json
- package-lock.json newer than node_modules/.package-lock.json
The cost is a single stat per invocation when in sync (cheap). Bootstrap
runs in ~2s on a warm npm cache. Set BROWSER_HARNESS_NO_BOOTSTRAP=1 to
disable (CI / immutable images).
const factory = require('/Users/<user>/.hx/packages/browser-harness/lib/factory.cjs');
await factory.withContext({ slot: 9, engine: 'firefox', headless: true }, async (h) => {
const page = await h.context.newPage();
await page.goto('https://example.com');
// ...
});
// dispose() runs even on throw — mandate M6| Export | Signature | Mandate |
|---|---|---|
create(opts) |
→ {browser, context, slot, statePath} |
M1 fresh context per call |
dispose(handle) |
→ void |
M2 close ctx + browser, idempotent on null |
persistState(handle) |
→ statePath | null |
M4 write storage state per slot |
withContext(opts, fn) |
→ <fn return> |
M6 dispose-on-throw |
slotStatePath(slot) |
→ string | null |
M3 slot isolation path |
Mandate enforcement (1-6):
- Fresh
BrowserContextpercreate()— no state leak between runs. dispose()closes context and browser; idempotent onnull/undefined.- Empty cookies/storage by default; opt-in via
slotparameter. - Slot storage at
BROWSER_HARNESS_STATE/slot-<N>.json. - Default timeout 30s via
context.setDefaultTimeout; override withopts.timeoutMs. withContext()runsdispose()infinally— clean exit even on throw.
For hexa scripts that prefer io-seam separation over direct shell:
hexa run ~/.hx/packages/browser-harness/wrappers/browser_harness.hexa --invoke probe
hexa run ~/.hx/packages/browser-harness/wrappers/browser_harness.hexa --invoke selftestResolver priority (deterministic, top-down):
1. $BROWSER_HARNESS_HOME/bin/{harness,browser-harness}
2. ~/.hx/bin/browser-harness ← canonical
3. ~/.hx/packages/browser-harness/bin/browser-harness
4. ~/.hx/packages/browser-harness/bin/harness ← legacy alias
5. ~/.local/bin/browser-harness
Wrapper sentinel: __BROWSER_HARNESS_PROBE__ status=<present|absent> path=<resolved>.
browser-harness selftest runs without launching a browser AND without making any SSH call. F1-F9:
| ID | Asserts |
|---|---|
| F1 | lib/factory.cjs exports complete (create, dispose, persistState, withContext, slotStatePath, STATE_DIR) |
| F2 | slotStatePath() — null for empty/null slot, slot-<N>.json for valid |
| F3 | dispose(null) and dispose(undefined) idempotent (no throw) |
| F4 | parseArgs flag-with-value vs flag-only roundtrip |
| F5 | probe subprocess returns ready\n exit 0 OR absent: …\n exit 1 |
| F6 | lib/oauth.cjs exports runOauthLogin, isStateValid, safeUrlPrefix; runOauthLogin({}) returns 4 (slot required); chmod 0600 capable on POSIX |
| F7 | lib/remote.cjs exports runRemote, runOauthLoginRemote, runFleet, preflight, buildPayload, fleetHosts; buildPayload(...) returns a self-contained Node script ('use strict'; header + inlined factory.cjs + oauth.cjs) |
| F8 | --target arg parsed correctly: default → mac; ubu1/ubu2/fleet → isRemoteTarget()===true; bare --target (no value) falls back to mac |
| F9 | tests/selftest_remote.cjs ships, lib/remote.cjs exports runRemoteSelftest, AND buildPayload({subcmd:'selftest'}) inlines the fixture (and only that subcmd does — probe/oauth-login/version payloads stay small) |
Exit 0 on fails=0, exit 5 otherwise. Final line is the sentinel.
Remote-side selftest (browser-harness selftest --target ubu*) runs symmetric coverage in-process on the remote: F1/F2/F3/F6 (same as bundled today) PLUS F5-remote (in-process runOauthLogin({}) → 4, no subprocess needed since bin/browser-harness is not shipped), F7-remote (factory + oauth co-loadable in same process — the canonical use case), F8-remote (SLOT_STATE dir creates with mode 0700 if absent). Same __BROWSER_HARNESS_SELFTEST__ PASS fails=N (remote) sentinel; lines prefixed with (remote).
CI integration (any orchestrator):
out=$(browser-harness selftest 2>&1)
echo "$out" | grep -q "__BROWSER_HARNESS_SELFTEST__ PASS fails=0" || { echo "$out"; exit 1; }v0.1.0— initial;bin/harnessentryv0.1.1— entry renamebin/browser-harness(matches hx auto-detect conventionbin/<pkg-name>);bin/harnesskept as symlink for back-compat; self-bootstrap on existence checkv0.1.2— dep-drift detection (lockfile mtime); shippedwrappers/browser_harness.hexav0.2.0— realoauth-login(lib/oauth.cjs); slot-isolated storageState persistence (mode 0600); exit codes 0/1/4/51/52 (50 removed);BROWSER_HARNESS_OAUTH_*env surface; F6 added to selftest. Seedocs/oauth-login.md.v0.2.1— bootstrap switched fromnpm installtonpm ci(with fallback tonpm installonly whenpackage-lock.jsonabsent). Eliminates thehx update browser-harnessfriction where the post-install lockfile regeneration leftpackage-lock.jsonlocally-modified, which then madegit pull --ff-onlyrefuse to apply the update.npm cireads but never writes the lockfile, so the working tree stays clean across invocations.v0.3.0—lib/remote.cjsMac→ubu1/ubu2/fleet SSH-pipe transport;--target {mac|ubu1|ubu2|fleet}flag added toprobe,selftest,version,oauth-login. Self-contained Node payload (factory.cjs + oauth.cjs inlined as base64) piped tossh <host> 'node -'; preflight asserts node + playwright on the remote (npx cache discovered automatically; falls back to one-shot tmpdir install).oauth-login --target ubu*SCPs slot-N.json over before launch and pulls the updated state back on success/idempotent (mode 0600); failure leaves Mac state untouched.--target fleetfans out acrossBROWSER_HARNESS_FLEET_HOSTS(defaultubu2);oauth-login --target fleetis refused. F7 (remote module) + F8 (target parsing) added to selftest. Bash exit-50 stub message superseded.v0.3.1— symmetric remote selftest + exit-52 SCP-skip via content hash. (1)tests/selftest_remote.cjsships and is inlined into the SSH payload bylib/remote.cjs::buildPayload()whensubcmd === 'selftest'. Remote coverage now matches the local F1-F8 surface as F1/F2/F3/F6 (bundled today) + F5-remote (in-processrunOauthLogin({}) → 4— no subprocess needed sincebin/browser-harnessisn't shipped) + F7-remote (factory + oauth co-loadable in the same process — the canonical use case) + F8-remote (SLOT_STATE dir creates with mode 0700 if absent). F4 stays CLI-side (parseArgs is a Mac concern); F7/F8 don't apply to the remote payload itself (the remote never inspects--target). Same__BROWSER_HARNESS_SELFTEST__ PASS fails=N (remote)sentinel; output lines prefixed with(remote). F9 added to local selftest as a structural guard (fixture exists +runRemoteSelftestexported + payload references it for the selftest subcmd only). (2)runOauthLoginRemotenow skips the SCP-back when the remote returned exit 52 AND the local + remote slot files are byte-identical (sha256 compared viassh <host> shasum -a 256 <path>). Logsremote.cjs: slot-<N> state byte-identical, skipped SCP-back. When local has no prior state but remote returned 52, still SCPs back (preserve a pre-seeded remote). Exit 0 always SCPs back.
browser-harness/
├── hexa.toml hx manifest (name, version, entry)
├── package.json npm manifest (playwright-core dep)
├── install.sh hx build hook (fresh install only)
├── bin/
│ ├── browser-harness CLI entry (bash → node); self-bootstraps deps
│ └── harness symlink → browser-harness (legacy)
├── lib/
│ ├── harness.cjs subcommand dispatcher (--target wiring)
│ ├── factory.cjs fresh-context factory (M1-M6)
│ ├── oauth.cjs real oauth-login flow (v0.2.0)
│ └── remote.cjs Mac→ubu1/ubu2/fleet SSH-pipe transport (v0.3.0)
├── docs/
│ └── oauth-login.md oauth-login design + exit codes + env + security
├── tests/
│ ├── selftest.cjs F1-F9 (Mac-side; default `--target mac`)
│ └── selftest_remote.cjs F1/F2/F3/F6 + F5-remote/F7-remote/F8-remote (inlined into SSH payload)
└── wrappers/
└── browser_harness.hexa optional hexa-side wrapper
MIT.