browser-workspace 0.4.1 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/SKILL.md +60 -37
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +174 -13
- package/package.json +1 -1
- package/platform_runner.py +23 -6
- package/platforms/chatgpt/actions/_temporary_submit.py +22 -2
- package/platforms/chatgpt/tests/test_contract.py +7 -0
- package/platforms/wechat-channels/actions/_common.py +1 -1
- package/platforms/wechat-channels/actions/_post.py +37 -38
- package/platforms/wechat-channels/manifest.yaml +19 -13
- package/platforms/xhs/actions/_post.py +109 -20
- package/platforms/xhs/manifest.yaml +14 -5
- package/platforms/xhs/tests/test_contract.py +17 -0
- package/platforms/youtube/actions/_post.py +7 -4
- package/pyproject.toml +6 -2
- package/session_client.py +21 -11
- package/session_daemon.py +115 -9
- package/src/browser_workspace.egg-info/PKG-INFO +2 -1
- package/src/browser_workspace.egg-info/SOURCES.txt +84 -15
- package/src/browser_workspace.egg-info/entry_points.txt +2 -0
- package/src/browser_workspace.egg-info/requires.txt +1 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -1
- package/src/browser_workspace_app/__init__.py +1 -0
- package/src/browser_workspace_app/agent-workspace/agent_helpers.py +718 -0
- package/src/browser_workspace_app/cli.py +236 -0
- package/src/browser_workspace_app/extension/manifest.json +26 -0
- package/src/browser_workspace_app/extension/service-worker.mjs +126 -0
- package/src/browser_workspace_app/extension/workspace-manager.mjs +680 -0
- package/src/browser_workspace_app/extension/workspace-page.mjs +7 -0
- package/src/browser_workspace_app/extension/workspace.html +8 -0
- package/src/browser_workspace_app/interaction-skills/connection.md +52 -0
- package/src/browser_workspace_app/interaction-skills/cookies.md +3 -0
- package/src/browser_workspace_app/interaction-skills/cross-origin-iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dialogs.md +64 -0
- package/src/browser_workspace_app/interaction-skills/downloads.md +3 -0
- package/src/browser_workspace_app/interaction-skills/drag-and-drop.md +3 -0
- package/src/browser_workspace_app/interaction-skills/dropdowns.md +3 -0
- package/src/browser_workspace_app/interaction-skills/iframes.md +3 -0
- package/src/browser_workspace_app/interaction-skills/network-requests.md +3 -0
- package/src/browser_workspace_app/interaction-skills/print-as-pdf.md +3 -0
- package/src/browser_workspace_app/interaction-skills/profile-sync.md +90 -0
- package/src/browser_workspace_app/interaction-skills/screenshots.md +17 -0
- package/src/browser_workspace_app/interaction-skills/scrolling.md +22 -0
- package/src/browser_workspace_app/interaction-skills/shadow-dom.md +3 -0
- package/src/browser_workspace_app/interaction-skills/tabs.md +73 -0
- package/src/browser_workspace_app/interaction-skills/uploads.md +1 -0
- package/src/browser_workspace_app/interaction-skills/viewport.md +16 -0
- package/src/browser_workspace_app/licenses/browser-harness-MIT.txt +21 -0
- package/src/browser_workspace_app/managed_chrome.py +244 -0
- package/src/browser_workspace_app/node/admin.mjs +44 -0
- package/src/browser_workspace_app/package.json +36 -0
- package/src/browser_workspace_app/platform_runner.py +204 -0
- package/src/browser_workspace_app/platforms/chatgpt/README.md +60 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_readiness.py +52 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_temporary_submit.py +591 -0
- package/src/browser_workspace_app/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/src/browser_workspace_app/platforms/chatgpt/manifest.yaml +34 -0
- package/src/browser_workspace_app/platforms/chatgpt/tests/test_contract.py +183 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/README.md +49 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/manifest.yaml +33 -0
- package/src/browser_workspace_app/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/README.md +32 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/manifest.yaml +25 -0
- package/src/browser_workspace_app/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/src/browser_workspace_app/platforms/tiktok/README.md +10 -0
- package/src/browser_workspace_app/platforms/tiktok/actions/_post.py +180 -0
- package/src/browser_workspace_app/platforms/tiktok/manifest.yaml +99 -0
- package/src/browser_workspace_app/platforms/wechat-channels/README.md +10 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_common.py +163 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_manage.py +130 -0
- package/src/browser_workspace_app/platforms/wechat-channels/actions/_post.py +231 -0
- package/src/browser_workspace_app/platforms/wechat-channels/manifest.yaml +124 -0
- package/src/browser_workspace_app/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/src/browser_workspace_app/platforms/xhs/README.md +10 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_manage.py +355 -0
- package/src/browser_workspace_app/platforms/xhs/actions/_post.py +281 -0
- package/src/browser_workspace_app/platforms/xhs/manifest.yaml +107 -0
- package/src/browser_workspace_app/platforms/xhs/tests/test_contract.py +34 -0
- package/src/browser_workspace_app/platforms/youtube/README.md +10 -0
- package/src/browser_workspace_app/platforms/youtube/actions/_post.py +103 -0
- package/src/browser_workspace_app/platforms/youtube/manifest.yaml +94 -0
- package/src/browser_workspace_app/pyproject.toml +33 -0
- package/src/browser_workspace_app/session_client.py +107 -0
- package/src/browser_workspace_app/session_daemon.py +431 -0
- package/src/browser_harness/__init__.py +0 -2
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +0 -201
- package/src/browser_harness/admin.py +0 -1588
- package/src/browser_harness/auth.py +0 -546
- package/src/browser_harness/daemon.py +0 -894
- package/src/browser_harness/helpers.py +0 -668
- package/src/browser_harness/macos.py +0 -131
- package/src/browser_harness/mcp_cli.py +0 -16
- package/src/browser_harness/paths.py +0 -49
- package/src/browser_harness/recorder.py +0 -343
- package/src/browser_harness/run.py +0 -413
- package/src/browser_harness/telemetry.py +0 -308
- package/src/browser_harness/video-template.html +0 -970
- package/src/browser_harness/video.py +0 -749
- package/src/browser_harness/video_render.py +0 -524
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Connection & Tab Visibility
|
|
2
|
+
|
|
3
|
+
## The omnibox popup problem
|
|
4
|
+
|
|
5
|
+
When Chrome opens fresh, the only CDP `type: "page"` targets are `chrome://inspect` and `chrome://omnibox-popup.top-chrome/` (a 1px invisible viewport). If the daemon attaches to the omnibox popup, all subsequent work — including `new_tab()` and `goto_url()` — happens on tabs that exist in CDP but may not be visible in the Chrome UI.
|
|
6
|
+
|
|
7
|
+
The daemon's `attach_first_page()` handles this by creating an `about:blank` tab when no real pages exist. If you still end up on an invisible tab, use `switch_tab()` to attach to the real tab. Call `activate_tab()` only when the user explicitly asks Chrome to visibly show it.
|
|
8
|
+
|
|
9
|
+
## Startup sequence
|
|
10
|
+
|
|
11
|
+
1. Check if a daemon is already running with `daemon_alive()`
|
|
12
|
+
2. If stale sockets exist but daemon is dead, clean them up
|
|
13
|
+
3. List open tabs with `list_tabs()` to see what's available
|
|
14
|
+
4. `ensure_real_tab()` attaches to a real page
|
|
15
|
+
5. `switch_tab(target_id)` attaches without changing the visible Chrome tab; use `activate_tab(target_id)` only for a user-requested visible switch
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
if not daemon_alive():
|
|
19
|
+
import os, ipc
|
|
20
|
+
ipc.cleanup_endpoint("default")
|
|
21
|
+
pid = ipc.pid_path("default")
|
|
22
|
+
if pid.exists(): pid.unlink()
|
|
23
|
+
ensure_daemon()
|
|
24
|
+
|
|
25
|
+
tabs = list_tabs()
|
|
26
|
+
for t in tabs:
|
|
27
|
+
print(t["url"][:60])
|
|
28
|
+
|
|
29
|
+
tab = ensure_real_tab()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Bringing Chrome to front
|
|
33
|
+
|
|
34
|
+
If Chrome is behind other windows or on another desktop and the user explicitly wants it shown:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import subprocess
|
|
38
|
+
subprocess.run(["osascript", "-e", 'tell application "Google Chrome" to activate'])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For normal agent work, do not activate Chrome. Screenshots and CDP input work
|
|
42
|
+
on the attached background tab; activate only for a page that demonstrably
|
|
43
|
+
pauses visibility-dependent rendering while hidden.
|
|
44
|
+
|
|
45
|
+
## Navigating
|
|
46
|
+
|
|
47
|
+
Prefer navigating an existing tab over `new_tab()`. Harness-created tabs open in the background.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
tab = ensure_real_tab()
|
|
51
|
+
goto_url("https://example.com")
|
|
52
|
+
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Dialogs
|
|
2
|
+
|
|
3
|
+
Browser dialogs (`alert`, `confirm`, `prompt`, `beforeunload`) freeze the JS thread. Two approaches depending on timing.
|
|
4
|
+
|
|
5
|
+
## Detection
|
|
6
|
+
|
|
7
|
+
`page_info()` auto-surfaces any open dialog: if one is pending it returns `{"dialog": {"type", "message", ...}}` instead of the usual viewport dict (because the page's JS is frozen anyway). So if you call `page_info()` after an action and see a `dialog` key, handle it before doing anything else.
|
|
8
|
+
|
|
9
|
+
## Reactive: dismiss via CDP (preferred)
|
|
10
|
+
|
|
11
|
+
Works even when JS is frozen. Handles all dialog types including `beforeunload`.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
# Dismiss and read the message
|
|
15
|
+
cdp("Page.handleJavaScriptDialog", accept=True) # accept / click OK
|
|
16
|
+
cdp("Page.handleJavaScriptDialog", accept=False) # cancel / click Cancel
|
|
17
|
+
|
|
18
|
+
# Read what the dialog said (from buffered CDP events)
|
|
19
|
+
events = drain_events()
|
|
20
|
+
for e in events:
|
|
21
|
+
if e["method"] == "Page.javascriptDialogOpening":
|
|
22
|
+
print(e["params"]["type"]) # "alert", "confirm", "prompt", "beforeunload"
|
|
23
|
+
print(e["params"]["message"]) # the dialog text
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Undetectable by antibot — no JS injected into the page.
|
|
27
|
+
|
|
28
|
+
## Proactive: stub via JS
|
|
29
|
+
|
|
30
|
+
Prevents dialogs from ever appearing. Good when you expect multiple `alert()`/`confirm()` calls in sequence.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
js("""
|
|
34
|
+
window.__dialogs__=[];
|
|
35
|
+
window.alert=m=>window.__dialogs__.push(String(m));
|
|
36
|
+
window.confirm=m=>{window.__dialogs__.push(String(m));return true;};
|
|
37
|
+
window.prompt=(m,d)=>{window.__dialogs__.push(String(m));return d||'';};
|
|
38
|
+
""")
|
|
39
|
+
# ... do actions that trigger dialogs ...
|
|
40
|
+
msgs = js("window.__dialogs__||[]")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Tradeoffs:
|
|
44
|
+
- Stubs are lost on page navigation -- must re-run the snippet
|
|
45
|
+
- `confirm()` always returns `true` (auto-approves)
|
|
46
|
+
- Detectable by antibot (`window.alert.toString()` reveals non-native code)
|
|
47
|
+
- Does NOT handle `beforeunload`
|
|
48
|
+
|
|
49
|
+
## beforeunload specifically
|
|
50
|
+
|
|
51
|
+
Fires when navigating away from a page with unsaved changes (forms, editors, upload pages). The page freezes until the user clicks Leave/Stay.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
# Option A: dismiss after navigating (CDP-level, safe)
|
|
55
|
+
goto_url("https://new-url.com")
|
|
56
|
+
try:
|
|
57
|
+
cdp("Page.handleJavaScriptDialog", accept=True) # click "Leave"
|
|
58
|
+
except:
|
|
59
|
+
pass # no dialog — normal
|
|
60
|
+
|
|
61
|
+
# Option B: prevent before navigating (JS injection, detectable)
|
|
62
|
+
js("window.onbeforeunload=null")
|
|
63
|
+
goto_url("https://new-url.com")
|
|
64
|
+
```
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Profile sync
|
|
2
|
+
|
|
3
|
+
Make a remote Browser Use browser start already logged in, by uploading cookies from a local Chrome profile.
|
|
4
|
+
|
|
5
|
+
## One-time install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
curl -fsSL https://browser-use.com/profile.sh | sh
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Downloads `profile-use` (macOS / Linux, x64 / arm64). The Python helpers shell out to it; you don't run `profile-use` directly.
|
|
12
|
+
|
|
13
|
+
## Python API (pre-imported in a Browser Workspace session)
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
list_cloud_profiles()
|
|
17
|
+
# [{id, name, userId, cookieDomains, lastUsedAt}, ...] — every profile under this API key
|
|
18
|
+
|
|
19
|
+
list_local_profiles()
|
|
20
|
+
# [{BrowserName, ProfileName, DisplayName, ProfilePath, ...}, ...] — detected on this machine
|
|
21
|
+
|
|
22
|
+
sync_local_profile(profile_name, browser=None,
|
|
23
|
+
cloud_profile_id=None, # update an existing cloud profile instead of creating new
|
|
24
|
+
include_domains=None, # only these domains (and subdomains); leading dot optional
|
|
25
|
+
exclude_domains=None) # drop these domains; applied before include
|
|
26
|
+
# Shells out to `profile-use sync`. Returns the cloud profile UUID
|
|
27
|
+
# (the existing one if cloud_profile_id was passed, else the newly-created one).
|
|
28
|
+
|
|
29
|
+
start_remote_daemon("work", profileName="my-work") # name→id resolved client-side
|
|
30
|
+
start_remote_daemon("work", profileId="<uuid>") # or pass UUID directly
|
|
31
|
+
|
|
32
|
+
stop_remote_daemon("work") # shut the daemon and PATCH the cloud browser to stop — billing ends
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`sync_local_profile` prints `♻️ Using existing cloud profile` when `cloud_profile_id` is accepted, or `📝 Creating remote profile...` → `✓ Profile created: <uuid>` when it creates a new one. Check that line if you want to confirm which path ran.
|
|
36
|
+
|
|
37
|
+
## Chat-driven flow (don't guess — ask the user)
|
|
38
|
+
|
|
39
|
+
Cookies are real auth. Don't sync or pick a profile unilaterally.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# 1. Show what's already in the cloud.
|
|
43
|
+
for p in list_cloud_profiles():
|
|
44
|
+
print(f"{p['name']:25} {len(p['cookieDomains']):3} domains {p['id']}")
|
|
45
|
+
```
|
|
46
|
+
→ Agent: *"You have these cloud profiles (<N> domains each). Want to reuse one, sync a local profile, or start clean?"*
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# 2a. Reuse cloud → one call.
|
|
50
|
+
start_remote_daemon("work", profileName="browser-use.com")
|
|
51
|
+
|
|
52
|
+
# 2b. Sync local first. Show the options:
|
|
53
|
+
for lp in list_local_profiles():
|
|
54
|
+
print(lp["DisplayName"])
|
|
55
|
+
```
|
|
56
|
+
→ Agent: *"Which local profile?"* → user picks → before syncing, inspect domain-level cookie counts with `profile-use inspect --profile <name>` (or `--verbose` for individual cookies) and report the summary; never dump 500 cookies into chat.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# 3. Sync + use. Returns the cloud UUID.
|
|
60
|
+
uuid = sync_local_profile("browser-use.com")
|
|
61
|
+
start_remote_daemon("work", profileId=uuid)
|
|
62
|
+
|
|
63
|
+
# 3b. Refresh that same cloud profile later (idempotent — no duplicate profiles).
|
|
64
|
+
sync_local_profile("browser-use.com", cloud_profile_id=uuid)
|
|
65
|
+
|
|
66
|
+
# 3c. Scoped: push *only* Stripe cookies into a dedicated cloud profile.
|
|
67
|
+
sync_local_profile("browser-use.com",
|
|
68
|
+
cloud_profile_id=uuid,
|
|
69
|
+
include_domains=["stripe.com"])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## What actually gets synced
|
|
73
|
+
|
|
74
|
+
**Cookies only.** No localStorage, no IndexedDB, no extensions. Enough for session-cookie sites (Google, GitHub, Stripe, most SaaS); not for sites that store auth in localStorage.
|
|
75
|
+
|
|
76
|
+
Cookies mutated during a remote session only persist on a clean `PATCH /browsers/{id} {"action":"stop"}` — the daemon does this on shutdown when `BU_BROWSER_ID` + `BROWSER_USE_API_KEY` are set (default for remote daemons). Sessions that hit the timeout lose in-session state.
|
|
77
|
+
|
|
78
|
+
## Cloud profile CRUD
|
|
79
|
+
|
|
80
|
+
- UI: https://cloud.browser-use.com/settings?tab=profiles
|
|
81
|
+
- API: `GET /profiles`, `GET/PATCH/DELETE /profiles/{id}` (paths are relative to `BU_API = "https://api.browser-use.com/api/v3"` in `admin.py`). Fields: `id`, `name`, `userId`, `lastUsedAt`, `cookieDomains[]`. `list_cloud_profiles()` wraps this.
|
|
82
|
+
- Name → UUID: `profileName=` on `start_remote_daemon` resolves client-side; no API change needed.
|
|
83
|
+
- Need the UUID for an existing profile? `matches = [p["id"] for p in list_cloud_profiles() if p["name"] == "<name>"]` — then verify `len(matches) == 1` before using it. Profile names are not unique; syncs create duplicates unless you pass `cloud_profile_id=`.
|
|
84
|
+
- Lower-level raw calls: `from browser_harness.admin import _browser_use; _browser_use("/profiles/<id>", "DELETE")`. Pass the path *without* the `/api/v3` prefix — it's already on `BU_API`.
|
|
85
|
+
|
|
86
|
+
## Traps
|
|
87
|
+
|
|
88
|
+
- **Default proxy (`proxyCountryCode="us"`) blocks some destinations** with `ERR_TUNNEL_CONNECTION_FAILED` (e.g. `cloud.browser-use.com` itself). `proxyCountryCode=None` disables the BU proxy; a different country code picks a different exit.
|
|
89
|
+
- **Prefer a dedicated work profile over your personal one.** Especially while testing.
|
|
90
|
+
- **Older than `profile-use` v1.0.5?** Pre-1.0.5 the sync needed the Chrome profile to be closed (exclusive SQLite lock on the `Cookies` DB). v1.0.5+ copies the profile dir to a temp and syncs from the copy — Chrome can stay open.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Screenshots
|
|
2
|
+
|
|
3
|
+
`capture_screenshot()` writes a PNG of the current viewport. The file is in **device pixels** — on a 2× display a 2296×1143 CSS viewport produces a 4592×2286 PNG.
|
|
4
|
+
|
|
5
|
+
That matters for two reasons:
|
|
6
|
+
|
|
7
|
+
1. **Click coordinates are CSS pixels.** Don't read a target off the image and pass it to `click_at_xy()` directly without dividing by `devicePixelRatio`. The simplest workflow is to take the screenshot, look at it in a viewer that shows CSS coordinates, or measure relative positions and use `js("window.devicePixelRatio")` to convert.
|
|
8
|
+
|
|
9
|
+
2. **Some LLMs reject images > 2000 px per side.** Long sessions on 2× displays will eventually hit this. Pass `max_dim=1800` to downscale the file before it gets into the conversation:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
capture_screenshot("/tmp/shot.png", max_dim=1800)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The downscale only happens when the image actually exceeds `max_dim`, so it's safe to leave on for every shot.
|
|
16
|
+
|
|
17
|
+
Use full-page screenshots (`full=True`) only when you need to see content below the fold — they are much larger and slower than viewport-only.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Scrolling
|
|
2
|
+
|
|
3
|
+
Separate page scroll, nested containers, virtualized lists, and dropdown menus, and identify which element is actually consuming wheel events before scrolling.
|
|
4
|
+
|
|
5
|
+
## Hidden tabs
|
|
6
|
+
|
|
7
|
+
Start with the normal `scroll(...)` helper. Chrome on Windows can leave a
|
|
8
|
+
mouse-wheel command unanswered when the attached tab is not visible. If that
|
|
9
|
+
scroll times out:
|
|
10
|
+
|
|
11
|
+
1. Do not call `activate_tab()`; a timeout is not permission to foreground
|
|
12
|
+
Chrome.
|
|
13
|
+
2. Temporarily call
|
|
14
|
+
`cdp("Emulation.setFocusEmulationEnabled", enabled=True)`, retry the same
|
|
15
|
+
`scroll(...)` once, and re-read the page or container scroll position.
|
|
16
|
+
3. Disable focus emulation in a `finally` block.
|
|
17
|
+
|
|
18
|
+
If background scrolling still fails, stop and name that exact limitation.
|
|
19
|
+
Call `activate_tab()` only when the user explicitly asks to see or visibly
|
|
20
|
+
switch to the tab. Do not replace wheel input with custom `Runtime.evaluate`
|
|
21
|
+
scrolling or a cross-frame JavaScript walker; those paths change page semantics
|
|
22
|
+
and require context the agent does not have.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Tabs
|
|
2
|
+
|
|
3
|
+
Use **CDP for control**, **UI automation for user-visible order**.
|
|
4
|
+
|
|
5
|
+
## Pure CDP (portable: macOS / Linux / Windows)
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
tabs = list_tabs() # includes chrome:// pages too
|
|
9
|
+
real_tabs = list_tabs(include_chrome=False)
|
|
10
|
+
tid = new_tab("https://example.com") # create + attach in the background
|
|
11
|
+
switch_tab(tid) # attach harness, move the horse marker
|
|
12
|
+
activate_tab(tid) # optional: explicitly show it in Chrome
|
|
13
|
+
print(current_tab())
|
|
14
|
+
print(page_info())
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
What CDP is good at:
|
|
18
|
+
- attach to a tab
|
|
19
|
+
- open a tab
|
|
20
|
+
- activate a known target
|
|
21
|
+
- inspect URL/title/viewport
|
|
22
|
+
- capture the attached tab's screenshot even if another tab is visibly frontmost
|
|
23
|
+
|
|
24
|
+
What CDP is bad at:
|
|
25
|
+
- matching the **left-to-right tab strip order** the user sees
|
|
26
|
+
- telling whether the attached target is an omnibox popup / internal page without URL filtering
|
|
27
|
+
|
|
28
|
+
## Visible order (platform UI)
|
|
29
|
+
|
|
30
|
+
### macOS
|
|
31
|
+
|
|
32
|
+
```applescript
|
|
33
|
+
tell application "Google Chrome"
|
|
34
|
+
set out to {}
|
|
35
|
+
set i to 1
|
|
36
|
+
repeat with t in every tab of front window
|
|
37
|
+
set end of out to {tab_index:i, tab_title:(title of t), tab_url:(URL of t)}
|
|
38
|
+
set i to i + 1
|
|
39
|
+
end repeat
|
|
40
|
+
return out
|
|
41
|
+
end tell
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```applescript
|
|
45
|
+
tell application "Google Chrome"
|
|
46
|
+
set active tab index of front window to 2
|
|
47
|
+
activate
|
|
48
|
+
end tell
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Linux
|
|
52
|
+
|
|
53
|
+
No AppleScript. Same split still applies:
|
|
54
|
+
- use CDP for `new_tab`, attach, inspect, activate known targets
|
|
55
|
+
- use window-manager / browser UI automation when the user means visible order
|
|
56
|
+
|
|
57
|
+
Typical tools:
|
|
58
|
+
- `xdotool`
|
|
59
|
+
- `wmctrl`
|
|
60
|
+
- desktop-environment scripting (`gdbus`, KWin, GNOME Shell extensions, etc.)
|
|
61
|
+
|
|
62
|
+
## Rules that held up in practice
|
|
63
|
+
|
|
64
|
+
- `switch_tab()` intentionally does **not** change Chrome's visible tab.
|
|
65
|
+
- Static screenshots and normal CDP input work on the attached background tab.
|
|
66
|
+
- `activate_tab()` is only for a user-requested visible switch. Rendering or
|
|
67
|
+
input trouble is not permission to foreground Chrome; use background CDP and
|
|
68
|
+
temporary focus emulation first.
|
|
69
|
+
- `Target.activateTarget` is the CDP-side "show this tab".
|
|
70
|
+
- `list_tabs()` includes `chrome://newtab/` by default; ask for `include_chrome=False` when you want only real pages.
|
|
71
|
+
- `chrome://omnibox-popup.top-chrome/` can appear as a fake page target; ignore it for user-facing tab lists.
|
|
72
|
+
- If a page has `w=0 h=0`, you may be attached to the wrong target or a non-window surface.
|
|
73
|
+
- For dynamic UIs, re-read element rects after opening dropdowns / modals before coordinate-clicking.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Uploads
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Viewport
|
|
2
|
+
|
|
3
|
+
Cover how viewport size changes affect layout, coordinate clicks, and any workflow that depends on stable geometry.
|
|
4
|
+
|
|
5
|
+
## Touch emulation
|
|
6
|
+
|
|
7
|
+
`maxTouchPoints` is optional, but Chrome validates it as 1–16 even when touch
|
|
8
|
+
emulation is being disabled. Omit it when disabling:
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
cdp("Emulation.setTouchEmulationEnabled", enabled=True, maxTouchPoints=5)
|
|
12
|
+
cdp("Emulation.setTouchEmulationEnabled", enabled=False)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Do not pass `maxTouchPoints=0`; Chrome rejects the supplied value before acting
|
|
16
|
+
on `enabled=False`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Browser Use
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parent
|
|
16
|
+
# Chrome identity is machine-global for Browser Workspace. Runtime/config overrides
|
|
17
|
+
# may move sockets/caches, but must never create another browser instance.
|
|
18
|
+
SINGLETON_ROOT = Path.home() / ".config/browser-workspace"
|
|
19
|
+
PROFILE_DIR = SINGLETON_ROOT / "chrome"
|
|
20
|
+
STATE_FILE = SINGLETON_ROOT / "managed-chrome.json"
|
|
21
|
+
LOCK_FILE = SINGLETON_ROOT / "managed-chrome.lock"
|
|
22
|
+
LOG_FILE = SINGLETON_ROOT / "managed-chrome.log"
|
|
23
|
+
DEFAULT_PORT = int(os.environ.get("BROWSER_WORKSPACE_CDP_PORT", "9222"))
|
|
24
|
+
PORT_CANDIDATES = int(os.environ.get("BROWSER_WORKSPACE_CDP_PORT_CANDIDATES", "20"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _endpoint(port: int) -> str:
|
|
28
|
+
return f"http://127.0.0.1:{port}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _endpoint_live(port: int, timeout: float = 0.5) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(f"{_endpoint(port)}/json/version", timeout=timeout) as response:
|
|
34
|
+
payload = json.loads(response.read())
|
|
35
|
+
return bool(payload.get("webSocketDebuggerUrl"))
|
|
36
|
+
except Exception:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _port_available(port: int) -> bool:
|
|
41
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
42
|
+
try:
|
|
43
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
44
|
+
sock.bind(("127.0.0.1", port))
|
|
45
|
+
return True
|
|
46
|
+
except OSError:
|
|
47
|
+
return False
|
|
48
|
+
finally:
|
|
49
|
+
sock.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pid_alive(pid: int | None) -> bool:
|
|
53
|
+
if not pid:
|
|
54
|
+
return False
|
|
55
|
+
try:
|
|
56
|
+
os.kill(int(pid), 0)
|
|
57
|
+
return True
|
|
58
|
+
except (OSError, ValueError, TypeError):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_state() -> dict:
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(STATE_FILE.read_text())
|
|
65
|
+
except (OSError, ValueError, TypeError):
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _write_state(payload: dict) -> None:
|
|
70
|
+
SINGLETON_ROOT.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
72
|
+
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
|
73
|
+
os.replace(tmp, STATE_FILE)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@contextlib.contextmanager
|
|
77
|
+
def _singleton_lock():
|
|
78
|
+
SINGLETON_ROOT.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
handle = open(LOCK_FILE, "a+")
|
|
80
|
+
try:
|
|
81
|
+
if platform.system() == "Windows":
|
|
82
|
+
import msvcrt
|
|
83
|
+
handle.seek(0)
|
|
84
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
85
|
+
else:
|
|
86
|
+
import fcntl
|
|
87
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
try:
|
|
91
|
+
if platform.system() == "Windows":
|
|
92
|
+
import msvcrt
|
|
93
|
+
handle.seek(0)
|
|
94
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
95
|
+
else:
|
|
96
|
+
import fcntl
|
|
97
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
98
|
+
finally:
|
|
99
|
+
handle.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _chrome_path() -> str:
|
|
103
|
+
configured = os.environ.get("BROWSER_WORKSPACE_CHROME_PATH") or os.environ.get("BH_CHROME_PATH")
|
|
104
|
+
if configured:
|
|
105
|
+
return configured
|
|
106
|
+
system = platform.system()
|
|
107
|
+
if system == "Darwin":
|
|
108
|
+
candidates = [
|
|
109
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
110
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
111
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
112
|
+
]
|
|
113
|
+
elif system == "Windows":
|
|
114
|
+
local = Path(os.environ.get("LOCALAPPDATA", ""))
|
|
115
|
+
program_files = [Path(os.environ.get("PROGRAMFILES", "")), Path(os.environ.get("PROGRAMFILES(X86)", ""))]
|
|
116
|
+
candidates = [
|
|
117
|
+
str(local / "Google/Chrome/Application/chrome.exe"),
|
|
118
|
+
*(str(p / "Google/Chrome/Application/chrome.exe") for p in program_files if str(p)),
|
|
119
|
+
]
|
|
120
|
+
else:
|
|
121
|
+
candidates = ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"]
|
|
122
|
+
for candidate in candidates:
|
|
123
|
+
if os.path.isabs(candidate):
|
|
124
|
+
if Path(candidate).exists():
|
|
125
|
+
return candidate
|
|
126
|
+
elif resolved := shutil.which(candidate):
|
|
127
|
+
return resolved
|
|
128
|
+
raise RuntimeError("Browser Workspace could not find Chrome; set BROWSER_WORKSPACE_CHROME_PATH")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _discover_existing_managed_chrome() -> dict | None:
|
|
132
|
+
"""Recover the singleton even if its state file was deleted or became stale."""
|
|
133
|
+
if platform.system() == "Windows":
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
output = subprocess.check_output(["ps", "-axo", "pid=,command="], text=True)
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
profile_arg = f"--user-data-dir={PROFILE_DIR}"
|
|
140
|
+
for line in output.splitlines():
|
|
141
|
+
if profile_arg not in line:
|
|
142
|
+
continue
|
|
143
|
+
match = re.match(r"\s*(\d+)\s+(.+)", line)
|
|
144
|
+
if not match:
|
|
145
|
+
continue
|
|
146
|
+
pid = int(match.group(1))
|
|
147
|
+
command = match.group(2)
|
|
148
|
+
if "--type=" in command:
|
|
149
|
+
continue
|
|
150
|
+
port_match = re.search(r"--remote-debugging-port=(\d+)", command)
|
|
151
|
+
if not port_match:
|
|
152
|
+
continue
|
|
153
|
+
port = int(port_match.group(1))
|
|
154
|
+
if _pid_alive(pid) and _endpoint_live(port):
|
|
155
|
+
return {
|
|
156
|
+
"pid": pid,
|
|
157
|
+
"port": port,
|
|
158
|
+
"profile_dir": str(PROFILE_DIR),
|
|
159
|
+
"chrome_path": command.split(" --user-data-dir=", 1)[0],
|
|
160
|
+
}
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _live_singleton() -> dict | None:
|
|
165
|
+
state = _load_state()
|
|
166
|
+
port = state.get("port")
|
|
167
|
+
pid = state.get("pid")
|
|
168
|
+
if isinstance(port, int) and _pid_alive(pid) and _endpoint_live(port):
|
|
169
|
+
return state
|
|
170
|
+
recovered = _discover_existing_managed_chrome()
|
|
171
|
+
if recovered:
|
|
172
|
+
_write_state(recovered)
|
|
173
|
+
return recovered
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _choose_port() -> int:
|
|
178
|
+
for port in range(DEFAULT_PORT, DEFAULT_PORT + max(1, PORT_CANDIDATES)):
|
|
179
|
+
if _port_available(port):
|
|
180
|
+
return port
|
|
181
|
+
raise RuntimeError(
|
|
182
|
+
f"Browser Workspace could not find a free CDP port in "
|
|
183
|
+
f"{DEFAULT_PORT}..{DEFAULT_PORT + max(1, PORT_CANDIDATES) - 1}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _as_result(state: dict, reused: bool) -> dict:
|
|
188
|
+
endpoint = _endpoint(int(state["port"]))
|
|
189
|
+
os.environ["BU_CDP_URL"] = endpoint
|
|
190
|
+
return {**state, "mode": "managed", "endpoint": endpoint, "reused": reused}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def ensure_managed_chrome() -> dict:
|
|
194
|
+
"""Return the one machine-wide Browser Workspace Chrome, starting it only if absent."""
|
|
195
|
+
with _singleton_lock():
|
|
196
|
+
# Strong invariant: an existing Browser Workspace Chrome always wins.
|
|
197
|
+
if existing := _live_singleton():
|
|
198
|
+
return _as_result(existing, True)
|
|
199
|
+
|
|
200
|
+
# Advanced external endpoint is only considered when no managed singleton exists.
|
|
201
|
+
if os.environ.get("BU_CDP_WS"):
|
|
202
|
+
return {"mode": "override", "endpoint": "BU_CDP_WS"}
|
|
203
|
+
if url := os.environ.get("BU_CDP_URL"):
|
|
204
|
+
return {"mode": "override", "endpoint": url}
|
|
205
|
+
|
|
206
|
+
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
port = _choose_port()
|
|
208
|
+
chrome = _chrome_path()
|
|
209
|
+
log = open(LOG_FILE, "ab", buffering=0)
|
|
210
|
+
command = [
|
|
211
|
+
chrome,
|
|
212
|
+
f"--user-data-dir={PROFILE_DIR}",
|
|
213
|
+
f"--remote-debugging-port={port}",
|
|
214
|
+
"--no-first-run",
|
|
215
|
+
"--no-default-browser-check",
|
|
216
|
+
"about:blank",
|
|
217
|
+
]
|
|
218
|
+
process = subprocess.Popen(
|
|
219
|
+
command,
|
|
220
|
+
stdin=subprocess.DEVNULL,
|
|
221
|
+
stdout=log,
|
|
222
|
+
stderr=log,
|
|
223
|
+
start_new_session=True,
|
|
224
|
+
close_fds=True,
|
|
225
|
+
)
|
|
226
|
+
log.close()
|
|
227
|
+
|
|
228
|
+
deadline = time.time() + float(os.environ.get("BROWSER_WORKSPACE_CHROME_START_TIMEOUT", "20"))
|
|
229
|
+
while time.time() < deadline:
|
|
230
|
+
if process.poll() is not None:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"Browser Workspace managed Chrome exited with code {process.returncode}; see {LOG_FILE}"
|
|
233
|
+
)
|
|
234
|
+
if _endpoint_live(port):
|
|
235
|
+
payload = {
|
|
236
|
+
"pid": process.pid,
|
|
237
|
+
"port": port,
|
|
238
|
+
"profile_dir": str(PROFILE_DIR),
|
|
239
|
+
"chrome_path": chrome,
|
|
240
|
+
}
|
|
241
|
+
_write_state(payload)
|
|
242
|
+
return _as_result(payload, False)
|
|
243
|
+
time.sleep(0.1)
|
|
244
|
+
raise RuntimeError(f"Browser Workspace managed Chrome did not expose CDP on port {port}; see {LOG_FILE}")
|