Skip to content

Commit 0c9597b

Browse files
authored
Merge pull request #301 from ComBba/feat/domain-skills-browser-use-cloud
feat(domain-skills): add browser-use-cloud (REST + cleanup-zombies)
2 parents 2b8a3a6 + 3de7fe0 commit 0c9597b

2 files changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Stop active Browser Use cloud browsers older than `--older-than` minutes.
2+
3+
Designed to be the live regression artefact for the cloud.md skill in this
4+
folder — running it exercises GET /browsers + PATCH /browsers/{id}/stop on
5+
the public API and surfaces every wire-shape gotcha the skill documents.
6+
7+
Usage:
8+
BROWSER_USE_API_KEY=... python cleanup-zombies.py
9+
# stop browsers running longer than 30 minutes (default)
10+
11+
BROWSER_USE_API_KEY=... python cleanup-zombies.py --older-than 5 --dry-run
12+
# preview only; no PATCH /stop sent
13+
14+
BROWSER_USE_API_KEY=... python cleanup-zombies.py --json
15+
# machine-readable output (one record per browser inspected)
16+
17+
Exit codes:
18+
0 any zombies stopped (or none needed)
19+
1 API error (auth, network, etc.)
20+
2 bad CLI args
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import argparse
26+
import datetime
27+
import json
28+
import os
29+
import sys
30+
import urllib.error
31+
import urllib.request
32+
33+
API = "https://api.browser-use.com/api/v3"
34+
35+
36+
def _headers() -> dict[str, str]:
37+
key = os.environ.get("BROWSER_USE_API_KEY")
38+
if not key:
39+
sys.exit("BROWSER_USE_API_KEY is not set")
40+
return {
41+
"X-Browser-Use-API-Key": key,
42+
"Content-Type": "application/json",
43+
"Accept": "application/json",
44+
}
45+
46+
47+
def _call(method: str, path: str, body: dict | None = None, timeout: float = 30.0) -> dict:
48+
req = urllib.request.Request(
49+
f"{API}{path}",
50+
method=method,
51+
data=(json.dumps(body).encode() if body is not None else None),
52+
headers=_headers(),
53+
)
54+
with urllib.request.urlopen(req, timeout=timeout) as resp:
55+
return json.loads(resp.read() or b"{}")
56+
57+
58+
def list_active_browsers() -> list[dict]:
59+
"""Return only sessions that are still alive (no `finishedAt`)."""
60+
out, page = [], 1
61+
while True:
62+
listing = _call("GET", f"/browsers?pageSize=100&pageNumber={page}")
63+
items = listing.get("items") or []
64+
if not items:
65+
break
66+
out.extend(b for b in items if not b.get("finishedAt"))
67+
if len(out) + sum(1 for b in items if b.get("finishedAt")) >= listing.get("totalItems", len(items)):
68+
break
69+
page += 1
70+
return out
71+
72+
73+
def _parse_started(b: dict) -> datetime.datetime:
74+
"""`startedAt` is ISO 8601 UTC with a trailing `Z`. Python <3.11 needs the swap."""
75+
return datetime.datetime.fromisoformat(b["startedAt"].replace("Z", "+00:00"))
76+
77+
78+
def _to_float(v: str | None) -> float:
79+
"""Cost / proxy fields come back as strings; tolerate `None` and empty."""
80+
return float(v) if v else 0.0
81+
82+
83+
def stop_browser(browser_id: str) -> dict:
84+
return _call("PATCH", f"/browsers/{browser_id}", {"action": "stop"})
85+
86+
87+
def main() -> int:
88+
parser = argparse.ArgumentParser(
89+
description="Stop Browser Use cloud browsers older than N minutes.",
90+
)
91+
parser.add_argument("--older-than", type=int, default=30, metavar="MIN",
92+
help="age threshold in minutes (default: 30)")
93+
parser.add_argument("--dry-run", action="store_true",
94+
help="list zombies but do not call PATCH /stop")
95+
parser.add_argument("--json", action="store_true",
96+
help="emit one JSON object per inspected browser")
97+
args = parser.parse_args()
98+
99+
if args.older_than < 0:
100+
parser.error("--older-than must be non-negative")
101+
102+
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=args.older_than)
103+
104+
try:
105+
active = list_active_browsers()
106+
except urllib.error.HTTPError as e:
107+
sys.stderr.write(f"GET /browsers failed: HTTP {e.code} -- {e.read().decode('utf-8', 'replace')[:200]}\n")
108+
return 1
109+
except urllib.error.URLError as e:
110+
sys.stderr.write(f"GET /browsers network error: {e}\n")
111+
return 1
112+
113+
stopped = 0
114+
for b in active:
115+
started = _parse_started(b)
116+
age_min = (datetime.datetime.now(datetime.timezone.utc) - started).total_seconds() / 60
117+
is_zombie = started < cutoff
118+
record = {
119+
"id": b["id"],
120+
"started_at": b["startedAt"],
121+
"age_minutes": round(age_min, 1),
122+
"browser_cost": _to_float(b.get("browserCost")),
123+
"proxy_cost": _to_float(b.get("proxyCost")),
124+
"proxy_used_mb": _to_float(b.get("proxyUsedMb")),
125+
"is_zombie": is_zombie,
126+
"action": "skipped",
127+
}
128+
if is_zombie:
129+
if args.dry_run:
130+
record["action"] = "would_stop"
131+
else:
132+
try:
133+
final = stop_browser(b["id"])
134+
record["action"] = "stopped"
135+
record["final_browser_cost"] = _to_float(final.get("browserCost"))
136+
record["final_proxy_cost"] = _to_float(final.get("proxyCost"))
137+
stopped += 1
138+
except urllib.error.HTTPError as e:
139+
record["action"] = f"stop_failed: HTTP {e.code}"
140+
except urllib.error.URLError as e:
141+
record["action"] = f"stop_failed: {e.reason}"
142+
143+
if args.json:
144+
print(json.dumps(record))
145+
else:
146+
tag = {
147+
"skipped": "OK",
148+
"would_stop": "DRY",
149+
"stopped": "STOP",
150+
}.get(record["action"], record["action"])
151+
print(
152+
f"[{tag}] {record['id']} age={record['age_minutes']:5.1f}min "
153+
f"cost=${record['browser_cost']+record['proxy_cost']:.4f}"
154+
)
155+
156+
if not args.json:
157+
verb = "would stop" if args.dry_run else "stopped"
158+
print(f"summary: {len(active)} active session(s), {verb} {stopped if not args.dry_run else sum(1 for b in active if _parse_started(b) < cutoff)}")
159+
return 0
160+
161+
162+
if __name__ == "__main__":
163+
sys.exit(main())
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Browser Use Cloud — Programmatic Automation
2+
3+
`https://api.browser-use.com/api/v3` (REST). All five endpoints below were
4+
exercised end-to-end on 2026-05-05 with a real `BROWSER_USE_API_KEY`; the
5+
companion script `cleanup-zombies.py` next to this file *is* the
6+
field-test — running it lists active browsers and stops zombies via the
7+
same wire calls the harness uses internally.
8+
9+
This skill is for users who already start cloud browsers via
10+
`start_remote_daemon()` and want to manage the surrounding lifecycle —
11+
provisioning fleets, cleaning up zombies, listing what's running, sharing
12+
liveUrls — without clicking through `cloud.browser-use.com`.
13+
14+
## Auth
15+
16+
REST uses a custom header (not `Authorization: Bearer` — that path
17+
returns a generic 401 silently):
18+
19+
```python
20+
import os
21+
HEADERS = {
22+
"X-Browser-Use-API-Key": os.environ["BROWSER_USE_API_KEY"],
23+
"Content-Type": "application/json",
24+
}
25+
```
26+
27+
The key only authorises actions on browsers and profiles created under
28+
it — there are no organisation-level admin endpoints on the public API.
29+
30+
## Endpoint reference
31+
32+
All paths are under `https://api.browser-use.com/api/v3`. Verified status
33+
codes and shapes from 2026-05-05 below.
34+
35+
### `POST /browsers` — provision a cloud browser
36+
37+
Body (camelCase):
38+
39+
| Key | Type | Notes |
40+
|---|---|---|
41+
| `profileId` | UUID | optional; logged-in cloud profile |
42+
| `profileName` | str | optional; resolved client-side |
43+
| `proxyCountryCode` | ISO2 | default `"us"`; pass `null` to disable BU proxy |
44+
| `timeout` | int | minutes, 1..240 |
45+
| `customProxy` | obj | `{host, port, username, password, ignoreCertErrors}` |
46+
| `browserScreenWidth` / `browserScreenHeight` | int | viewport |
47+
| `allowResizing` | bool | viewport user-resizable |
48+
| `enableRecording` | bool | session recording |
49+
50+
Returns `201` with this shape (also returned by `GET /browsers/{id}`,
51+
`GET /browsers` items, and `PATCH /browsers/{id}`):
52+
53+
```python
54+
{
55+
"id": str,
56+
"status": str, # e.g. "active"
57+
"liveUrl": str, # host: live.browser-use.com (different from cloud.browser-use.com)
58+
"cdpUrl": str, # https:// — daemon converts to ws via /json/version
59+
"timeoutAt": str, # ISO 8601 UTC
60+
"startedAt": str,
61+
"finishedAt": None, # populated only after stop
62+
"proxyUsedMb": str, # STRING — cast to float before arithmetic
63+
"proxyCost": str, # STRING
64+
"browserCost": str, # STRING
65+
"agentSessionId": None,
66+
"recordingUrl": None, # str only when enableRecording=True at create
67+
}
68+
```
69+
70+
The `liveUrl` carries the cdp WebSocket as a `?wss=...` query param, so
71+
sharing the URL alone hands off a viewable session — no extra setup.
72+
73+
### `PATCH /browsers/{id}` — stop (end billing)
74+
75+
Body `{"action": "stop"}`. Returns `200` with the same browser object,
76+
but `liveUrl` and `cdpUrl` come back as `null` and `finishedAt` is
77+
populated. Use the returned `proxyCost` + `browserCost` for final cost.
78+
Always wrap caller code in `try/finally`; every billed minute counts.
79+
80+
### `GET /browsers` — list active sessions
81+
82+
Returns `200` and the standard envelope
83+
`{items: [...], totalItems, pageNumber, pageSize}`. `items[*]` matches
84+
the `POST /browsers` response shape. Already-finished browsers appear in
85+
the listing for a window with `finishedAt` populated — filter them out
86+
when computing age.
87+
88+
### `GET /profiles?pageSize=N&pageNumber=N` — list cloud profiles
89+
90+
`pageSize` caps at 100. Same envelope as `/browsers`.
91+
92+
### `GET /profiles/{id}` — profile detail
93+
94+
Returns the same shape as the listing items:
95+
96+
```python
97+
{
98+
"id": str,
99+
"userId": None, # null in observed responses
100+
"name": str,
101+
"lastUsedAt": str | None, # null until first use
102+
"createdAt": str,
103+
"updatedAt": str,
104+
"cookieDomains": list[str] | None, # null on freshly-created profiles
105+
}
106+
```
107+
108+
`browser_harness.admin.list_cloud_profiles()` already wraps the listing
109+
+ per-id GET; prefer it unless you need raw access.
110+
111+
## Companion script: `cleanup-zombies.py`
112+
113+
A self-contained operator script next to this file. Run it with:
114+
115+
```bash
116+
BROWSER_USE_API_KEY=... python agent-workspace/domain-skills/browser-use-cloud/cleanup-zombies.py
117+
# stops every active browser older than 30 minutes (default)
118+
119+
BROWSER_USE_API_KEY=... python .../cleanup-zombies.py --older-than 5 --dry-run
120+
# preview only; no PATCH /stop sent
121+
```
122+
123+
The script is the practical residue of the API verification — running it
124+
exercises four of the five endpoints (`GET /browsers`, plus
125+
`PATCH .../stop` per zombie). Use it as the live regression check
126+
whenever this skill is updated.
127+
128+
## Dashboard navigation (when API isn't enough)
129+
130+
The dashboard at `cloud.browser-use.com` requires a logged-in session;
131+
the unauthenticated root redirects to `/signup` (verified 2026-05-05).
132+
Beyond `/signup` the slugs below are *inferred from typical SaaS layout*
133+
— confirm in your own browser before relying on the literal paths:
134+
135+
```
136+
/signup (verified)
137+
/dashboard [verify]
138+
/browsers [verify] — likely the dashboard mirror of GET /browsers
139+
/browsers/<id> [verify]
140+
/profiles [verify]
141+
/api-keys [verify]
142+
```
143+
144+
There is no `/usage` page mirror — `GET /usage` on the API returns 404,
145+
so per-session cost has to come from each browser record (`proxyCost` +
146+
`browserCost`). The dashboard surfaces aggregate billing somewhere, but
147+
that's outside the API surface and not useful from inside `bh`.
148+
149+
For dashboard scraping, attach to your real Chrome and read cookies:
150+
151+
```python
152+
cookies = cdp("Network.getCookies", urls=["https://cloud.browser-use.com"])
153+
parts = [c["name"] + "=" + c["value"] for c in cookies.get("cookies", [])]
154+
dash_headers = {"Cookie": "; ".join(parts), "Accept": "text/html,application/json"}
155+
```
156+
157+
Empty cookie jar = not logged in; open `cloud.browser-use.com` in your
158+
real Chrome once, then retry.
159+
160+
## Traps to avoid
161+
162+
- **Auth header name** is `X-Browser-Use-API-Key`. `Authorization:
163+
Bearer ...` silently fails with a generic 401.
164+
- **Cost fields are strings**, not numbers. `proxyCost`, `browserCost`,
165+
`proxyUsedMb` come back as quoted strings (`"0.0123"`); cast to
166+
`float` before arithmetic.
167+
- **`cookieDomains` can be `None`** on freshly-created profiles, despite
168+
what `admin.py:list_cloud_profiles`'s docstring says. Guard with
169+
`c or []`.
170+
- **`liveUrl` host is `live.browser-use.com`**, not
171+
`cloud.browser-use.com`. They're separate surfaces.
172+
- **`start_remote_daemon` overwrites `BU_CDP_WS`** in the daemon env;
173+
re-read from `browser["cdpUrl"]` if you need the value afterwards.
174+
(PR #300 stops `run.py` from clobbering an explicit `BU_CDP_URL`, but
175+
the daemon env still gets set.)
176+
- **`liveUrl` is single-session** — after stop, the URL no longer
177+
resolves; don't cache across calls.
178+
- **`_browser_use` has a 60s timeout** in `admin.py`; long-running ops
179+
(large profile sync) need their own polling.
180+
- **`profile-use` CLI is a separate install**:
181+
`curl -fsSL https://browser-use.com/profile.sh | sh`.
182+
- **`pageSize` caps at 100** silently — paginate via `pageNumber`.
183+
`totalItems` in the envelope lets you size loops up front.
184+
- **`proxyCountryCode` defaults to `"us"`** when omitted; pass `None` to
185+
disable BU proxy entirely. Wrong country = wrong egress IP = breaks
186+
geo-locked auth.
187+
188+
## What this skill does NOT cover
189+
190+
- **Billing / payment methods** — dashboard only, intentionally
191+
sensitive.
192+
- **Organisation / team admin** — outside the per-API-key surface.
193+
- **SDK features** — Browser Use ships official SDKs separately; this
194+
skill is the raw-HTTP path for power users inside `bh`.
195+
- **Cross-API-key reads** — every endpoint is scoped to the calling key.
196+
197+
## Provenance
198+
199+
Live-tested 2026-05-05 against `https://api.browser-use.com/api/v3`:
200+
201+
| Endpoint | Method | Status | Notes |
202+
|---|---|---|---|
203+
| `/profiles?pageSize=100&pageNumber=1` | GET | 200 | shape verified |
204+
| `/profiles/{id}` | GET | 200 | `cookieDomains=None` observed on a fresh profile |
205+
| `/browsers` | POST | 201 | `liveUrl` host is `live.browser-use.com` |
206+
| `/browsers/{id}` (`{action:"stop"}`) | PATCH | 200 | returns final cost |
207+
| `/browsers` | GET | 200 | paginated `{items,totalItems,pageNumber,pageSize}` |
208+
| `/usage` | GET | 404 | **no public endpoint** |
209+
| `/` | GET | 404 | no root metadata |
210+
211+
Companion script `cleanup-zombies.py` re-runs the listing + stop subset
212+
end-to-end and is the regression artefact for this skill. A full E2E
213+
loop (spawn → list → stop → re-list) was executed on 2026-05-05 against
214+
the production API and printed:
215+
216+
```
217+
[STOP] 3ac4c964-...-d3d3e1ad7508 age= 0.0min cost=$0.0020
218+
summary: 1 active session(s), stopped 1
219+
```
220+
221+
Re-running the script in `--dry-run` mode against an empty pool is the
222+
cheapest smoke test (no `PATCH /stop` calls, ~$0).

0 commit comments

Comments
 (0)