Browser-Automation Harness β Playwright-based Β· fresh-context factory Β· OAuth slot persistence Β· SSH-pipe remote fanout
Playwright Β· fresh-context Β· OAuth-slot Β· SSH-pipe Β· fleet-fanout Β· AI-native sentinels
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.
- v0.3.1 β symmetric remote selftest + exit-52 SCP-skip via content hash (see Versioning)
- F1-F9 structural selftest fixtures Β· F5-remote/F7-remote/F8-remote on
--target ubu* - 3 Playwright engines (chromium / firefox / webkit), default
firefox - Remote targets:
mac(default) Β·ubu1Β·ubu2Β·fleet(SSH-piped Node payload) - Exit codes:
0PASS Β·1absent/probe-fail Β·2fatal Β·4usage Β·5selftest fail Β·51oauth manual-required-but-headless Β·52oauth idempotent
# 1. Install hexa-lang (gives you `hexa` + `hx` package manager)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/dancinlab/hexa-lang/main/install.sh)"
# 2. Install browser-harness
hx install browser-harnessbrowser-harness probe # check install + factory loadable
browser-harness selftest # run F1-F9 structural fixtures
browser-harness oauth-login --slot N [--headless] # oauth flow (see docs/oauth-login.md)
browser-harness version # print version (fleet β Mac version only)
browser-harness help # usageAll subcommands above accept [--target {mac|ubu1|ubu2|fleet}] (oauth-login: mac|ubu1|ubu2 only).
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.