The green-circle prefix didn't fit the harness theme. Horse echoes the project name (browser-harness) and pairs naturally with chess-knight imagery.
Also fixes a latent slice bug: the prefix is 3 UTF-16 units (surrogate pair + space), so unmark uses slice(3) and removes the trailing space cleanly. The previous 🟢 + slice(2) left the space behind, which would slowly accumulate leading spaces across switch_tab cycles.
Several users flagged that the install.md demo prompts the agent to ask
whether to star browser-harness on their behalf, which reads as star-farming.
Drop the star ask entirely — the demo still opens the repo page so the user
can see the harness has attached, but no longer solicits a star.
A single BH_TMP_DIR conflated two storage concerns with opposite
constraints: sock/port/pid files are bound by AF_UNIX sun_path (104
bytes on macOS, 108 on Linux) and must live in a short path; log files
and screenshots have no length limit and benefit from a deep, indexable,
persistent location. Forcing both into one dir means callers either
bury screenshots under /tmp or risk silently overrunning sun_path.
Decouple with a new BH_RUNTIME_DIR for sock/port/pid. BH_TMP_DIR keeps
log/screenshot duty. BH_RUNTIME_DIR falls back to BH_TMP_DIR, then to
/tmp on POSIX or tempfile.gettempdir() on Windows, so single-dir callers
keep working unchanged.
Previously start_unix_server() would bind() and listen() with mode
derived from the process umask before os.chmod(0o600) ran, leaving a
brief window where the socket's mode reflected umask. Setting umask
0o077 around the bind makes the socket land at 0600 directly, removing
the window.
Refs #298
Calling Target.getTargetInfo from helpers can't work: the daemon strips
session_id for any Target.* method, so the call hit the browser-level
connection with no targetId, and Chrome silently returned info about the
*browser* target (empty url/title) instead of the attached page. New
current_tab meta uses self.target_id like connection_status already does.
Fixes#304.
A DNS/timeout/socket error during PATCH /browsers/{id} was bubbling out
of the loop, leaving remaining zombies running and still billing.
HTTPError is a subclass of URLError, so the existing clause stays first
and the URLError catch handles only the transport-level failures.
Adds an operator-facing skill for cloud.browser-use.com / api.browser-use.com,
covering the same REST surface that the harness's own admin.py already
uses (X-Browser-Use-API-Key header, /browsers, /profiles), plus a
companion script for the most common automation -- stopping zombie
sessions older than N minutes.
Live-tested on 2026-05-05 with a real BROWSER_USE_API_KEY:
POST /browsers 201 - shape verified incl. liveUrl on live.browser-use.com
PATCH /browsers/{id} stop 200 - returns final cost
GET /browsers 200 - paginated {items, totalItems, ...}
GET /profiles 200 - same envelope
GET /profiles/{id} 200 - cookieDomains=None on fresh profiles
GET /usage 404 - no public endpoint, doc'd accordingly
GET / 404 - no root metadata
The cleanup-zombies.py companion is the regression artefact; running it
in dry-run mode is the cheapest smoke test, and a full E2E loop
(spawn -> list -> stop -> re-list) was confirmed end-to-end during
authoring.
Notable wire gotchas surfaced and documented:
- Cost / proxy fields (proxyCost, browserCost, proxyUsedMb) are returned
as JSON strings, not numbers; cast to float before arithmetic.
- liveUrl host is live.browser-use.com (different from cloud.browser-use.com),
with the cdp WebSocket encoded as a ?wss= query parameter.
- cookieDomains can be null on a freshly-created profile despite
list_cloud_profiles' docstring describing it as an array.
- GET /usage returns 404 -- per-session cost lives on each browser
record; aggregate billing only on the dashboard.
Style follows the claude-ai/share-export (#267) pattern of
markdown-skill-with-companion-py, sized at 222 + 161 LOC. Discovery
under helpers.py:163's current logic would resolve cloud.browser-use.com
to "cloud/" rather than "browser-use-cloud/" -- the skill folder name
mirrors the convention used by claude-ai/, vercel/, and tasksquad-ai/
which are also not auto-discoverable today; PR #165 is the broader fix
for that.
Refs: PR #300 (run.py precedence fix in the same area), PR #267
(claude-ai companion-script pattern), PR #288 (vercel dashboard skill
header style).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
install.md:58-59 documents BU_CDP_URL / BU_CDP_WS as overrides for local
Chrome discovery, but run.py:87-92's auto-bootstrap guard never checked
either env var. With BU_CDP_URL set plus BROWSER_USE_API_KEY and
BU_AUTOSPAWN (commonly set together for unrelated reasons -- profile sync,
headless CI fallback, parent agents managing their own session), no daemon,
and no local Chrome on 9222/9223, the guard fires and start_remote_daemon()
provisions a billed cloud browser. admin.py:471 then calls
ensure_daemon(env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), ...}),
overwriting the user's explicit endpoint with the cloud WebSocket. Net:
surprise cloud bill plus silent endpoint replacement.
Add _explicit_cdp_configured() next to _local_chrome_listening() and gate
the auto-bootstrap with `not _explicit_cdp_configured()`. Mirrors the
existing _is_local_chrome_mode helper at admin.py:159-161 that already
treats BU_CDP_WS as the "not local discovery" signal. #277's
fresh-headless-box behaviour (no explicit endpoint set) is preserved.
Tests: 8 new unit tests in tests/unit/test_run.py covering URL/WS variants,
both-set, empty-string, daemon-alive and local-Chrome short-circuit
robustness, and direct helper input/output. tests/unit/ goes from 66 to 74,
all green. Without this patch, exactly 5 of the new tests fail (the ones
targeting the guard + helper); the 3 short-circuit/empty edge cases pass
even unpatched because existing guards already cover those paths.
Refs #266, #277, #292.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
codex flagged that set_session was now performing up to four sequential
domain enables (5s timeout each) plus a 2s Network.disable, so a slow
or remote daemon could block the synchronous IPC reply for ~22s while
the helper's _send() socket has only a 5s read timeout. Old code only
awaited Page.enable (3s) on this path.
Three changes to keep the reply under the IPC deadline:
1. _enable_default_domains now awaits asyncio.gather over the four
Domain.enable coroutines instead of looping sequentially. Per-call
timeout reduced from 5s to 4s. Worst case is bounded by a single
CDP round trip rather than four.
2. set_session schedules the old-session Network.disable in the same
gather as the four enables on the new session — independent CDP
sessions, no ordering required for correctness (the consumer-side
filter in wait_for_network_idle is the actual correctness gate).
3. The 🟢 tab-marker title-prefix Runtime.evaluate is now fire-and-forget
via asyncio.create_task (+ _silent). It's purely cosmetic; agents
shouldn't wait on it.
Worst-case set_session reply time: max(2s disable, 4s parallel enables)
≈ 4s, comfortably under the 5s IPC timeout. Normal case on a remote
daemon drops from ~800ms (4 sequential round trips) to ~200ms (1 round
trip in parallel).
Two new tests in tests/unit/test_daemon.py use a fake CDP whose
send_raw blocks on an asyncio.Event, then assert the peak in-flight
call count:
- with a previous session: peak == 5 (1 disable + 4 enables)
- first attach: peak == 4 (4 enables, disable skipped)
Sequential await would peak at 1 on both. Full suite: 65 passed.
codex flagged a P2 follow-on to the just-added Network.enable: my fix
reliably enables Network on every fresh session, but old sessions
never get Network disabled (helpers.switch_tab attaches without
detaching), and wait_for_network_idle reads from the daemon's global
drain_events stream without filtering by session_id. Net effect: an
agent that visited a polling/SSE tab and switched away would observe
that background tab's traffic in a later wait, either timing out or
waiting on the wrong tab's requests.
Two narrow fixes:
1. helpers.wait_for_network_idle now captures the active session at
the start of the wait and skips events whose session_id doesn't
match. That's the consumer-side root-cause fix.
2. daemon set_session now calls Network.disable on the previous
session before enabling on the new one. Defense in depth — keeps
the daemon's event buffer from filling with background-tab noise
in the first place. Best-effort with its own 2s timeout; failure
doesn't abort the rest of the handler.
Tests added:
- tests/unit/test_helpers.py: events from a background session are
ignored; the active session can reach idle even when the
background session is busy.
- tests/unit/test_daemon.py: set_session disables Network on the old
session; first set_session call (no prior session) does not call
Network.disable.
Full suite: 63 passed (60 -> 63).
Prior to this change, the daemon's set_session meta-handler only called
Page.enable on the new CDP session. The initial-attach path enabled all
four of Page/DOM/Runtime/Network. set_session is what backs switch_tab()
and new_tab() in helpers.py, so any helper that depends on Network
events — most notably the wait_for_network_idle() that just landed in
PR #258 — silently stopped receiving events after a tab switch.
Refactored: extracted the domain-enable loop into a private
_enable_default_domains(session_id) helper used by both attach_first_page
and the set_session handler. Each domain is enabled with its own
timeout, and a single failure does not abort the others.
Also tightened the target_id fallback semantics: if a caller passes
target_id=None on set_session, the daemon keeps its existing target_id
rather than overwriting with None (preserves the existing 'or' fallback,
just covered by a test now).
Tests in new tests/unit/test_daemon.py drive Daemon.handle() directly
with a fake CDP client and assert:
- set_session enables all four default domains on the new session
(the regression — would fail against the old single-Page.enable code)
- target_id is preserved when caller passes None
- _enable_default_domains attempts every domain even when one raises
Identified via codex review (P1). Full suite: 60 passed.