Releases: pA1nD/horse-browser
Release list
v0.16.0 — your agents are taught at install, and stay taught
v0.16.0 — your agents are taught at install, and stay taught
horse-browser writes three files it does not live in: the Claude Code lane hook in
~/.claude/settings.json, the grok session hook, and the rule that tells an agent how to drive
this browser. They had three implementations and three trigger policies, and each went wrong
in its own way.
The lane hook APPENDED. Its repair pass rewrote a dead entry to the current path without
checking whether that path was already registered, so every checkout that ever ran the
launcher left behind a permanent duplicate. The operator's machine had ten registrations where
one belongs — the count was a tally of temp clones, and every Bash call in every session ran
the hook ten times. It was watched happening live: a test run "repaired" a dead entry into a
tenth copy of the live one.
A build agent's throwaway checkout under $TMPDIR wired its own path into that same global
settings.json, was cleaned up an hour later, and left every Bash call in every Claude Code
session on the machine failing a hook that no longer existed.
And the rule was only ever written by an explicit command, so it froze at whatever release you
last applied it from. The operator's copy was five releases old and missing the paragraph that
tells agents not to idle on a bare sleep — which had shipped that same morning.
One manifest, one reconciler, LAST-WRITER-WINS. It no longer asks whether the entry on disk is
stale or whether we are already present; it makes our entries be exactly the entries that are
there. Idempotence falls out of that, and two installs converge instead of piling up. The
correct pattern was already in the tree: reset_stale_sw and the grok hook both compare against
what they last saw. The lane hook was the only place using a wall-clock throttle, and it was
the only place that broke.
THE RULE IS NOW ON BY DEFAULT. Installing an agent browser that agents have not been told
about is a half-install. The policy this has to respect is "never touch ~/.claude from a SILENT
install", and it prints a line for every file it writes; horse-browser rule off removes it
and stops rewriting it. Nothing to re-run after an upgrade — RULE.md in the package is the
source, and the copy on disk is reconciled against it.
The gate is a fingerprint, not a clock: one stat covering the sources, the targets, $ROOT and
the enabled set, so a reconcile happens when something actually changed. ~2ms against the 21ms
python3 spawn it protects, and it reacts at once rather than up to an hour later. It compares
sub-second mtime and size because test -nt compares WHOLE SECONDS on this platform — the
first cut of this gate could not see a settings.json edited in the same second as the stamp,
and the suite caught it.
npm fires no uninstall hook: verified empirically, neither preuninstall nor postuninstall runs
for npm rm -g. So the registration outlives the package. The command is now registered
wrapped — sh -c '[ -x … ] || exit 0; exec …' — which costs nothing, since the shell would
spawn to run the hook anyway, and turns "package removed" from a machine-wide breakage into
nothing at all.
Isolation, because a test run is what caused this: 16 of 19 suites set no opt-out, so npm test from ANY clone wrote that clone's path into the operator's global config. Every suite now
exports HORSE_BROWSER_NO_RECONCILE=1, and the reconciler refuses outright when $ROOT is under a
temp dir — a tree living in a temp dir is not an installation.
Also removed: five migrations whose targets no longer exist anywhere — the pre-rules CLAUDE.md
block strip, the bh_open loader stub, two legacy browser-harness workspace locations, and the
pre-0.9.7 global heal stamp. tools/lane_hook_wire.py and 60 lines of install.sh went with them,
and claude-md.sh dropped to a shim, so there is one implementation rather than two.
Upgrading: this rewrites your lane-hook entries to the guarded form and collapses any
duplicates on the first run. If you would rather not have the rule in every session, run
horse-browser rule off once.
Suite: 349 checks, 17 suites, 0 failures.
v0.15.0 — the browser stops painting when nobody is driving
v0.15.0 — the browser stops painting when nobody is driving
Chrome for Testing sat at the top of the operator's energy list on battery, burning ~125% of
a core with its window not focused and almost no tabs open. Two causes, both ours, found with
exact CPU-time deltas rather than the %CPU lifetime average that hid them:
Emulation.setFocusEmulationEnabled was set once by switch_tab and never cleared. Making a
background tab report itself visible and focused is the whole point — pages misbehave when
they are never focused — but a page that believes it is visible PAINTS, so every tab any
agent had ever touched kept compositing for the daemon's entire life. It is now a lease owned
by the daemon: taken by the first call that drives a tab, renewed by every call after,
dropped after HORSE_BROWSER_FOCUS_TTL (60s) of silence, and retaken — awaited — before the
next call touches the page. The 🐴 mark rides the lease, so the horse finally means "being
driven right now" instead of accumulating on every tab ever visited.
The Monitor screencast ran at everyNthFrame:1 whether or not anyone was looking: 160 fps
aggregate, 3.86 MB/s of JPEG, ~30% of a core around the clock. It now follows attention —
live (window focused) / glance (visible but unfocused: the second-display wall, ~1 fps) / off
(tab not on screen: nothing). Occlusion detection is off in this browser by launch flag, so
"buried behind an editor" is indistinguishable from "on a side monitor"; glance serves both
and is priced so the buried case is affordable.
Result on the operator's browser: ~125% -> 0.0%.
Gestures are paced against the clock, not against sleeps. A health run under fleet load
measured the drag at 8.6-9.4s where an idle machine gives 1.7-2.2s — a nine-second drag of
280px is not a hand, and the sites this exists to get past score exactly that, so it was a
realness defect that surfaced as a failing test. Every loop dispatched a sample and then
slept the planned gap, making the real gap dispatch + sleep; a dispatch costs ~2ms idle and
~66ms under load. Sample spacing is now a schedule, and the path advances on elapsed time, so
falling behind costs SAMPLES rather than seconds: the drag thins out under load, which a hand
does, instead of crawling, which no hand does. Measured against injected dispatch cost:
0ms 1.82s · 60ms 2.08s · 120ms 3.18s, where the old code took 6.15s. The approach and the
pre-press hover are paced the same way; press_hold already was.
Three test defects fixed while proving the above, each a real failure mode rather than noise:
-
Fixture servers were started and never checked. port() binds an ephemeral port, closes it,
and returns the number, so under load the OS hands that port to another connection first —
http.server died, Chrome got chrome-error://chromewebdata, and 12 gestures were recorded
against a blank page. Fixtures now bind before announcing they are up, in four suites. -
The first pacing tests injected real sleeps and duly failed on a busy machine: the exact
defect they exist to catch, one layer up. They run on a virtual clock now, with the
per-dispatch cost injected, so the thing under test is the only variable. -
The overshoot check counted how many of 12 real drags overshot and required 2..10 — a fair
coin asked to land between 2 and 10 heads in 12 throws, which fails 0.6% of the time on
correct code. That rate is worse than a higher one: it always arrives as "just re-run it".
Found by Monte-Carlo'ing every drag-profile check over 400 simulated runs at three dispatch
costs; it was the only one that ever failed. The frequency claim moved to 400 virtual-clock
samples where a wrong rate is a >5-sigma event, and the shape claim — an overshoot is a
real one or none, never a nudge — stayed in the browser, where it cannot flake.
Also: the drag duration band was tighter than the human traces it encodes (the 14 recorded
hands run 1.08-3.42s; the band said 0.45-3.0s), and the lease sweeper ticked at a fixed 10s,
so a deliberately short TTL meant something other than what was asked. Both now follow their
own data.
Suite: 336 checks, 17 suites, 0 failures.
v0.14.0 — the Runtime domain is no longer enabled
v0.14.0 — the Runtime domain is no longer enabled
Brotector flagged horse-browser 25 times on a single page load:
runtime.enabled webdriver 1 { stackLookupCount: 0, nameLookupCount: 3 }
Runtime.enable is checked by Cloudflare and DataDome both, and it was costing us nothing.
The domain exists to deliver Runtime.* EVENTS; this codebase consumes none — the only ones
it acts on are Page.javascriptDialog* and Network.*. Runtime.evaluate is a command and works
whether the domain is enabled or not. Dropped, and brotector now reports an empty table.
Why it survived: rebrowser's detector, run against this same browser an hour earlier, said
"runtimeEnableLeak: no leak detected". It probes the stack getter variant only. Brotector
also probes name, and that is the one that catches it — both counts appear in the same
line, 0 and 3. One green detector is not a clean bill of health.
The unit tests asserted the old behaviour and duly failed. They now assert the ABSENCE, so
nobody restores it for symmetry with the other three domains.
290 checks, 16 suites.
v0.13.3 — input that a page cannot tell from a hand's
v0.13.3 — input that a page cannot tell from a hand's, and the tool that proved it wasn't
background tab: screenX 0 screenY 0 outerWidth 0 outerHeight 0
foreground tab: screenX 4096 screenY 30 outerWidth 2028 outerHeight 1102
window.outerWidth === 0 is on every bot-detection checklist there is, and every page this
tool has ever driven reported it — because not taking the operator's focus is the whole
promise, so tabs are never brought to the front. The central design decision was emitting a
textbook automation signature on every page load.
Fixed without giving up backgrounding: the service worker knows the window's real bounds, an
isolated-world bridge carries them to the page's world, and the getters use them. A
backgrounded tab now reports the window it is actually in.
Four more states no physical device produces, all found in one sitting:
PointerEvent.pressure was 0 while a button was held. Pressure is CDP's force, which
defaults to 0 — every click, hold and drag reported contact with no force behind it.
KeyboardEvent.location was 0 on ShiftLeft. A real left Shift reports 1. That was in every
capital letter and shifted symbol ever typed, including by the credential broker.
screenX === clientX on injected events. CDP takes one coordinate pair and Chrome copies it
into both spaces, ignoring the window; real input derives them independently.
Every dispatched coordinate was rounded to a whole number. Chrome preserves fractional
coordinates exactly, so this was ours — and a real pointer is sub-pixel on any display with
a device pixel ratio above 1.
The last one had two layers under it. The rounding caused apparent stalls, which earned a
mechanism to break them up and a code comment blaming Chrome for quantising input; and the
"human baseline" that seemed to permit those stalls came from a recorder rounding to 0.1px,
while the test measured mousemove — Chrome's rounded compat copy — and agreed with both. A
recorded human session settles it: the same gesture reads 100% fractional with zero repeats
as pointermove, and 0% fractional with 18.4% repeats as mousemove.
tests/lib/event-audit.py is why the rest were found. It records the complete property set of
every pointer, mouse and keyboard event we synthesise — about thirty fields and seventeen —
asserts the combinations physics forbids, and diffs every remaining field against a recording
of a real hand. It found the Shift bug minutes after it existed, and it runs in the suite, so
the class fails a build rather than a challenge. No public tool does this: sannysoft, CreepJS,
browserscan and fingerprint.com's demo all test the browser's fingerprint, none check whether
the input is physically coherent.
Every field now overlaps with the recorded hand.
290 checks (128 harness + 162 launcher/integration), 16 suites.
v0.13.2 — input events that are physically coherent, and a tool that checks
v0.13.2 — input events that are physically coherent, and a tool that checks
Two states this tool had been emitting that no physical device can produce. Both are one
line of JavaScript for a page to check, and both were in every gesture and every
keystroke it had ever sent.
PointerEvent.pressure was 0 while a button was held. Pressure is whatever CDP's force
said, and CDP defaults it to 0 — so every click, hold and drag reported contact with no
force behind it. A real mouse reports 0.5 while any button is down and 0 otherwise.
KeyboardEvent.location was 0 on ShiftLeft. code says which physical key, location says
which side, and a real left Shift always reports 1. That was in every capital letter and
every shifted symbol typed, including anything the credential broker types into a login
form. Now derived from the code: left 1, right 2, numpad 3.
The first was found by hand. The second was found by tests/lib/event-audit.py, which is
the general version: it records the complete property set of every pointer, mouse and
keyboard event we synthesise (about thirty fields and seventeen respectively), asserts the
combinations physics forbids, and diffs every remaining field against a recording of a
real hand. It runs in the suite, so the class fails a build rather than a challenge.
The other fields check out, recorded here so nobody re-audits them: tilt, twist and
tangentialPressure are 0 (correct for a mouse), width and height 1x1, isPrimary true,
isTrusted true, pointerId stable, coalesced counts 1 and predicted 0-2. movementX/Y is
(0,0) on about 5% of moves, which is a hand pausing rather than a stalled loop.
No public tool tests this. bot.sannysoft.com, CreepJS, browserscan, fingerprint.com's demo
all test the browser's fingerprint; none check whether the input is physically coherent.
Neither fix clears DataDome, and the reason appears to be elsewhere: on a residential exit
the same code passes every site clean — including with --enable-automation and no realness
extension at all — while a datacenter exit is challenged even fully masked. Effort on
gesture realism is capped by the address.
290 checks (128 harness + 162 launcher/integration), 16 suites.
v0.13.1 — the drag is fitted to a real hand now, not to reasoning about one
v0.13.1 — the drag is fitted to a real hand now, not to reasoning about one
Every property drag() had was invented: an approach, asymmetric velocity, overshoot and
correction, a hold before release. All plausible. None measured. The only judge that could
settle it — a vendor's scoring — answers one bit at a time and costs a site per answer.
So a person dragged the widget. tests/lib/human-trace.py serves a slider with the live
DataDome geometry (280x40 track, 63x40 handle, 222px of travel), records the pointer's
whole life, and puts hand and machine side by side on the nine numbers the test asserts.
Fourteen human drags. Six of the nine were wrong:
metric was hand now
dur 0.90 1.75 1.92
samples 27 63.5 65
approach 4 16 17 (samples ON the control before the press)
half_at 0.33 0.17 0.17 (time fraction to cover half the distance)
dy_range 1.8 28.85 28.5 (vertical wander across the travel)
overshoot 0.5-4 bimodal bimodal
dy_range is the one worth naming. The drag held a straight line to within 2px across 222px
of travel; a hand wanders 29px. Noticing a pointer that never leaves the axis it moves
along takes no model of intent at all.
Overshoot was wrong in a way reasoning does not reach. Not "a small consistent overshoot":
7 of 14 human drags had none, and the other 7 ran 5-24px past before correcting. Always
overshooting a little is the average of two behaviours and resembles neither.
Two mechanical findings behind the rest. Sample spacing had to become heavy-tailed — a
hand's coefficient of variation is 1.14, where uniform randomness gives 0.3, because
evenly-spread randomness is still evenly spread. And Chrome quantises dispatched
coordinates, so a jitter decaying to zero as the gesture settles falls below the rounding
grid and emits five to seven identical samples; a hand repeats a position too, but never
more than three times. Allow the repeat, cap the run.
The traces are committed, so drag-profile.sh takes its bounds from them rather than from
taste, and any later change to drag() can be re-scored against the same hand.
286 checks (128 harness + 158 launcher/integration), 15 suites.
v0.13.0 — the solver reads the frame, and the gesture is shaped like a hand
v0.13.0 — the solver reads the frame, and the gesture is shaped like a hand
A challenge sealed in a cross-origin iframe is not out of reach: the frame is its own
CDP target, or — when site isolation keeps it in the parent's process — a document the
parent can read directly. Either way the control's real rect is readable, so the solver
aims at pixels instead of guessing a fraction of the frame.
Found by driving live challenges end to end. Each of these alone was enough to fail:
- iframe_target returned a frame from ANOTHER TAB. It matched on URL across the whole
browser, and the browser is shared. The tell pointed the wrong way: the frame read
visibilityState "hidden" and its widget had never rendered. It now asks the parent who
owns the frame. - The scorer preferred a 338x121 text wrapper to the 63x40 handle nested inside it. Text
names the widget; the cursor names the grip. A grab cursor now outranks everything. - The drag was shaped like a loop — symmetric easing, even sample spacing, no approach,
instant release. Right coordinates, wrong hand. - The gesture waited for the renderer. On a live slider under software rendering that is
a layout and a paint per move: ~400ms each, against ~16ms over empty background, which
made a 1.5s drag take 26.85s. Gesture samples are now sent, not awaited. Live: 1.32s. - A same-site challenge frame has no CDP target, and that was read as unreadable. It sent
a perfectly readable reCAPTCHA checkbox to vision. - solve_challenge escalated challenges that had already passed, and its handoff message
claimed the DOM could not be read while handing over coordinates read from it.
Also: horse-browser instances — pid, port, uptime, live tab count and profile for every
browser with a debugging port, strays marked. Four throwaway browsers had been up for the
better part of an hour with nothing in the tool that would mention them.
And on a virtual display, software rendering is no longer "healed": Xvfb has no hardware
GL, so that check fired on every invocation and relaunched the browser under live
sessions, losing their tabs and websockets.
Verified against live vendors: Cloudflare Turnstile passes and is reported as passed;
reCAPTCHA v2's checkbox is located and clicked, and the vendor answers by opening its
image grid (perception from there, correctly handed off); DataDome's slider is located to
the pixel and driven in 1.32s, but its scoring does not yet accept the motion.
286 checks (128 harness + 158 launcher/integration), 15 suites.
v0.12.3 — small /dev/shm survived, fallbacks announce themselves
v0.12.3 — small /dev/shm survived, fallbacks announce themselves
A 64MB /dev/shm (the container default) crashes Chrome's renderer on heavy pages;
the launcher detects it, works around it, and names the real fix (--shm-size=1g).
Every remaining fallback now says what it gave up, once. The dangerous one:
profile_pids returned 'nobody holds this profile' when it simply could not look.
158 checks.
v0.12.2 — external review findings
v0.12.2 — external review findings
CRITICAL: every Linux session self-reaped after ~32s (BH_ANCHOR_START dialect
mismatch between detect.sh and the harness).
HIGH: concurrent registry writers lost tabs (now flocked); the reaper could close
live tabs via temp files in tabs/ (now dotfiles); the watchdog deleted the registry
after failing to close anything; a live lock holder was evicted at 120s.
MEDIUM: two subcommands ate stdin; the GREASE brand was one no Chrome 151 produces;
the WebGL substitute contradicted the platform; DISPLAY hijack; $CONFIG parent.
Plus: tests/attached-mode.sh stole the operator's focus on every run.
158 checks, 0 focus steals.
v0.12.1 — first run on a virgin machine
v0.12.1 — first run on a virgin machine
hb_lock used mkdir instead of mkdir -p, so on a $HOME without
~/.config/horse-browser it failed with ENOENT — and reported that as 'another
invocation holds the lock'. A fresh machine refused to launch and blamed a
process that never existed.
Three writers assumed directories only install.sh creates; all now create their
own parent. hb_lock returns 2 for an unusable path so callers stop waiting on a
phantom holder.
tests/first-run.sh (10 checks) runs everything against a throwaway $HOME.