Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions agent-workspace/domain-skills/browser-use-cloud/cleanup-zombies.py
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:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle network errors when stopping zombie sessions

When PATCH /browsers/{id} hits a transient network failure (e.g., timeout/DNS/socket reset), urlopen raises URLError, but this loop only catches HTTPError. That exception currently bubbles out, aborting the run before remaining zombies are processed, which can leave older sessions running and still accruing cost during exactly the cleanup workflow this script is meant to automate.

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())
222 changes: 222 additions & 0 deletions agent-workspace/domain-skills/browser-use-cloud/cloud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
# Browser Use Cloud — Programmatic Automation
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 5, 2026

Choose a reason for hiding this comment

The 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 cloud.browser-use.com/api.browser-use.com, so it is effectively undiscoverable through normal skill loading.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At agent-workspace/domain-skills/browser-use-cloud/cloud.md, line 1:

<comment>This skill is placed in a directory name that the current domain-skill resolver will never surface for `cloud.browser-use.com`/`api.browser-use.com`, so it is effectively undiscoverable through normal skill loading.</comment>

<file context>
@@ -0,0 +1,222 @@
+# Browser Use Cloud — Programmatic Automation
+
+`https://api.browser-use.com/api/v3` (REST). All five endpoints below were
</file context>
Fix with Cubic


`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`.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Documentation recommends piping a remote script directly into sh without integrity verification, creating a supply-chain/code-execution risk for operators.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At agent-workspace/domain-skills/browser-use-cloud/cloud.md, line 181:

<comment>Documentation recommends piping a remote script directly into `sh` without integrity verification, creating a supply-chain/code-execution risk for operators.</comment>

<file context>
@@ -0,0 +1,222 @@
+- **`_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.
</file context>
Fix with Cubic

- **`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).