diff --git a/agent-workspace/domain-skills/browser-use-cloud/cleanup-zombies.py b/agent-workspace/domain-skills/browser-use-cloud/cleanup-zombies.py new file mode 100644 index 00000000..02bde95c --- /dev/null +++ b/agent-workspace/domain-skills/browser-use-cloud/cleanup-zombies.py @@ -0,0 +1,163 @@ +"""Stop active Browser Use cloud browsers older than `--older-than` minutes. + +Designed to be the live regression artefact for the cloud.md skill in this +folder — running it exercises GET /browsers + PATCH /browsers/{id}/stop on +the public API and surfaces every wire-shape gotcha the skill documents. + +Usage: + BROWSER_USE_API_KEY=... python cleanup-zombies.py + # stop browsers running longer than 30 minutes (default) + + BROWSER_USE_API_KEY=... python cleanup-zombies.py --older-than 5 --dry-run + # preview only; no PATCH /stop sent + + BROWSER_USE_API_KEY=... python cleanup-zombies.py --json + # machine-readable output (one record per browser inspected) + +Exit codes: + 0 any zombies stopped (or none needed) + 1 API error (auth, network, etc.) + 2 bad CLI args +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import sys +import urllib.error +import urllib.request + +API = "https://api.browser-use.com/api/v3" + + +def _headers() -> dict[str, str]: + key = os.environ.get("BROWSER_USE_API_KEY") + if not key: + sys.exit("BROWSER_USE_API_KEY is not set") + return { + "X-Browser-Use-API-Key": key, + "Content-Type": "application/json", + "Accept": "application/json", + } + + +def _call(method: str, path: str, body: dict | None = None, timeout: float = 30.0) -> dict: + req = urllib.request.Request( + f"{API}{path}", + method=method, + data=(json.dumps(body).encode() if body is not None else None), + headers=_headers(), + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read() or b"{}") + + +def list_active_browsers() -> list[dict]: + """Return only sessions that are still alive (no `finishedAt`).""" + out, page = [], 1 + while True: + listing = _call("GET", f"/browsers?pageSize=100&pageNumber={page}") + items = listing.get("items") or [] + if not items: + break + out.extend(b for b in items if not b.get("finishedAt")) + if len(out) + sum(1 for b in items if b.get("finishedAt")) >= listing.get("totalItems", len(items)): + break + page += 1 + return out + + +def _parse_started(b: dict) -> datetime.datetime: + """`startedAt` is ISO 8601 UTC with a trailing `Z`. Python <3.11 needs the swap.""" + return datetime.datetime.fromisoformat(b["startedAt"].replace("Z", "+00:00")) + + +def _to_float(v: str | None) -> float: + """Cost / proxy fields come back as strings; tolerate `None` and empty.""" + return float(v) if v else 0.0 + + +def stop_browser(browser_id: str) -> dict: + return _call("PATCH", f"/browsers/{browser_id}", {"action": "stop"}) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Stop Browser Use cloud browsers older than N minutes.", + ) + parser.add_argument("--older-than", type=int, default=30, metavar="MIN", + help="age threshold in minutes (default: 30)") + parser.add_argument("--dry-run", action="store_true", + help="list zombies but do not call PATCH /stop") + parser.add_argument("--json", action="store_true", + help="emit one JSON object per inspected browser") + args = parser.parse_args() + + if args.older_than < 0: + parser.error("--older-than must be non-negative") + + cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=args.older_than) + + try: + active = list_active_browsers() + except urllib.error.HTTPError as e: + sys.stderr.write(f"GET /browsers failed: HTTP {e.code} -- {e.read().decode('utf-8', 'replace')[:200]}\n") + return 1 + except urllib.error.URLError as e: + sys.stderr.write(f"GET /browsers network error: {e}\n") + return 1 + + stopped = 0 + for b in active: + started = _parse_started(b) + age_min = (datetime.datetime.now(datetime.timezone.utc) - started).total_seconds() / 60 + is_zombie = started < cutoff + record = { + "id": b["id"], + "started_at": b["startedAt"], + "age_minutes": round(age_min, 1), + "browser_cost": _to_float(b.get("browserCost")), + "proxy_cost": _to_float(b.get("proxyCost")), + "proxy_used_mb": _to_float(b.get("proxyUsedMb")), + "is_zombie": is_zombie, + "action": "skipped", + } + if is_zombie: + if args.dry_run: + record["action"] = "would_stop" + else: + try: + final = stop_browser(b["id"]) + record["action"] = "stopped" + record["final_browser_cost"] = _to_float(final.get("browserCost")) + record["final_proxy_cost"] = _to_float(final.get("proxyCost")) + stopped += 1 + except urllib.error.HTTPError as e: + record["action"] = f"stop_failed: HTTP {e.code}" + except urllib.error.URLError as e: + record["action"] = f"stop_failed: {e.reason}" + + if args.json: + print(json.dumps(record)) + else: + tag = { + "skipped": "OK", + "would_stop": "DRY", + "stopped": "STOP", + }.get(record["action"], record["action"]) + print( + f"[{tag}] {record['id']} age={record['age_minutes']:5.1f}min " + f"cost=${record['browser_cost']+record['proxy_cost']:.4f}" + ) + + if not args.json: + verb = "would stop" if args.dry_run else "stopped" + 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)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent-workspace/domain-skills/browser-use-cloud/cloud.md b/agent-workspace/domain-skills/browser-use-cloud/cloud.md new file mode 100644 index 00000000..e9bbc128 --- /dev/null +++ b/agent-workspace/domain-skills/browser-use-cloud/cloud.md @@ -0,0 +1,222 @@ +# Browser Use Cloud — Programmatic Automation + +`https://api.browser-use.com/api/v3` (REST). All five endpoints below were +exercised end-to-end on 2026-05-05 with a real `BROWSER_USE_API_KEY`; the +companion script `cleanup-zombies.py` next to this file *is* the +field-test — running it lists active browsers and stops zombies via the +same wire calls the harness uses internally. + +This skill is for users who already start cloud browsers via +`start_remote_daemon()` and want to manage the surrounding lifecycle — +provisioning fleets, cleaning up zombies, listing what's running, sharing +liveUrls — without clicking through `cloud.browser-use.com`. + +## Auth + +REST uses a custom header (not `Authorization: Bearer` — that path +returns a generic 401 silently): + +```python +import os +HEADERS = { + "X-Browser-Use-API-Key": os.environ["BROWSER_USE_API_KEY"], + "Content-Type": "application/json", +} +``` + +The key only authorises actions on browsers and profiles created under +it — there are no organisation-level admin endpoints on the public API. + +## Endpoint reference + +All paths are under `https://api.browser-use.com/api/v3`. Verified status +codes and shapes from 2026-05-05 below. + +### `POST /browsers` — provision a cloud browser + +Body (camelCase): + +| Key | Type | Notes | +|---|---|---| +| `profileId` | UUID | optional; logged-in cloud profile | +| `profileName` | str | optional; resolved client-side | +| `proxyCountryCode` | ISO2 | default `"us"`; pass `null` to disable BU proxy | +| `timeout` | int | minutes, 1..240 | +| `customProxy` | obj | `{host, port, username, password, ignoreCertErrors}` | +| `browserScreenWidth` / `browserScreenHeight` | int | viewport | +| `allowResizing` | bool | viewport user-resizable | +| `enableRecording` | bool | session recording | + +Returns `201` with this shape (also returned by `GET /browsers/{id}`, +`GET /browsers` items, and `PATCH /browsers/{id}`): + +```python +{ + "id": str, + "status": str, # e.g. "active" + "liveUrl": str, # host: live.browser-use.com (different from cloud.browser-use.com) + "cdpUrl": str, # https:// — daemon converts to ws via /json/version + "timeoutAt": str, # ISO 8601 UTC + "startedAt": str, + "finishedAt": None, # populated only after stop + "proxyUsedMb": str, # STRING — cast to float before arithmetic + "proxyCost": str, # STRING + "browserCost": str, # STRING + "agentSessionId": None, + "recordingUrl": None, # str only when enableRecording=True at create +} +``` + +The `liveUrl` carries the cdp WebSocket as a `?wss=...` query param, so +sharing the URL alone hands off a viewable session — no extra setup. + +### `PATCH /browsers/{id}` — stop (end billing) + +Body `{"action": "stop"}`. Returns `200` with the same browser object, +but `liveUrl` and `cdpUrl` come back as `null` and `finishedAt` is +populated. Use the returned `proxyCost` + `browserCost` for final cost. +Always wrap caller code in `try/finally`; every billed minute counts. + +### `GET /browsers` — list active sessions + +Returns `200` and the standard envelope +`{items: [...], totalItems, pageNumber, pageSize}`. `items[*]` matches +the `POST /browsers` response shape. Already-finished browsers appear in +the listing for a window with `finishedAt` populated — filter them out +when computing age. + +### `GET /profiles?pageSize=N&pageNumber=N` — list cloud profiles + +`pageSize` caps at 100. Same envelope as `/browsers`. + +### `GET /profiles/{id}` — profile detail + +Returns the same shape as the listing items: + +```python +{ + "id": str, + "userId": None, # null in observed responses + "name": str, + "lastUsedAt": str | None, # null until first use + "createdAt": str, + "updatedAt": str, + "cookieDomains": list[str] | None, # null on freshly-created profiles +} +``` + +`browser_harness.admin.list_cloud_profiles()` already wraps the listing ++ per-id GET; prefer it unless you need raw access. + +## Companion script: `cleanup-zombies.py` + +A self-contained operator script next to this file. Run it with: + +```bash +BROWSER_USE_API_KEY=... python agent-workspace/domain-skills/browser-use-cloud/cleanup-zombies.py +# stops every active browser older than 30 minutes (default) + +BROWSER_USE_API_KEY=... python .../cleanup-zombies.py --older-than 5 --dry-run +# preview only; no PATCH /stop sent +``` + +The script is the practical residue of the API verification — running it +exercises four of the five endpoints (`GET /browsers`, plus +`PATCH .../stop` per zombie). Use it as the live regression check +whenever this skill is updated. + +## Dashboard navigation (when API isn't enough) + +The dashboard at `cloud.browser-use.com` requires a logged-in session; +the unauthenticated root redirects to `/signup` (verified 2026-05-05). +Beyond `/signup` the slugs below are *inferred from typical SaaS layout* +— confirm in your own browser before relying on the literal paths: + +``` +/signup (verified) +/dashboard [verify] +/browsers [verify] — likely the dashboard mirror of GET /browsers +/browsers/ [verify] +/profiles [verify] +/api-keys [verify] +``` + +There is no `/usage` page mirror — `GET /usage` on the API returns 404, +so per-session cost has to come from each browser record (`proxyCost` + +`browserCost`). The dashboard surfaces aggregate billing somewhere, but +that's outside the API surface and not useful from inside `bh`. + +For dashboard scraping, attach to your real Chrome and read cookies: + +```python +cookies = cdp("Network.getCookies", urls=["https://cloud.browser-use.com"]) +parts = [c["name"] + "=" + c["value"] for c in cookies.get("cookies", [])] +dash_headers = {"Cookie": "; ".join(parts), "Accept": "text/html,application/json"} +``` + +Empty cookie jar = not logged in; open `cloud.browser-use.com` in your +real Chrome once, then retry. + +## Traps to avoid + +- **Auth header name** is `X-Browser-Use-API-Key`. `Authorization: + Bearer ...` silently fails with a generic 401. +- **Cost fields are strings**, not numbers. `proxyCost`, `browserCost`, + `proxyUsedMb` come back as quoted strings (`"0.0123"`); cast to + `float` before arithmetic. +- **`cookieDomains` can be `None`** on freshly-created profiles, despite + what `admin.py:list_cloud_profiles`'s docstring says. Guard with + `c or []`. +- **`liveUrl` host is `live.browser-use.com`**, not + `cloud.browser-use.com`. They're separate surfaces. +- **`start_remote_daemon` overwrites `BU_CDP_WS`** in the daemon env; + re-read from `browser["cdpUrl"]` if you need the value afterwards. + (PR #300 stops `run.py` from clobbering an explicit `BU_CDP_URL`, but + the daemon env still gets set.) +- **`liveUrl` is single-session** — after stop, the URL no longer + resolves; don't cache across calls. +- **`_browser_use` has a 60s timeout** in `admin.py`; long-running ops + (large profile sync) need their own polling. +- **`profile-use` CLI is a separate install**: + `curl -fsSL https://browser-use.com/profile.sh | sh`. +- **`pageSize` caps at 100** silently — paginate via `pageNumber`. + `totalItems` in the envelope lets you size loops up front. +- **`proxyCountryCode` defaults to `"us"`** when omitted; pass `None` to + disable BU proxy entirely. Wrong country = wrong egress IP = breaks + geo-locked auth. + +## What this skill does NOT cover + +- **Billing / payment methods** — dashboard only, intentionally + sensitive. +- **Organisation / team admin** — outside the per-API-key surface. +- **SDK features** — Browser Use ships official SDKs separately; this + skill is the raw-HTTP path for power users inside `bh`. +- **Cross-API-key reads** — every endpoint is scoped to the calling key. + +## Provenance + +Live-tested 2026-05-05 against `https://api.browser-use.com/api/v3`: + +| Endpoint | Method | Status | Notes | +|---|---|---|---| +| `/profiles?pageSize=100&pageNumber=1` | GET | 200 | shape verified | +| `/profiles/{id}` | GET | 200 | `cookieDomains=None` observed on a fresh profile | +| `/browsers` | POST | 201 | `liveUrl` host is `live.browser-use.com` | +| `/browsers/{id}` (`{action:"stop"}`) | PATCH | 200 | returns final cost | +| `/browsers` | GET | 200 | paginated `{items,totalItems,pageNumber,pageSize}` | +| `/usage` | GET | 404 | **no public endpoint** | +| `/` | GET | 404 | no root metadata | + +Companion script `cleanup-zombies.py` re-runs the listing + stop subset +end-to-end and is the regression artefact for this skill. A full E2E +loop (spawn → list → stop → re-list) was executed on 2026-05-05 against +the production API and printed: + +``` +[STOP] 3ac4c964-...-d3d3e1ad7508 age= 0.0min cost=$0.0020 +summary: 1 active session(s), stopped 1 +``` + +Re-running the script in `--dry-run` mode against an empty pool is the +cheapest smoke test (no `PATCH /stop` calls, ~$0).