-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(domain-skills): add browser-use-cloud (REST + cleanup-zombies) #301
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}" | ||
|
Comment on lines
+138
to
+139
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| 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()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| # Browser Use Cloud — Programmatic Automation | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This skill is placed in a directory name that the current domain-skill resolver will never surface for Prompt for AI agents |
||
|
|
||
| `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/<id> [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`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Documentation recommends piping a remote script directly into Prompt for AI agents |
||
| - **`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). | ||
Uh oh!
There was an error while loading. Please reload this page.