@pencil-agent/nano-pencil 2.0.0-beta.8 → 2.0.0
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 +267 -267
- package/dist/build-meta.json +3 -3
- package/dist/core/export-html/AGENT.md +11 -11
- package/dist/core/export-html/template.css +971 -971
- package/dist/core/export-html/template.html +54 -54
- package/dist/core/extensions-host/index.d.ts +1 -1
- package/dist/core/extensions-host/loader.js +1 -1
- package/dist/core/extensions-host/runner.d.ts +1 -0
- package/dist/core/extensions-host/runner.js +2 -2
- package/dist/core/extensions-host/types.d.ts +17 -22
- package/dist/core/lib/ai/src/types.d.ts +12 -2
- package/dist/core/persona/persona-manager.js +5 -2
- package/dist/core/runtime/agent-session.js +3 -3
- package/dist/core/runtime/extension-core-bindings.d.ts +1 -0
- package/dist/core/runtime/extension-core-bindings.js +2 -2
- package/dist/extensions/builtin/AGENT.md +115 -115
- package/dist/extensions/builtin/browser/AGENT.md +17 -17
- package/dist/extensions/builtin/browser/agent-workspace/agent_helpers.py +12 -12
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/amazon/product-search.md +198 -198
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/archive-org/scraping.md +341 -341
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv/scraping.md +311 -311
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/arxiv-bulk/scraping.md +333 -333
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/atlas/overview.md +70 -70
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/booking-com/scraping.md +578 -578
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/capterra/scraping.md +440 -440
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/centilebrain/generate-estimates.md +110 -110
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coingecko/scraping.md +325 -325
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coinmarketcap/scraping.md +463 -463
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/coursera/scraping.md +360 -360
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/craigslist/scraping.md +390 -390
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/crossref/scraping.md +568 -568
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/dev-to/scraping.md +323 -323
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/duckduckgo/scraping.md +349 -349
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/ebay/scraping.md +435 -435
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/etsy/scraping.md +506 -506
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/eventbrite/scraping.md +363 -363
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/expedia/automation.md +168 -168
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/groups.md +236 -236
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/facebook/pages.md +295 -295
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/framer/editor.md +108 -108
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/fred/scraping.md +493 -493
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/g2/scraping.md +580 -580
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/genius/scraping.md +511 -511
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/repo-actions.md +65 -65
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/github/scraping.md +184 -184
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/glassdoor/scraping.md +543 -543
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gmail/compose.md +122 -122
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/goodreads/scraping.md +461 -461
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/gutenberg/scraping.md +383 -383
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/hackernews/scraping.md +243 -243
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/howlongtobeat/scraping.md +473 -473
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/imdb/scraping.md +271 -271
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/itch-io/scraping.md +436 -436
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/job-boards/indeed-glassdoor.md +1021 -1021
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/letterboxd/scraping.md +349 -349
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/linkedin/invitation-manager.md +109 -109
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/loom/folder-enumeration.md +170 -170
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/macrotrends/scraping.md +537 -537
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/article-hydration.md +120 -120
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/medium/scraping.md +414 -414
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/metacritic/scraping.md +477 -477
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/musicbrainz/scraping.md +478 -478
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/nasa/scraping.md +339 -339
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/news-aggregation/multi-source.md +205 -205
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/open-library/scraping.md +472 -472
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openalex/scraping.md +470 -470
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/openstreetmap/scraping.md +490 -490
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/package-registries/npm-pypi.md +478 -478
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/polymarket/scraping.md +234 -234
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/producthunt/scraping.md +307 -307
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/pubmed/scraping.md +421 -421
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/quora/scraping.md +364 -364
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rawg/scraping.md +352 -352
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/reddit/scraping.md +124 -124
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/rest-countries/scraping.md +233 -233
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/sec-edgar/scraping.md +361 -361
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/README.md +36 -36
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/embedded-apps.md +72 -72
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/knowledge-base.md +109 -109
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/shopify-admin/polaris-inputs.md +137 -137
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/soundcloud/scraping.md +362 -362
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/spotify/scraping.md +339 -339
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/stackoverflow/scraping.md +435 -435
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/steam/scraping.md +575 -575
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/substack/scraping.md +338 -338
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/thetechgeeks/pricing.md +52 -52
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tiktok/upload.md +107 -107
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/tradingview/scraping.md +309 -309
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trello/boards-and-lists.md +88 -88
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/trustpilot/scraping.md +375 -375
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/walmart/scraping.md +444 -444
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wayback-machine/scraping.md +306 -306
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/weather/scraping.md +398 -398
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/wellfound/scraping.md +596 -596
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/world-bank/scraping.md +356 -356
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/xiaohongshu/scraping.md +84 -84
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/youtube/scraping.md +418 -418
- package/dist/extensions/builtin/browser/agent-workspace/domain-skills/zillow/scraping.md +433 -433
- package/dist/extensions/builtin/browser/browser.md +73 -73
- package/dist/extensions/builtin/browser/install.md +142 -142
- package/dist/extensions/builtin/browser/interaction-skills/connection.md +48 -48
- package/dist/extensions/builtin/browser/interaction-skills/cookies.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/cross-origin-iframes.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/dialogs.md +64 -64
- package/dist/extensions/builtin/browser/interaction-skills/downloads.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/drag-and-drop.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/dropdowns.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/iframes.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/network-requests.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/print-as-pdf.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/profile-sync.md +90 -90
- package/dist/extensions/builtin/browser/interaction-skills/screenshots.md +17 -17
- package/dist/extensions/builtin/browser/interaction-skills/scrolling.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/shadow-dom.md +3 -3
- package/dist/extensions/builtin/browser/interaction-skills/tabs.md +69 -69
- package/dist/extensions/builtin/browser/interaction-skills/uploads.md +1 -1
- package/dist/extensions/builtin/browser/interaction-skills/viewport.md +3 -3
- package/dist/extensions/builtin/browser/src/browser_harness/AGENT.md +15 -15
- package/dist/extensions/builtin/browser/src/browser_harness/__init__.py +8 -8
- package/dist/extensions/builtin/browser/src/browser_harness/_ipc.py +90 -90
- package/dist/extensions/builtin/browser/src/browser_harness/admin.py +722 -722
- package/dist/extensions/builtin/browser/src/browser_harness/daemon.py +328 -328
- package/dist/extensions/builtin/browser/src/browser_harness/helpers.py +396 -396
- package/dist/extensions/builtin/browser/src/browser_harness/run.py +103 -103
- package/dist/extensions/builtin/discipline/skills/brainstorming/SKILL.md +33 -33
- package/dist/extensions/builtin/discipline/skills/executing-plans/SKILL.md +25 -25
- package/dist/extensions/builtin/discipline/skills/finishing-development-branch/SKILL.md +25 -25
- package/dist/extensions/builtin/discipline/skills/receiving-code-review/SKILL.md +22 -22
- package/dist/extensions/builtin/discipline/skills/requesting-code-review/SKILL.md +31 -31
- package/dist/extensions/builtin/discipline/skills/systematic-debugging/SKILL.md +28 -28
- package/dist/extensions/builtin/discipline/skills/test-driven-development/SKILL.md +32 -32
- package/dist/extensions/builtin/discipline/skills/using-git-worktrees/SKILL.md +25 -25
- package/dist/extensions/builtin/discipline/skills/verification-before-completion/SKILL.md +27 -27
- package/dist/extensions/builtin/discipline/skills/writing-plans/SKILL.md +26 -26
- package/dist/extensions/builtin/goal/README.md +67 -67
- package/dist/extensions/builtin/goal/goal-controller.d.ts +39 -10
- package/dist/extensions/builtin/goal/goal-controller.js +1 -1
- package/dist/extensions/builtin/goal/goal-format.js +1 -1
- package/dist/extensions/builtin/goal/goal-prompts.d.ts +2 -0
- package/dist/extensions/builtin/goal/goal-prompts.js +5 -4
- package/dist/extensions/builtin/goal/goal-store.js +1 -1
- package/dist/extensions/builtin/goal/index.d.ts +1 -1
- package/dist/extensions/builtin/goal/index.js +10 -7
- package/dist/extensions/builtin/grub/README.md +112 -112
- package/dist/extensions/builtin/link-world/agent-workspace/README.md +16 -16
- package/dist/extensions/builtin/link-world/index.js +6 -6
- package/dist/extensions/builtin/link-world/internet-search/internet-search.md +65 -65
- package/dist/extensions/builtin/link-world/link-world-agent.md +82 -82
- package/dist/extensions/builtin/link-world/linkworld.md +313 -313
- package/dist/extensions/builtin/link-world/{network-routing.md → network-routing/network-routing.md} +67 -67
- package/dist/extensions/builtin/loop/README.md +92 -92
- package/dist/extensions/builtin/mcp/figma-design.md +68 -68
- package/dist/extensions/builtin/mcp/mcp-management.md +85 -85
- package/dist/extensions/builtin/plan/index.js +1 -1
- package/dist/extensions/builtin/recap/AGENT.md +15 -15
- package/dist/extensions/builtin/sal/README.md +72 -72
- package/dist/extensions/builtin/security-audit/README.md +289 -289
- package/dist/extensions/builtin/task/task-store.d.ts +4 -0
- package/dist/extensions/builtin/task/task-store.js +1 -1
- package/dist/extensions/builtin/team/AGENT.md +112 -112
- package/dist/extensions/builtin/team/TESTING.md +299 -299
- package/dist/extensions/builtin/token-save/README.md +56 -56
- package/dist/extensions/optional/AGENT.md +10 -10
- package/dist/index.d.ts +5 -30
- package/dist/index.js +1 -1
- package/dist/models.d.ts +7 -0
- package/dist/models.js +1 -0
- package/dist/modes/interactive/components/footer.js +1 -1
- package/dist/modes/interactive/components/task-status-panel.d.ts +36 -0
- package/dist/modes/interactive/components/task-status-panel.js +1 -0
- package/dist/modes/interactive/controllers/stream-render-controller.d.ts +7 -0
- package/dist/modes/interactive/controllers/stream-render-controller.js +2 -2
- package/dist/modes/interactive/interactive-mode.js +40 -40
- package/dist/modes/interactive/state/interactive-state.d.ts +2 -0
- package/dist/modes/interactive/state/interactive-state.js +1 -1
- package/dist/modes/interactive/theme/dark.json +85 -85
- package/dist/modes/interactive/theme/light.json +84 -84
- package/dist/modes/interactive/theme/theme-schema.json +335 -335
- package/dist/modes/interactive/theme/warm.json +81 -81
- package/dist/node_modules/@pencil-agent/ai/dist/cli.js +0 -0
- package/dist/node_modules/@pencil-agent/ai/dist/models.generated.js +1 -1
- package/dist/node_modules/@pencil-agent/ai/dist/providers/anthropic.js +2 -2
- package/dist/node_modules/@pencil-agent/ai/dist/providers/openai-completions.js +5 -5
- package/dist/node_modules/@pencil-agent/ai/dist/providers/openai-responses.js +1 -1
- package/dist/node_modules/@pencil-agent/ai/dist/stream.js +1 -1
- package/dist/packages/protocol/src/commands.d.ts +33 -0
- package/dist/packages/protocol/src/flags.d.ts +20 -0
- package/dist/packages/protocol/src/hooks.d.ts +17 -0
- package/dist/packages/protocol/src/hooks.js +0 -0
- package/dist/packages/{extension-sdk → protocol}/src/index.d.ts +7 -4
- package/dist/packages/protocol/src/index.js +1 -0
- package/dist/packages/{extension-sdk → protocol}/src/lifecycle.d.ts +15 -27
- package/dist/packages/protocol/src/lifecycle.js +0 -0
- package/dist/packages/{extension-sdk → protocol}/src/tools.d.ts +1 -1
- package/dist/packages/protocol/src/tools.js +0 -0
- package/dist/public-config.d.ts +12 -0
- package/dist/public-config.js +1 -0
- package/dist/runtime.d.ts +9 -0
- package/dist/runtime.js +1 -0
- package/dist/session-compaction.d.ts +7 -0
- package/dist/session-compaction.js +1 -0
- package/dist/session.d.ts +7 -0
- package/dist/session.js +1 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +1 -0
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +1 -0
- package/docs/ACP/345/215/217/350/256/256/351/233/206/346/210/220/345/274/200/345/217/221/346/226/207/346/241/243.md +851 -0
- package/docs/SDK-TESTING.md +364 -0
- package/docs/codex-goal-command-impl.md +1055 -1055
- package/docs/codex-goal-vs-grub.md +500 -500
- package/docs/custom-provider.md +27 -27
- package/docs/extensions.md +27 -27
- package/docs/keybindings.md +27 -27
- package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +250 -250
- package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +122 -122
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +1222 -1222
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/256/236/347/216/260/346/212/245/345/221/212.md" +158 -158
- package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/257/271/346/257/224/345/210/206/346/236/220.md" +128 -128
- package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +320 -320
- package/docs/loop-usage-examples.md +214 -214
- package/docs/mem-core/346/212/200/346/234/257/346/226/207/346/241/243.md +593 -0
- package/docs/models.md +27 -27
- package/docs/packages.md +27 -27
- package/docs/pi-design-philosophy.md +457 -457
- package/docs/planmode.md +1987 -1987
- package/docs/prompt-templates.md +27 -27
- package/docs/providers.md +27 -27
- package/docs/sdk.md +27 -27
- package/docs/skills.md +27 -27
- package/docs/startup-performance-optimization.md +301 -0
- package/docs/themes.md +27 -27
- package/docs/tui.md +27 -27
- package/docs//350/256/244/347/237/245/345/234/260/345/233/276.md +47 -0
- package/package.json +190 -162
- package/dist/packages/extension-sdk/src/index.js +0 -1
- package/docs/cc-agent-design.md +0 -1297
- package/docs/cc-tui-design.md +0 -1333
- package/docs//345/257/271/346/240/207Claude-Code.md +0 -1775
- /package/dist/packages/{extension-sdk/src/lifecycle.js → protocol/src/commands.js} +0 -0
- /package/dist/packages/{extension-sdk/src/tools.js → protocol/src/flags.js} +0 -0
|
@@ -1,722 +1,722 @@
|
|
|
1
|
-
"""Browser Harness admin commands and remote browser lifecycle.
|
|
2
|
-
|
|
3
|
-
[WHO]: Provides daemon setup/doctor/reload/update helpers and Browser Use cloud browser/profile functions
|
|
4
|
-
[FROM]: Depends on browser_harness._ipc, urllib, subprocess, socket, tempfile, pathlib, and environment variables
|
|
5
|
-
[TO]: Consumed by browser_harness.run and user snippets through pre-imported admin helpers
|
|
6
|
-
[HERE]: extensions/builtin/browser/src/browser_harness/admin.py within vendored Browser Harness package
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import os
|
|
11
|
-
import socket
|
|
12
|
-
import tempfile
|
|
13
|
-
import time
|
|
14
|
-
import urllib.request
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
from . import _ipc as ipc
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _load_env():
|
|
21
|
-
repo_root = Path(__file__).resolve().parents[2]
|
|
22
|
-
workspace = Path(os.environ.get("BH_AGENT_WORKSPACE", repo_root / "agent-workspace")).expanduser()
|
|
23
|
-
for p in (repo_root / ".env", workspace / ".env"):
|
|
24
|
-
if not p.exists():
|
|
25
|
-
continue
|
|
26
|
-
_load_env_file(p)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _load_env_file(p):
|
|
30
|
-
for line in p.read_text().splitlines():
|
|
31
|
-
line = line.strip()
|
|
32
|
-
if not line or line.startswith("#") or "=" not in line:
|
|
33
|
-
continue
|
|
34
|
-
k, v = line.split("=", 1)
|
|
35
|
-
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
_load_env()
|
|
39
|
-
|
|
40
|
-
NAME = os.environ.get("BU_NAME", "default")
|
|
41
|
-
BU_API = "https://api.browser-use.com/api/v3"
|
|
42
|
-
GH_RELEASES = "https://api.github.com/repos/browser-use/browser-harness/releases/latest"
|
|
43
|
-
VERSION_CACHE = Path(tempfile.gettempdir()) / "bu-version-cache.json"
|
|
44
|
-
VERSION_CACHE_TTL = 24 * 3600
|
|
45
|
-
DOCTOR_TEXT_LIMIT = 140
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _log_tail(name):
|
|
49
|
-
try:
|
|
50
|
-
return ipc.log_path(name or NAME).read_text().strip().splitlines()[-1]
|
|
51
|
-
except (FileNotFoundError, IndexError):
|
|
52
|
-
return None
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _needs_chrome_remote_debugging_prompt(msg):
|
|
56
|
-
"""True when Chrome needs the inspect-page permission/profile flow."""
|
|
57
|
-
lower = (msg or "").lower()
|
|
58
|
-
return (
|
|
59
|
-
"devtoolsactiveport not found" in lower
|
|
60
|
-
or "enable chrome://inspect" in lower
|
|
61
|
-
or "not live yet" in lower
|
|
62
|
-
or (
|
|
63
|
-
"ws handshake failed" in lower
|
|
64
|
-
and (
|
|
65
|
-
"403" in lower
|
|
66
|
-
or "opening handshake" in lower
|
|
67
|
-
or "timed out" in lower
|
|
68
|
-
or "timeout" in lower
|
|
69
|
-
)
|
|
70
|
-
)
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _is_local_chrome_mode(env=None):
|
|
75
|
-
"""True when the daemon discovers a local Chrome instead of a remote CDP WS."""
|
|
76
|
-
return not (env or {}).get("BU_CDP_WS") and not os.environ.get("BU_CDP_WS")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def daemon_alive(name=None):
|
|
80
|
-
try:
|
|
81
|
-
c = ipc.connect(name or NAME, timeout=1.0); c.close(); return True
|
|
82
|
-
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError):
|
|
83
|
-
return False
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _daemon_endpoint_names():
|
|
87
|
-
# BH_TMP_DIR isolates one daemon per dir → no filename-prefix discovery,
|
|
88
|
-
# just check whether our local endpoint exists. Without BH_TMP_DIR, _TMP
|
|
89
|
-
# is the shared default (`/tmp` etc.) and we glob `bu-*.<suffix>` to find
|
|
90
|
-
# every daemon on the machine.
|
|
91
|
-
suffix = ".port" if ipc.IS_WINDOWS else ".sock"
|
|
92
|
-
if ipc.BH_TMP_DIR:
|
|
93
|
-
return [NAME] if (ipc._TMP / f"bu{suffix}").exists() else []
|
|
94
|
-
names = []
|
|
95
|
-
for p in sorted(ipc._TMP.glob(f"bu-*{suffix}")):
|
|
96
|
-
raw = p.name[3:-len(suffix)]
|
|
97
|
-
try:
|
|
98
|
-
ipc._check(raw)
|
|
99
|
-
except ValueError:
|
|
100
|
-
continue
|
|
101
|
-
names.append(raw)
|
|
102
|
-
return names
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _daemon_browser_connection(name):
|
|
106
|
-
c = None
|
|
107
|
-
try:
|
|
108
|
-
c = ipc.connect(name, timeout=1.0)
|
|
109
|
-
c.sendall(b'{"meta":"connection_status"}\n')
|
|
110
|
-
data = b""
|
|
111
|
-
while not data.endswith(b"\n"):
|
|
112
|
-
chunk = c.recv(1 << 16)
|
|
113
|
-
if not chunk:
|
|
114
|
-
break
|
|
115
|
-
data += chunk
|
|
116
|
-
response = json.loads(data)
|
|
117
|
-
if "error" in response:
|
|
118
|
-
return None
|
|
119
|
-
page = response.get("page")
|
|
120
|
-
if page:
|
|
121
|
-
page = {"title": page.get("title") or "(untitled)", "url": page.get("url") or ""}
|
|
122
|
-
return {"name": name, "page": page}
|
|
123
|
-
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, KeyError, ValueError, json.JSONDecodeError):
|
|
124
|
-
return None
|
|
125
|
-
finally:
|
|
126
|
-
if c:
|
|
127
|
-
c.close()
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def browser_connections():
|
|
131
|
-
"""Live browser-harness daemons with healthy CDP browser connections and their attached page."""
|
|
132
|
-
out = []
|
|
133
|
-
for name in _daemon_endpoint_names():
|
|
134
|
-
conn = _daemon_browser_connection(name)
|
|
135
|
-
if conn:
|
|
136
|
-
out.append(conn)
|
|
137
|
-
return out
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def active_browser_connections():
|
|
141
|
-
"""Count live browser-harness daemons with a healthy CDP browser connection."""
|
|
142
|
-
return len(browser_connections())
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _doctor_short_text(value, limit=None):
|
|
146
|
-
limit = limit or DOCTOR_TEXT_LIMIT
|
|
147
|
-
value = str(value)
|
|
148
|
-
return value if len(value) <= limit else value[:limit - 3] + "..."
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def ensure_daemon(wait=60.0, name=None, env=None, _open_inspect=True):
|
|
152
|
-
"""Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect."""
|
|
153
|
-
if daemon_alive(name):
|
|
154
|
-
# Stale daemons accept connects AND reply to meta:* (pure Python) even when the
|
|
155
|
-
# CDP WS to Chrome is dead — probe with a real CDP call and require "result".
|
|
156
|
-
# Must go through ipc.connect so this works on Windows (TCP loopback) too;
|
|
157
|
-
# raw AF_UNIX here would fail on every warm call and churn the daemon.
|
|
158
|
-
try:
|
|
159
|
-
s = ipc.connect(name or NAME, timeout=3.0)
|
|
160
|
-
s.sendall(b'{"method":"Target.getTargets","params":{}}\n')
|
|
161
|
-
data = b""
|
|
162
|
-
while not data.endswith(b"\n"):
|
|
163
|
-
chunk = s.recv(1 << 16)
|
|
164
|
-
if not chunk: break
|
|
165
|
-
data += chunk
|
|
166
|
-
if b'"result"' in data: return
|
|
167
|
-
except Exception: pass
|
|
168
|
-
restart_daemon(name)
|
|
169
|
-
|
|
170
|
-
import subprocess, sys
|
|
171
|
-
local = _is_local_chrome_mode(env)
|
|
172
|
-
for attempt in (0, 1):
|
|
173
|
-
e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
|
|
174
|
-
p = subprocess.Popen(
|
|
175
|
-
[sys.executable, "-m", "browser_harness.daemon"],
|
|
176
|
-
env=e, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(),
|
|
177
|
-
)
|
|
178
|
-
deadline = time.time() + wait
|
|
179
|
-
while time.time() < deadline:
|
|
180
|
-
if daemon_alive(name): return
|
|
181
|
-
if p.poll() is not None: break
|
|
182
|
-
time.sleep(0.2)
|
|
183
|
-
msg = _log_tail(name) or ""
|
|
184
|
-
if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg):
|
|
185
|
-
if _open_inspect:
|
|
186
|
-
_open_chrome_inspect()
|
|
187
|
-
print("browser-harness: click Allow on chrome://inspect (and tick the checkbox if shown)", file=sys.stderr)
|
|
188
|
-
restart_daemon(name)
|
|
189
|
-
continue
|
|
190
|
-
raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def stop_remote_daemon(name="remote"):
|
|
194
|
-
"""Stop a remote daemon and its backing Browser Use cloud browser.
|
|
195
|
-
|
|
196
|
-
Triggers the daemon's clean shutdown, which PATCHes
|
|
197
|
-
/browsers/{id} {"action":"stop"} so billing ends and any profile
|
|
198
|
-
state in the session is persisted."""
|
|
199
|
-
# restart_daemon is misnamed — it only stops the daemon (sends
|
|
200
|
-
# shutdown, SIGTERMs if needed, unlinks socket+pid). It never
|
|
201
|
-
# restarts anything on its own; a follow-up `browser-harness`
|
|
202
|
-
# call would auto-spawn a fresh one via ensure_daemon(). That
|
|
203
|
-
# "run-it-again-to-restart" workflow is why it was named that way.
|
|
204
|
-
restart_daemon(name)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def restart_daemon(name=None):
|
|
208
|
-
"""Best-effort daemon shutdown + socket/pid cleanup.
|
|
209
|
-
|
|
210
|
-
Name is historical: callers typically follow this with another
|
|
211
|
-
`browser-harness` invocation, which auto-spawns a fresh daemon via
|
|
212
|
-
ensure_daemon(). The function itself only stops."""
|
|
213
|
-
import signal
|
|
214
|
-
|
|
215
|
-
pid_path = str(ipc.pid_path(name or NAME))
|
|
216
|
-
try:
|
|
217
|
-
c = ipc.connect(name or NAME, timeout=5.0)
|
|
218
|
-
c.sendall(b'{"meta":"shutdown"}\n')
|
|
219
|
-
c.recv(1024)
|
|
220
|
-
c.close()
|
|
221
|
-
except Exception:
|
|
222
|
-
pass
|
|
223
|
-
try:
|
|
224
|
-
pid = int(open(pid_path).read())
|
|
225
|
-
except (FileNotFoundError, ValueError):
|
|
226
|
-
pid = None
|
|
227
|
-
if pid:
|
|
228
|
-
for _ in range(75):
|
|
229
|
-
try:
|
|
230
|
-
os.kill(pid, 0)
|
|
231
|
-
time.sleep(0.2)
|
|
232
|
-
except (ProcessLookupError, OSError, SystemError):
|
|
233
|
-
break
|
|
234
|
-
else:
|
|
235
|
-
try:
|
|
236
|
-
os.kill(pid, signal.SIGTERM)
|
|
237
|
-
except (ProcessLookupError, OSError, SystemError):
|
|
238
|
-
pass
|
|
239
|
-
ipc.cleanup_endpoint(name or NAME)
|
|
240
|
-
try:
|
|
241
|
-
os.unlink(pid_path)
|
|
242
|
-
except FileNotFoundError:
|
|
243
|
-
pass
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _browser_use(path, method, body=None):
|
|
247
|
-
key = os.environ.get("BROWSER_USE_API_KEY")
|
|
248
|
-
if not key:
|
|
249
|
-
raise RuntimeError("BROWSER_USE_API_KEY missing -- see .env.example")
|
|
250
|
-
req = urllib.request.Request(
|
|
251
|
-
f"{BU_API}{path}",
|
|
252
|
-
method=method,
|
|
253
|
-
data=(json.dumps(body).encode() if body is not None else None),
|
|
254
|
-
headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
|
|
255
|
-
)
|
|
256
|
-
return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def _stop_cloud_browser(browser_id):
|
|
260
|
-
if not browser_id:
|
|
261
|
-
return
|
|
262
|
-
try:
|
|
263
|
-
_browser_use(f"/browsers/{browser_id}", "PATCH", {"action": "stop"})
|
|
264
|
-
except BaseException:
|
|
265
|
-
pass
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def _cdp_ws_from_url(cdp_url):
|
|
269
|
-
return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def _has_local_gui():
|
|
273
|
-
"""True when this machine plausibly has a browser we can open. False on headless servers."""
|
|
274
|
-
import platform
|
|
275
|
-
system = platform.system()
|
|
276
|
-
if system in ("Darwin", "Windows"):
|
|
277
|
-
return True
|
|
278
|
-
if system == "Linux":
|
|
279
|
-
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
280
|
-
return False
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _show_live_url(url):
|
|
284
|
-
"""Print liveUrl and auto-open it locally if there's a GUI."""
|
|
285
|
-
import sys, webbrowser
|
|
286
|
-
if not url: return
|
|
287
|
-
print(url)
|
|
288
|
-
if not _has_local_gui():
|
|
289
|
-
print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
|
|
290
|
-
return
|
|
291
|
-
try:
|
|
292
|
-
webbrowser.open(url, new=2)
|
|
293
|
-
print("(opened liveUrl in your default browser)", file=sys.stderr)
|
|
294
|
-
except Exception as e:
|
|
295
|
-
print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def list_cloud_profiles():
|
|
299
|
-
"""List cloud profiles under the current API key.
|
|
300
|
-
|
|
301
|
-
Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
|
|
302
|
-
is the array of domain strings the cloud profile has cookies for — use
|
|
303
|
-
`len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
|
|
304
|
-
detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
|
|
305
|
-
|
|
306
|
-
Paginates through all pages — the API caps `pageSize` at 100."""
|
|
307
|
-
out, page = [], 1
|
|
308
|
-
while True:
|
|
309
|
-
listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
|
|
310
|
-
items = listing.get("items") if isinstance(listing, dict) else listing
|
|
311
|
-
if not items:
|
|
312
|
-
break
|
|
313
|
-
for p in items:
|
|
314
|
-
detail = _browser_use(f"/profiles/{p['id']}", "GET")
|
|
315
|
-
out.append({
|
|
316
|
-
"id": detail["id"],
|
|
317
|
-
"name": detail.get("name"),
|
|
318
|
-
"userId": detail.get("userId"),
|
|
319
|
-
"cookieDomains": detail.get("cookieDomains") or [],
|
|
320
|
-
"lastUsedAt": detail.get("lastUsedAt"),
|
|
321
|
-
})
|
|
322
|
-
if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
|
|
323
|
-
break
|
|
324
|
-
page += 1
|
|
325
|
-
return out
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _resolve_profile_name(profile_name):
|
|
329
|
-
"""Find a single cloud profile by exact name; raise if 0 or >1 match."""
|
|
330
|
-
matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
|
|
331
|
-
if not matches:
|
|
332
|
-
raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
|
|
333
|
-
if len(matches) > 1:
|
|
334
|
-
raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
|
|
335
|
-
return matches[0]["id"]
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
|
|
339
|
-
"""Provision a Browser Use cloud browser and start a daemon attached to it.
|
|
340
|
-
|
|
341
|
-
kwargs forwarded to `POST /browsers` (camelCase):
|
|
342
|
-
profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
|
|
343
|
-
profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
|
|
344
|
-
proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
|
|
345
|
-
timeout — minutes, 1..240.
|
|
346
|
-
customProxy — {host, port, username, password, ignoreCertErrors}.
|
|
347
|
-
browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
|
|
348
|
-
|
|
349
|
-
Returns the full browser dict including `liveUrl`. Prints the liveUrl and
|
|
350
|
-
auto-opens it locally when a GUI is detected, so the user can watch along."""
|
|
351
|
-
if daemon_alive(name):
|
|
352
|
-
raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
|
|
353
|
-
if profileName:
|
|
354
|
-
if "profileId" in create_kwargs:
|
|
355
|
-
raise RuntimeError("pass profileName OR profileId, not both")
|
|
356
|
-
create_kwargs["profileId"] = _resolve_profile_name(profileName)
|
|
357
|
-
browser = _browser_use("/browsers", "POST", create_kwargs)
|
|
358
|
-
try:
|
|
359
|
-
ensure_daemon(
|
|
360
|
-
name=name,
|
|
361
|
-
env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
|
|
362
|
-
)
|
|
363
|
-
except BaseException:
|
|
364
|
-
_stop_cloud_browser(browser.get("id"))
|
|
365
|
-
raise
|
|
366
|
-
_show_live_url(browser.get("liveUrl"))
|
|
367
|
-
return browser
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
def list_local_profiles():
|
|
371
|
-
"""Detected local browser profiles on this machine. Shells out to `profile-use list --json`.
|
|
372
|
-
Returns [{BrowserName, BrowserPath, ProfileName, ProfilePath, DisplayName}, ...].
|
|
373
|
-
Requires `profile-use` (see interaction-skills/profile-sync.md for install)."""
|
|
374
|
-
import json, shutil, subprocess
|
|
375
|
-
if not shutil.which("profile-use"):
|
|
376
|
-
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
377
|
-
return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True))
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
|
|
381
|
-
include_domains=None, exclude_domains=None):
|
|
382
|
-
"""Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
|
|
383
|
-
|
|
384
|
-
Shells out to `profile-use sync` (v1.0.4+). Requires BROWSER_USE_API_KEY and the
|
|
385
|
-
target local Chrome profile to be closed (profile-use needs an exclusive lock on
|
|
386
|
-
the Cookies DB).
|
|
387
|
-
|
|
388
|
-
Args:
|
|
389
|
-
profile_name: local Chrome profile name (as shown by `list_local_profiles`).
|
|
390
|
-
browser: disambiguate when multiple browsers have profiles of the
|
|
391
|
-
same name (e.g. "Google Chrome"). Default: any match.
|
|
392
|
-
cloud_profile_id: push cookies into this existing cloud profile instead of
|
|
393
|
-
creating a new one. Idempotent — call again to refresh
|
|
394
|
-
the same profile. Default: create new.
|
|
395
|
-
include_domains: only sync cookies for these domains (and subdomains).
|
|
396
|
-
Leading dot is optional. Example: ["google.com", "stripe.com"].
|
|
397
|
-
exclude_domains: drop cookies for these domains (and subdomains). Applied
|
|
398
|
-
before `include_domains` so exclude wins on overlap."""
|
|
399
|
-
import os, re, shutil, subprocess, sys
|
|
400
|
-
if not shutil.which("profile-use"):
|
|
401
|
-
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
402
|
-
if not os.environ.get("BROWSER_USE_API_KEY"):
|
|
403
|
-
raise RuntimeError("BROWSER_USE_API_KEY missing")
|
|
404
|
-
cmd = ["profile-use", "sync", "--profile", profile_name]
|
|
405
|
-
if browser:
|
|
406
|
-
cmd += ["--browser", browser]
|
|
407
|
-
if cloud_profile_id:
|
|
408
|
-
cmd += ["--cloud-profile-id", cloud_profile_id]
|
|
409
|
-
for d in include_domains or []:
|
|
410
|
-
cmd += ["--domain", d]
|
|
411
|
-
for d in exclude_domains or []:
|
|
412
|
-
cmd += ["--exclude-domain", d]
|
|
413
|
-
r = subprocess.run(cmd, text=True, capture_output=True)
|
|
414
|
-
sys.stdout.write(r.stdout)
|
|
415
|
-
sys.stderr.write(r.stderr)
|
|
416
|
-
if r.returncode != 0:
|
|
417
|
-
raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
|
|
418
|
-
# With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
|
|
419
|
-
# instead of "Profile created: <uuid>", so we already know the UUID.
|
|
420
|
-
if cloud_profile_id:
|
|
421
|
-
return cloud_profile_id
|
|
422
|
-
m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
|
|
423
|
-
if not m:
|
|
424
|
-
raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
|
|
425
|
-
return m.group(1)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def _version():
|
|
429
|
-
"""Installed version of the browser-harness package. Empty string if unknown."""
|
|
430
|
-
try:
|
|
431
|
-
from importlib.metadata import PackageNotFoundError, version
|
|
432
|
-
try:
|
|
433
|
-
return version("browser-harness")
|
|
434
|
-
except PackageNotFoundError:
|
|
435
|
-
return ""
|
|
436
|
-
except Exception:
|
|
437
|
-
return ""
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
def _repo_dir():
|
|
441
|
-
"""Return the repo root if this install is an editable git clone, else None."""
|
|
442
|
-
for p in Path(__file__).resolve().parents:
|
|
443
|
-
if (p / ".git").is_dir():
|
|
444
|
-
return p
|
|
445
|
-
return None
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
def _install_mode():
|
|
449
|
-
""""git" for editable clone, "pypi" for an installed wheel, "unknown" otherwise."""
|
|
450
|
-
if _repo_dir():
|
|
451
|
-
return "git"
|
|
452
|
-
return "pypi" if _version() else "unknown"
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def _cache_read():
|
|
456
|
-
try:
|
|
457
|
-
return json.loads(VERSION_CACHE.read_text())
|
|
458
|
-
except (FileNotFoundError, ValueError):
|
|
459
|
-
return {}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def _cache_write(data):
|
|
463
|
-
try:
|
|
464
|
-
VERSION_CACHE.write_text(json.dumps(data))
|
|
465
|
-
except OSError:
|
|
466
|
-
pass
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
def _latest_release_tag(force=False):
|
|
470
|
-
"""Return latest release tag from GitHub, or None. Cached for 24h to avoid hammering the API."""
|
|
471
|
-
cache = _cache_read()
|
|
472
|
-
now = time.time()
|
|
473
|
-
if not force and cache.get("tag") and now - cache.get("fetched_at", 0) < VERSION_CACHE_TTL:
|
|
474
|
-
return cache["tag"]
|
|
475
|
-
try:
|
|
476
|
-
req = urllib.request.Request(GH_RELEASES, headers={"Accept": "application/vnd.github+json"})
|
|
477
|
-
tag = json.loads(urllib.request.urlopen(req, timeout=5).read()).get("tag_name") or ""
|
|
478
|
-
except Exception:
|
|
479
|
-
return cache.get("tag") # fall back to last known
|
|
480
|
-
tag = tag.lstrip("v")
|
|
481
|
-
_cache_write({**cache, "tag": tag, "fetched_at": now})
|
|
482
|
-
return tag or None
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def _version_tuple(v):
|
|
486
|
-
"""Best-effort semver parse. Non-numeric components sort as 0, so pre-releases may not rank perfectly."""
|
|
487
|
-
parts = []
|
|
488
|
-
for s in (v or "").split("."):
|
|
489
|
-
m = ""
|
|
490
|
-
for ch in s:
|
|
491
|
-
if ch.isdigit():
|
|
492
|
-
m += ch
|
|
493
|
-
else:
|
|
494
|
-
break
|
|
495
|
-
parts.append(int(m) if m else 0)
|
|
496
|
-
return tuple(parts)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def check_for_update():
|
|
500
|
-
"""(current, latest, newer_available). latest may be None if the API was unreachable and no cache exists."""
|
|
501
|
-
cur = _version()
|
|
502
|
-
latest = _latest_release_tag()
|
|
503
|
-
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
504
|
-
return cur, latest, newer
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
def print_update_banner(out=None):
|
|
508
|
-
"""Print the update banner to stderr once per day. Silent when up-to-date or offline."""
|
|
509
|
-
import sys
|
|
510
|
-
out = out or sys.stderr
|
|
511
|
-
cache = _cache_read()
|
|
512
|
-
today = time.strftime("%Y-%m-%d")
|
|
513
|
-
if cache.get("banner_shown_on") == today:
|
|
514
|
-
return
|
|
515
|
-
cur, latest, newer = check_for_update()
|
|
516
|
-
if not newer:
|
|
517
|
-
return
|
|
518
|
-
print(f"[browser-harness] update available: {cur} -> {latest}", file=out)
|
|
519
|
-
print(f"[browser-harness] agents: run `browser-harness --update -y` to upgrade and restart the daemon", file=out)
|
|
520
|
-
_cache_write({**cache, "banner_shown_on": today})
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def _chrome_running():
|
|
524
|
-
"""Cross-platform best-effort check for a running Chrome/Edge process."""
|
|
525
|
-
import platform, subprocess
|
|
526
|
-
system = platform.system()
|
|
527
|
-
try:
|
|
528
|
-
if system == "Windows":
|
|
529
|
-
out = subprocess.check_output(["tasklist"], text=True, timeout=5)
|
|
530
|
-
names = ("chrome.exe", "msedge.exe")
|
|
531
|
-
else:
|
|
532
|
-
out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, timeout=5)
|
|
533
|
-
names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge")
|
|
534
|
-
return any(n.lower() in out.lower() for n in names)
|
|
535
|
-
except Exception:
|
|
536
|
-
return False
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
def _open_chrome_inspect():
|
|
540
|
-
"""Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
|
|
541
|
-
import platform, subprocess, webbrowser
|
|
542
|
-
url = "chrome://inspect/#remote-debugging"
|
|
543
|
-
if platform.system() == "Darwin":
|
|
544
|
-
try:
|
|
545
|
-
subprocess.run([
|
|
546
|
-
"osascript",
|
|
547
|
-
"-e", 'tell application "Google Chrome" to activate',
|
|
548
|
-
"-e", f'tell application "Google Chrome" to open location "{url}"',
|
|
549
|
-
], timeout=5, check=False)
|
|
550
|
-
return
|
|
551
|
-
except Exception:
|
|
552
|
-
pass
|
|
553
|
-
try:
|
|
554
|
-
webbrowser.open(url, new=2)
|
|
555
|
-
except Exception:
|
|
556
|
-
pass
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
def run_setup():
|
|
560
|
-
"""Interactive bootstrap: attach to the running browser, guiding the user through chrome://inspect if needed.
|
|
561
|
-
|
|
562
|
-
Exit code 0 on success, 1 on failure."""
|
|
563
|
-
import sys
|
|
564
|
-
print("browser-harness setup: attaching to your browser...")
|
|
565
|
-
|
|
566
|
-
if daemon_alive():
|
|
567
|
-
print("daemon already running; nothing to do.")
|
|
568
|
-
return 0
|
|
569
|
-
|
|
570
|
-
if not _chrome_running():
|
|
571
|
-
print("no Chrome/Edge process detected. please start your browser and rerun `browser-harness --setup`.")
|
|
572
|
-
return 1
|
|
573
|
-
|
|
574
|
-
# First attach attempt.
|
|
575
|
-
try:
|
|
576
|
-
ensure_daemon(wait=20.0)
|
|
577
|
-
print("daemon is up.")
|
|
578
|
-
return 0
|
|
579
|
-
except RuntimeError as e:
|
|
580
|
-
first_err = str(e)
|
|
581
|
-
|
|
582
|
-
needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err)
|
|
583
|
-
if needs_inspect:
|
|
584
|
-
print("chrome remote-debugging is not enabled on the current profile.")
|
|
585
|
-
print("opening chrome://inspect/#remote-debugging -- in the tab that opens:")
|
|
586
|
-
print(" 1. if chrome shows the profile picker, pick your normal profile;")
|
|
587
|
-
print(" 2. tick 'Discover network targets' and click Allow if prompted.")
|
|
588
|
-
_open_chrome_inspect()
|
|
589
|
-
else:
|
|
590
|
-
print(f"attach failed: {first_err}")
|
|
591
|
-
print("retrying for up to 60s (chrome may still be starting up)...")
|
|
592
|
-
|
|
593
|
-
deadline = time.time() + 60
|
|
594
|
-
last = first_err
|
|
595
|
-
while time.time() < deadline:
|
|
596
|
-
try:
|
|
597
|
-
ensure_daemon(wait=5.0, _open_inspect=False)
|
|
598
|
-
print("daemon is up.")
|
|
599
|
-
return 0
|
|
600
|
-
except RuntimeError as e:
|
|
601
|
-
last = str(e)
|
|
602
|
-
time.sleep(2)
|
|
603
|
-
|
|
604
|
-
print(f"setup failed: {last}", file=sys.stderr)
|
|
605
|
-
print("run `browser-harness --doctor` for diagnostics.", file=sys.stderr)
|
|
606
|
-
return 1
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
def run_doctor():
|
|
610
|
-
"""Read-only diagnostics. Exit 0 iff everything looks healthy."""
|
|
611
|
-
import platform, shutil, sys
|
|
612
|
-
cur = _version()
|
|
613
|
-
mode = _install_mode()
|
|
614
|
-
chrome = _chrome_running()
|
|
615
|
-
daemon = daemon_alive()
|
|
616
|
-
connections = browser_connections()
|
|
617
|
-
profile_use = shutil.which("profile-use") is not None
|
|
618
|
-
api_key = bool(os.environ.get("BROWSER_USE_API_KEY"))
|
|
619
|
-
latest = _latest_release_tag()
|
|
620
|
-
# Only claim an update when we know the installed version — `cur or "(unknown)"`
|
|
621
|
-
# for display would otherwise be parsed as (0,) and flag every latest as newer.
|
|
622
|
-
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
623
|
-
cur_display = cur or "(unknown)"
|
|
624
|
-
|
|
625
|
-
def row(label, ok, detail=""):
|
|
626
|
-
mark = "ok " if ok else "FAIL"
|
|
627
|
-
print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
|
|
628
|
-
|
|
629
|
-
print("browser-harness doctor")
|
|
630
|
-
print(f" platform {platform.system()} {platform.release()}")
|
|
631
|
-
print(f" python {sys.version.split()[0]}")
|
|
632
|
-
print(f" version {cur_display} ({mode})")
|
|
633
|
-
if latest:
|
|
634
|
-
print(f" latest release {latest}" + (" (update available)" if newer else ""))
|
|
635
|
-
else:
|
|
636
|
-
print(" latest release (could not reach github)")
|
|
637
|
-
row("chrome running", chrome, "" if chrome else "start chrome/edge and rerun `browser-harness --setup`")
|
|
638
|
-
row("daemon alive", daemon, "" if daemon else "run `browser-harness --setup` to attach")
|
|
639
|
-
row("active browser connections", bool(connections), str(len(connections)))
|
|
640
|
-
for conn in connections:
|
|
641
|
-
page = conn.get("page")
|
|
642
|
-
if page:
|
|
643
|
-
title = _doctor_short_text(page["title"])
|
|
644
|
-
url = _doctor_short_text(page["url"])
|
|
645
|
-
print(f" {conn['name']} — active page: {title} — {url}")
|
|
646
|
-
else:
|
|
647
|
-
print(f" {conn['name']} — active page: (no real page)")
|
|
648
|
-
row("profile-use installed", profile_use, "" if profile_use else "optional: curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
649
|
-
row("BROWSER_USE_API_KEY set", api_key, "" if api_key else "optional: needed only for cloud browsers / profile sync")
|
|
650
|
-
# Core health = chrome + daemon. Profile-use/api-key are optional.
|
|
651
|
-
return 0 if (chrome and daemon) else 1
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
def _prompt_yes(question, default_yes=True, yes=False):
|
|
655
|
-
if yes:
|
|
656
|
-
return True
|
|
657
|
-
suffix = "[Y/n]" if default_yes else "[y/N]"
|
|
658
|
-
try:
|
|
659
|
-
ans = input(f"{question} {suffix} ").strip().lower()
|
|
660
|
-
except EOFError:
|
|
661
|
-
return default_yes
|
|
662
|
-
if not ans:
|
|
663
|
-
return default_yes
|
|
664
|
-
return ans.startswith("y")
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
def run_update(yes=False):
|
|
668
|
-
"""Pull the latest version and (after prompt) restart the daemon so it picks up changed code.
|
|
669
|
-
|
|
670
|
-
Exit 0 on success, non-zero on failure."""
|
|
671
|
-
import subprocess, sys
|
|
672
|
-
cur, latest, newer = check_for_update()
|
|
673
|
-
# Only short-circuit as "up to date" when we actually know the installed
|
|
674
|
-
# version. Otherwise `newer=False` just means "couldn't compare" — proceed.
|
|
675
|
-
if cur and latest and not newer:
|
|
676
|
-
print(f"browser-harness is up to date ({cur}).")
|
|
677
|
-
return 0
|
|
678
|
-
if cur and latest:
|
|
679
|
-
print(f"updating browser-harness: {cur} -> {latest}")
|
|
680
|
-
elif latest:
|
|
681
|
-
print(f"installed version unknown; will try to update to {latest}.")
|
|
682
|
-
else:
|
|
683
|
-
print("could not reach github; will try to update anyway.")
|
|
684
|
-
|
|
685
|
-
mode = _install_mode()
|
|
686
|
-
if mode == "git":
|
|
687
|
-
repo = _repo_dir()
|
|
688
|
-
status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], capture_output=True, text=True)
|
|
689
|
-
if status.returncode != 0:
|
|
690
|
-
print(f"git status failed: {status.stderr.strip()}", file=sys.stderr)
|
|
691
|
-
return 1
|
|
692
|
-
if status.stdout.strip():
|
|
693
|
-
print(f"refusing to update: uncommitted changes in {repo}", file=sys.stderr)
|
|
694
|
-
print("commit or stash them first, or run `git -C %s pull` yourself." % repo, file=sys.stderr)
|
|
695
|
-
return 1
|
|
696
|
-
r = subprocess.run(["git", "-C", str(repo), "pull", "--ff-only"])
|
|
697
|
-
if r.returncode != 0:
|
|
698
|
-
return r.returncode
|
|
699
|
-
elif mode == "pypi":
|
|
700
|
-
tool_upgrade = subprocess.run(["uv", "tool", "upgrade", "browser-harness"])
|
|
701
|
-
if tool_upgrade.returncode != 0:
|
|
702
|
-
# Fall back to pip in case this wasn't a `uv tool install`.
|
|
703
|
-
pip = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "browser-harness"])
|
|
704
|
-
if pip.returncode != 0:
|
|
705
|
-
return pip.returncode
|
|
706
|
-
else:
|
|
707
|
-
print("unknown install mode; can't auto-update.", file=sys.stderr)
|
|
708
|
-
return 1
|
|
709
|
-
|
|
710
|
-
# Invalidate banner/tag cache so the new version doesn't keep nagging.
|
|
711
|
-
cache = _cache_read()
|
|
712
|
-
cache.pop("banner_shown_on", None)
|
|
713
|
-
_cache_write(cache)
|
|
714
|
-
|
|
715
|
-
if daemon_alive():
|
|
716
|
-
if _prompt_yes("restart the running daemon so it picks up the new code?", default_yes=True, yes=yes):
|
|
717
|
-
restart_daemon()
|
|
718
|
-
print("daemon stopped; it will auto-restart on next `browser-harness` call.")
|
|
719
|
-
else:
|
|
720
|
-
print("daemon left running on old code. run `browser-harness` and it'll use the new code after the daemon recycles.")
|
|
721
|
-
print("update complete.")
|
|
722
|
-
return 0
|
|
1
|
+
"""Browser Harness admin commands and remote browser lifecycle.
|
|
2
|
+
|
|
3
|
+
[WHO]: Provides daemon setup/doctor/reload/update helpers and Browser Use cloud browser/profile functions
|
|
4
|
+
[FROM]: Depends on browser_harness._ipc, urllib, subprocess, socket, tempfile, pathlib, and environment variables
|
|
5
|
+
[TO]: Consumed by browser_harness.run and user snippets through pre-imported admin helpers
|
|
6
|
+
[HERE]: extensions/builtin/browser/src/browser_harness/admin.py within vendored Browser Harness package
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import _ipc as ipc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_env():
|
|
21
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
22
|
+
workspace = Path(os.environ.get("BH_AGENT_WORKSPACE", repo_root / "agent-workspace")).expanduser()
|
|
23
|
+
for p in (repo_root / ".env", workspace / ".env"):
|
|
24
|
+
if not p.exists():
|
|
25
|
+
continue
|
|
26
|
+
_load_env_file(p)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_env_file(p):
|
|
30
|
+
for line in p.read_text().splitlines():
|
|
31
|
+
line = line.strip()
|
|
32
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
33
|
+
continue
|
|
34
|
+
k, v = line.split("=", 1)
|
|
35
|
+
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_load_env()
|
|
39
|
+
|
|
40
|
+
NAME = os.environ.get("BU_NAME", "default")
|
|
41
|
+
BU_API = "https://api.browser-use.com/api/v3"
|
|
42
|
+
GH_RELEASES = "https://api.github.com/repos/browser-use/browser-harness/releases/latest"
|
|
43
|
+
VERSION_CACHE = Path(tempfile.gettempdir()) / "bu-version-cache.json"
|
|
44
|
+
VERSION_CACHE_TTL = 24 * 3600
|
|
45
|
+
DOCTOR_TEXT_LIMIT = 140
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _log_tail(name):
|
|
49
|
+
try:
|
|
50
|
+
return ipc.log_path(name or NAME).read_text().strip().splitlines()[-1]
|
|
51
|
+
except (FileNotFoundError, IndexError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _needs_chrome_remote_debugging_prompt(msg):
|
|
56
|
+
"""True when Chrome needs the inspect-page permission/profile flow."""
|
|
57
|
+
lower = (msg or "").lower()
|
|
58
|
+
return (
|
|
59
|
+
"devtoolsactiveport not found" in lower
|
|
60
|
+
or "enable chrome://inspect" in lower
|
|
61
|
+
or "not live yet" in lower
|
|
62
|
+
or (
|
|
63
|
+
"ws handshake failed" in lower
|
|
64
|
+
and (
|
|
65
|
+
"403" in lower
|
|
66
|
+
or "opening handshake" in lower
|
|
67
|
+
or "timed out" in lower
|
|
68
|
+
or "timeout" in lower
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_local_chrome_mode(env=None):
|
|
75
|
+
"""True when the daemon discovers a local Chrome instead of a remote CDP WS."""
|
|
76
|
+
return not (env or {}).get("BU_CDP_WS") and not os.environ.get("BU_CDP_WS")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def daemon_alive(name=None):
|
|
80
|
+
try:
|
|
81
|
+
c = ipc.connect(name or NAME, timeout=1.0); c.close(); return True
|
|
82
|
+
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError):
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _daemon_endpoint_names():
|
|
87
|
+
# BH_TMP_DIR isolates one daemon per dir → no filename-prefix discovery,
|
|
88
|
+
# just check whether our local endpoint exists. Without BH_TMP_DIR, _TMP
|
|
89
|
+
# is the shared default (`/tmp` etc.) and we glob `bu-*.<suffix>` to find
|
|
90
|
+
# every daemon on the machine.
|
|
91
|
+
suffix = ".port" if ipc.IS_WINDOWS else ".sock"
|
|
92
|
+
if ipc.BH_TMP_DIR:
|
|
93
|
+
return [NAME] if (ipc._TMP / f"bu{suffix}").exists() else []
|
|
94
|
+
names = []
|
|
95
|
+
for p in sorted(ipc._TMP.glob(f"bu-*{suffix}")):
|
|
96
|
+
raw = p.name[3:-len(suffix)]
|
|
97
|
+
try:
|
|
98
|
+
ipc._check(raw)
|
|
99
|
+
except ValueError:
|
|
100
|
+
continue
|
|
101
|
+
names.append(raw)
|
|
102
|
+
return names
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _daemon_browser_connection(name):
|
|
106
|
+
c = None
|
|
107
|
+
try:
|
|
108
|
+
c = ipc.connect(name, timeout=1.0)
|
|
109
|
+
c.sendall(b'{"meta":"connection_status"}\n')
|
|
110
|
+
data = b""
|
|
111
|
+
while not data.endswith(b"\n"):
|
|
112
|
+
chunk = c.recv(1 << 16)
|
|
113
|
+
if not chunk:
|
|
114
|
+
break
|
|
115
|
+
data += chunk
|
|
116
|
+
response = json.loads(data)
|
|
117
|
+
if "error" in response:
|
|
118
|
+
return None
|
|
119
|
+
page = response.get("page")
|
|
120
|
+
if page:
|
|
121
|
+
page = {"title": page.get("title") or "(untitled)", "url": page.get("url") or ""}
|
|
122
|
+
return {"name": name, "page": page}
|
|
123
|
+
except (FileNotFoundError, ConnectionRefusedError, TimeoutError, socket.timeout, OSError, KeyError, ValueError, json.JSONDecodeError):
|
|
124
|
+
return None
|
|
125
|
+
finally:
|
|
126
|
+
if c:
|
|
127
|
+
c.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def browser_connections():
|
|
131
|
+
"""Live browser-harness daemons with healthy CDP browser connections and their attached page."""
|
|
132
|
+
out = []
|
|
133
|
+
for name in _daemon_endpoint_names():
|
|
134
|
+
conn = _daemon_browser_connection(name)
|
|
135
|
+
if conn:
|
|
136
|
+
out.append(conn)
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def active_browser_connections():
|
|
141
|
+
"""Count live browser-harness daemons with a healthy CDP browser connection."""
|
|
142
|
+
return len(browser_connections())
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _doctor_short_text(value, limit=None):
|
|
146
|
+
limit = limit or DOCTOR_TEXT_LIMIT
|
|
147
|
+
value = str(value)
|
|
148
|
+
return value if len(value) <= limit else value[:limit - 3] + "..."
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def ensure_daemon(wait=60.0, name=None, env=None, _open_inspect=True):
|
|
152
|
+
"""Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect."""
|
|
153
|
+
if daemon_alive(name):
|
|
154
|
+
# Stale daemons accept connects AND reply to meta:* (pure Python) even when the
|
|
155
|
+
# CDP WS to Chrome is dead — probe with a real CDP call and require "result".
|
|
156
|
+
# Must go through ipc.connect so this works on Windows (TCP loopback) too;
|
|
157
|
+
# raw AF_UNIX here would fail on every warm call and churn the daemon.
|
|
158
|
+
try:
|
|
159
|
+
s = ipc.connect(name or NAME, timeout=3.0)
|
|
160
|
+
s.sendall(b'{"method":"Target.getTargets","params":{}}\n')
|
|
161
|
+
data = b""
|
|
162
|
+
while not data.endswith(b"\n"):
|
|
163
|
+
chunk = s.recv(1 << 16)
|
|
164
|
+
if not chunk: break
|
|
165
|
+
data += chunk
|
|
166
|
+
if b'"result"' in data: return
|
|
167
|
+
except Exception: pass
|
|
168
|
+
restart_daemon(name)
|
|
169
|
+
|
|
170
|
+
import subprocess, sys
|
|
171
|
+
local = _is_local_chrome_mode(env)
|
|
172
|
+
for attempt in (0, 1):
|
|
173
|
+
e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
|
|
174
|
+
p = subprocess.Popen(
|
|
175
|
+
[sys.executable, "-m", "browser_harness.daemon"],
|
|
176
|
+
env=e, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **ipc.spawn_kwargs(),
|
|
177
|
+
)
|
|
178
|
+
deadline = time.time() + wait
|
|
179
|
+
while time.time() < deadline:
|
|
180
|
+
if daemon_alive(name): return
|
|
181
|
+
if p.poll() is not None: break
|
|
182
|
+
time.sleep(0.2)
|
|
183
|
+
msg = _log_tail(name) or ""
|
|
184
|
+
if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg):
|
|
185
|
+
if _open_inspect:
|
|
186
|
+
_open_chrome_inspect()
|
|
187
|
+
print("browser-harness: click Allow on chrome://inspect (and tick the checkbox if shown)", file=sys.stderr)
|
|
188
|
+
restart_daemon(name)
|
|
189
|
+
continue
|
|
190
|
+
raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check {ipc.log_path(name or NAME)}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def stop_remote_daemon(name="remote"):
|
|
194
|
+
"""Stop a remote daemon and its backing Browser Use cloud browser.
|
|
195
|
+
|
|
196
|
+
Triggers the daemon's clean shutdown, which PATCHes
|
|
197
|
+
/browsers/{id} {"action":"stop"} so billing ends and any profile
|
|
198
|
+
state in the session is persisted."""
|
|
199
|
+
# restart_daemon is misnamed — it only stops the daemon (sends
|
|
200
|
+
# shutdown, SIGTERMs if needed, unlinks socket+pid). It never
|
|
201
|
+
# restarts anything on its own; a follow-up `browser-harness`
|
|
202
|
+
# call would auto-spawn a fresh one via ensure_daemon(). That
|
|
203
|
+
# "run-it-again-to-restart" workflow is why it was named that way.
|
|
204
|
+
restart_daemon(name)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def restart_daemon(name=None):
|
|
208
|
+
"""Best-effort daemon shutdown + socket/pid cleanup.
|
|
209
|
+
|
|
210
|
+
Name is historical: callers typically follow this with another
|
|
211
|
+
`browser-harness` invocation, which auto-spawns a fresh daemon via
|
|
212
|
+
ensure_daemon(). The function itself only stops."""
|
|
213
|
+
import signal
|
|
214
|
+
|
|
215
|
+
pid_path = str(ipc.pid_path(name or NAME))
|
|
216
|
+
try:
|
|
217
|
+
c = ipc.connect(name or NAME, timeout=5.0)
|
|
218
|
+
c.sendall(b'{"meta":"shutdown"}\n')
|
|
219
|
+
c.recv(1024)
|
|
220
|
+
c.close()
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
try:
|
|
224
|
+
pid = int(open(pid_path).read())
|
|
225
|
+
except (FileNotFoundError, ValueError):
|
|
226
|
+
pid = None
|
|
227
|
+
if pid:
|
|
228
|
+
for _ in range(75):
|
|
229
|
+
try:
|
|
230
|
+
os.kill(pid, 0)
|
|
231
|
+
time.sleep(0.2)
|
|
232
|
+
except (ProcessLookupError, OSError, SystemError):
|
|
233
|
+
break
|
|
234
|
+
else:
|
|
235
|
+
try:
|
|
236
|
+
os.kill(pid, signal.SIGTERM)
|
|
237
|
+
except (ProcessLookupError, OSError, SystemError):
|
|
238
|
+
pass
|
|
239
|
+
ipc.cleanup_endpoint(name or NAME)
|
|
240
|
+
try:
|
|
241
|
+
os.unlink(pid_path)
|
|
242
|
+
except FileNotFoundError:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _browser_use(path, method, body=None):
|
|
247
|
+
key = os.environ.get("BROWSER_USE_API_KEY")
|
|
248
|
+
if not key:
|
|
249
|
+
raise RuntimeError("BROWSER_USE_API_KEY missing -- see .env.example")
|
|
250
|
+
req = urllib.request.Request(
|
|
251
|
+
f"{BU_API}{path}",
|
|
252
|
+
method=method,
|
|
253
|
+
data=(json.dumps(body).encode() if body is not None else None),
|
|
254
|
+
headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
|
|
255
|
+
)
|
|
256
|
+
return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _stop_cloud_browser(browser_id):
|
|
260
|
+
if not browser_id:
|
|
261
|
+
return
|
|
262
|
+
try:
|
|
263
|
+
_browser_use(f"/browsers/{browser_id}", "PATCH", {"action": "stop"})
|
|
264
|
+
except BaseException:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _cdp_ws_from_url(cdp_url):
|
|
269
|
+
return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _has_local_gui():
|
|
273
|
+
"""True when this machine plausibly has a browser we can open. False on headless servers."""
|
|
274
|
+
import platform
|
|
275
|
+
system = platform.system()
|
|
276
|
+
if system in ("Darwin", "Windows"):
|
|
277
|
+
return True
|
|
278
|
+
if system == "Linux":
|
|
279
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _show_live_url(url):
|
|
284
|
+
"""Print liveUrl and auto-open it locally if there's a GUI."""
|
|
285
|
+
import sys, webbrowser
|
|
286
|
+
if not url: return
|
|
287
|
+
print(url)
|
|
288
|
+
if not _has_local_gui():
|
|
289
|
+
print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
|
|
290
|
+
return
|
|
291
|
+
try:
|
|
292
|
+
webbrowser.open(url, new=2)
|
|
293
|
+
print("(opened liveUrl in your default browser)", file=sys.stderr)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def list_cloud_profiles():
|
|
299
|
+
"""List cloud profiles under the current API key.
|
|
300
|
+
|
|
301
|
+
Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
|
|
302
|
+
is the array of domain strings the cloud profile has cookies for — use
|
|
303
|
+
`len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
|
|
304
|
+
detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
|
|
305
|
+
|
|
306
|
+
Paginates through all pages — the API caps `pageSize` at 100."""
|
|
307
|
+
out, page = [], 1
|
|
308
|
+
while True:
|
|
309
|
+
listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
|
|
310
|
+
items = listing.get("items") if isinstance(listing, dict) else listing
|
|
311
|
+
if not items:
|
|
312
|
+
break
|
|
313
|
+
for p in items:
|
|
314
|
+
detail = _browser_use(f"/profiles/{p['id']}", "GET")
|
|
315
|
+
out.append({
|
|
316
|
+
"id": detail["id"],
|
|
317
|
+
"name": detail.get("name"),
|
|
318
|
+
"userId": detail.get("userId"),
|
|
319
|
+
"cookieDomains": detail.get("cookieDomains") or [],
|
|
320
|
+
"lastUsedAt": detail.get("lastUsedAt"),
|
|
321
|
+
})
|
|
322
|
+
if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
|
|
323
|
+
break
|
|
324
|
+
page += 1
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _resolve_profile_name(profile_name):
|
|
329
|
+
"""Find a single cloud profile by exact name; raise if 0 or >1 match."""
|
|
330
|
+
matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
|
|
331
|
+
if not matches:
|
|
332
|
+
raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
|
|
333
|
+
if len(matches) > 1:
|
|
334
|
+
raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
|
|
335
|
+
return matches[0]["id"]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
|
|
339
|
+
"""Provision a Browser Use cloud browser and start a daemon attached to it.
|
|
340
|
+
|
|
341
|
+
kwargs forwarded to `POST /browsers` (camelCase):
|
|
342
|
+
profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
|
|
343
|
+
profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
|
|
344
|
+
proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
|
|
345
|
+
timeout — minutes, 1..240.
|
|
346
|
+
customProxy — {host, port, username, password, ignoreCertErrors}.
|
|
347
|
+
browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
|
|
348
|
+
|
|
349
|
+
Returns the full browser dict including `liveUrl`. Prints the liveUrl and
|
|
350
|
+
auto-opens it locally when a GUI is detected, so the user can watch along."""
|
|
351
|
+
if daemon_alive(name):
|
|
352
|
+
raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
|
|
353
|
+
if profileName:
|
|
354
|
+
if "profileId" in create_kwargs:
|
|
355
|
+
raise RuntimeError("pass profileName OR profileId, not both")
|
|
356
|
+
create_kwargs["profileId"] = _resolve_profile_name(profileName)
|
|
357
|
+
browser = _browser_use("/browsers", "POST", create_kwargs)
|
|
358
|
+
try:
|
|
359
|
+
ensure_daemon(
|
|
360
|
+
name=name,
|
|
361
|
+
env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
|
|
362
|
+
)
|
|
363
|
+
except BaseException:
|
|
364
|
+
_stop_cloud_browser(browser.get("id"))
|
|
365
|
+
raise
|
|
366
|
+
_show_live_url(browser.get("liveUrl"))
|
|
367
|
+
return browser
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def list_local_profiles():
|
|
371
|
+
"""Detected local browser profiles on this machine. Shells out to `profile-use list --json`.
|
|
372
|
+
Returns [{BrowserName, BrowserPath, ProfileName, ProfilePath, DisplayName}, ...].
|
|
373
|
+
Requires `profile-use` (see interaction-skills/profile-sync.md for install)."""
|
|
374
|
+
import json, shutil, subprocess
|
|
375
|
+
if not shutil.which("profile-use"):
|
|
376
|
+
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
377
|
+
return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
|
|
381
|
+
include_domains=None, exclude_domains=None):
|
|
382
|
+
"""Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
|
|
383
|
+
|
|
384
|
+
Shells out to `profile-use sync` (v1.0.4+). Requires BROWSER_USE_API_KEY and the
|
|
385
|
+
target local Chrome profile to be closed (profile-use needs an exclusive lock on
|
|
386
|
+
the Cookies DB).
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
profile_name: local Chrome profile name (as shown by `list_local_profiles`).
|
|
390
|
+
browser: disambiguate when multiple browsers have profiles of the
|
|
391
|
+
same name (e.g. "Google Chrome"). Default: any match.
|
|
392
|
+
cloud_profile_id: push cookies into this existing cloud profile instead of
|
|
393
|
+
creating a new one. Idempotent — call again to refresh
|
|
394
|
+
the same profile. Default: create new.
|
|
395
|
+
include_domains: only sync cookies for these domains (and subdomains).
|
|
396
|
+
Leading dot is optional. Example: ["google.com", "stripe.com"].
|
|
397
|
+
exclude_domains: drop cookies for these domains (and subdomains). Applied
|
|
398
|
+
before `include_domains` so exclude wins on overlap."""
|
|
399
|
+
import os, re, shutil, subprocess, sys
|
|
400
|
+
if not shutil.which("profile-use"):
|
|
401
|
+
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
402
|
+
if not os.environ.get("BROWSER_USE_API_KEY"):
|
|
403
|
+
raise RuntimeError("BROWSER_USE_API_KEY missing")
|
|
404
|
+
cmd = ["profile-use", "sync", "--profile", profile_name]
|
|
405
|
+
if browser:
|
|
406
|
+
cmd += ["--browser", browser]
|
|
407
|
+
if cloud_profile_id:
|
|
408
|
+
cmd += ["--cloud-profile-id", cloud_profile_id]
|
|
409
|
+
for d in include_domains or []:
|
|
410
|
+
cmd += ["--domain", d]
|
|
411
|
+
for d in exclude_domains or []:
|
|
412
|
+
cmd += ["--exclude-domain", d]
|
|
413
|
+
r = subprocess.run(cmd, text=True, capture_output=True)
|
|
414
|
+
sys.stdout.write(r.stdout)
|
|
415
|
+
sys.stderr.write(r.stderr)
|
|
416
|
+
if r.returncode != 0:
|
|
417
|
+
raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
|
|
418
|
+
# With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
|
|
419
|
+
# instead of "Profile created: <uuid>", so we already know the UUID.
|
|
420
|
+
if cloud_profile_id:
|
|
421
|
+
return cloud_profile_id
|
|
422
|
+
m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
|
|
423
|
+
if not m:
|
|
424
|
+
raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
|
|
425
|
+
return m.group(1)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _version():
|
|
429
|
+
"""Installed version of the browser-harness package. Empty string if unknown."""
|
|
430
|
+
try:
|
|
431
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
432
|
+
try:
|
|
433
|
+
return version("browser-harness")
|
|
434
|
+
except PackageNotFoundError:
|
|
435
|
+
return ""
|
|
436
|
+
except Exception:
|
|
437
|
+
return ""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _repo_dir():
|
|
441
|
+
"""Return the repo root if this install is an editable git clone, else None."""
|
|
442
|
+
for p in Path(__file__).resolve().parents:
|
|
443
|
+
if (p / ".git").is_dir():
|
|
444
|
+
return p
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _install_mode():
|
|
449
|
+
""""git" for editable clone, "pypi" for an installed wheel, "unknown" otherwise."""
|
|
450
|
+
if _repo_dir():
|
|
451
|
+
return "git"
|
|
452
|
+
return "pypi" if _version() else "unknown"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _cache_read():
|
|
456
|
+
try:
|
|
457
|
+
return json.loads(VERSION_CACHE.read_text())
|
|
458
|
+
except (FileNotFoundError, ValueError):
|
|
459
|
+
return {}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _cache_write(data):
|
|
463
|
+
try:
|
|
464
|
+
VERSION_CACHE.write_text(json.dumps(data))
|
|
465
|
+
except OSError:
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _latest_release_tag(force=False):
|
|
470
|
+
"""Return latest release tag from GitHub, or None. Cached for 24h to avoid hammering the API."""
|
|
471
|
+
cache = _cache_read()
|
|
472
|
+
now = time.time()
|
|
473
|
+
if not force and cache.get("tag") and now - cache.get("fetched_at", 0) < VERSION_CACHE_TTL:
|
|
474
|
+
return cache["tag"]
|
|
475
|
+
try:
|
|
476
|
+
req = urllib.request.Request(GH_RELEASES, headers={"Accept": "application/vnd.github+json"})
|
|
477
|
+
tag = json.loads(urllib.request.urlopen(req, timeout=5).read()).get("tag_name") or ""
|
|
478
|
+
except Exception:
|
|
479
|
+
return cache.get("tag") # fall back to last known
|
|
480
|
+
tag = tag.lstrip("v")
|
|
481
|
+
_cache_write({**cache, "tag": tag, "fetched_at": now})
|
|
482
|
+
return tag or None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _version_tuple(v):
|
|
486
|
+
"""Best-effort semver parse. Non-numeric components sort as 0, so pre-releases may not rank perfectly."""
|
|
487
|
+
parts = []
|
|
488
|
+
for s in (v or "").split("."):
|
|
489
|
+
m = ""
|
|
490
|
+
for ch in s:
|
|
491
|
+
if ch.isdigit():
|
|
492
|
+
m += ch
|
|
493
|
+
else:
|
|
494
|
+
break
|
|
495
|
+
parts.append(int(m) if m else 0)
|
|
496
|
+
return tuple(parts)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def check_for_update():
|
|
500
|
+
"""(current, latest, newer_available). latest may be None if the API was unreachable and no cache exists."""
|
|
501
|
+
cur = _version()
|
|
502
|
+
latest = _latest_release_tag()
|
|
503
|
+
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
504
|
+
return cur, latest, newer
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def print_update_banner(out=None):
|
|
508
|
+
"""Print the update banner to stderr once per day. Silent when up-to-date or offline."""
|
|
509
|
+
import sys
|
|
510
|
+
out = out or sys.stderr
|
|
511
|
+
cache = _cache_read()
|
|
512
|
+
today = time.strftime("%Y-%m-%d")
|
|
513
|
+
if cache.get("banner_shown_on") == today:
|
|
514
|
+
return
|
|
515
|
+
cur, latest, newer = check_for_update()
|
|
516
|
+
if not newer:
|
|
517
|
+
return
|
|
518
|
+
print(f"[browser-harness] update available: {cur} -> {latest}", file=out)
|
|
519
|
+
print(f"[browser-harness] agents: run `browser-harness --update -y` to upgrade and restart the daemon", file=out)
|
|
520
|
+
_cache_write({**cache, "banner_shown_on": today})
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _chrome_running():
|
|
524
|
+
"""Cross-platform best-effort check for a running Chrome/Edge process."""
|
|
525
|
+
import platform, subprocess
|
|
526
|
+
system = platform.system()
|
|
527
|
+
try:
|
|
528
|
+
if system == "Windows":
|
|
529
|
+
out = subprocess.check_output(["tasklist"], text=True, timeout=5)
|
|
530
|
+
names = ("chrome.exe", "msedge.exe")
|
|
531
|
+
else:
|
|
532
|
+
out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, timeout=5)
|
|
533
|
+
names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge")
|
|
534
|
+
return any(n.lower() in out.lower() for n in names)
|
|
535
|
+
except Exception:
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _open_chrome_inspect():
|
|
540
|
+
"""Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
|
|
541
|
+
import platform, subprocess, webbrowser
|
|
542
|
+
url = "chrome://inspect/#remote-debugging"
|
|
543
|
+
if platform.system() == "Darwin":
|
|
544
|
+
try:
|
|
545
|
+
subprocess.run([
|
|
546
|
+
"osascript",
|
|
547
|
+
"-e", 'tell application "Google Chrome" to activate',
|
|
548
|
+
"-e", f'tell application "Google Chrome" to open location "{url}"',
|
|
549
|
+
], timeout=5, check=False)
|
|
550
|
+
return
|
|
551
|
+
except Exception:
|
|
552
|
+
pass
|
|
553
|
+
try:
|
|
554
|
+
webbrowser.open(url, new=2)
|
|
555
|
+
except Exception:
|
|
556
|
+
pass
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def run_setup():
|
|
560
|
+
"""Interactive bootstrap: attach to the running browser, guiding the user through chrome://inspect if needed.
|
|
561
|
+
|
|
562
|
+
Exit code 0 on success, 1 on failure."""
|
|
563
|
+
import sys
|
|
564
|
+
print("browser-harness setup: attaching to your browser...")
|
|
565
|
+
|
|
566
|
+
if daemon_alive():
|
|
567
|
+
print("daemon already running; nothing to do.")
|
|
568
|
+
return 0
|
|
569
|
+
|
|
570
|
+
if not _chrome_running():
|
|
571
|
+
print("no Chrome/Edge process detected. please start your browser and rerun `browser-harness --setup`.")
|
|
572
|
+
return 1
|
|
573
|
+
|
|
574
|
+
# First attach attempt.
|
|
575
|
+
try:
|
|
576
|
+
ensure_daemon(wait=20.0)
|
|
577
|
+
print("daemon is up.")
|
|
578
|
+
return 0
|
|
579
|
+
except RuntimeError as e:
|
|
580
|
+
first_err = str(e)
|
|
581
|
+
|
|
582
|
+
needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err)
|
|
583
|
+
if needs_inspect:
|
|
584
|
+
print("chrome remote-debugging is not enabled on the current profile.")
|
|
585
|
+
print("opening chrome://inspect/#remote-debugging -- in the tab that opens:")
|
|
586
|
+
print(" 1. if chrome shows the profile picker, pick your normal profile;")
|
|
587
|
+
print(" 2. tick 'Discover network targets' and click Allow if prompted.")
|
|
588
|
+
_open_chrome_inspect()
|
|
589
|
+
else:
|
|
590
|
+
print(f"attach failed: {first_err}")
|
|
591
|
+
print("retrying for up to 60s (chrome may still be starting up)...")
|
|
592
|
+
|
|
593
|
+
deadline = time.time() + 60
|
|
594
|
+
last = first_err
|
|
595
|
+
while time.time() < deadline:
|
|
596
|
+
try:
|
|
597
|
+
ensure_daemon(wait=5.0, _open_inspect=False)
|
|
598
|
+
print("daemon is up.")
|
|
599
|
+
return 0
|
|
600
|
+
except RuntimeError as e:
|
|
601
|
+
last = str(e)
|
|
602
|
+
time.sleep(2)
|
|
603
|
+
|
|
604
|
+
print(f"setup failed: {last}", file=sys.stderr)
|
|
605
|
+
print("run `browser-harness --doctor` for diagnostics.", file=sys.stderr)
|
|
606
|
+
return 1
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def run_doctor():
|
|
610
|
+
"""Read-only diagnostics. Exit 0 iff everything looks healthy."""
|
|
611
|
+
import platform, shutil, sys
|
|
612
|
+
cur = _version()
|
|
613
|
+
mode = _install_mode()
|
|
614
|
+
chrome = _chrome_running()
|
|
615
|
+
daemon = daemon_alive()
|
|
616
|
+
connections = browser_connections()
|
|
617
|
+
profile_use = shutil.which("profile-use") is not None
|
|
618
|
+
api_key = bool(os.environ.get("BROWSER_USE_API_KEY"))
|
|
619
|
+
latest = _latest_release_tag()
|
|
620
|
+
# Only claim an update when we know the installed version — `cur or "(unknown)"`
|
|
621
|
+
# for display would otherwise be parsed as (0,) and flag every latest as newer.
|
|
622
|
+
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
623
|
+
cur_display = cur or "(unknown)"
|
|
624
|
+
|
|
625
|
+
def row(label, ok, detail=""):
|
|
626
|
+
mark = "ok " if ok else "FAIL"
|
|
627
|
+
print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
|
|
628
|
+
|
|
629
|
+
print("browser-harness doctor")
|
|
630
|
+
print(f" platform {platform.system()} {platform.release()}")
|
|
631
|
+
print(f" python {sys.version.split()[0]}")
|
|
632
|
+
print(f" version {cur_display} ({mode})")
|
|
633
|
+
if latest:
|
|
634
|
+
print(f" latest release {latest}" + (" (update available)" if newer else ""))
|
|
635
|
+
else:
|
|
636
|
+
print(" latest release (could not reach github)")
|
|
637
|
+
row("chrome running", chrome, "" if chrome else "start chrome/edge and rerun `browser-harness --setup`")
|
|
638
|
+
row("daemon alive", daemon, "" if daemon else "run `browser-harness --setup` to attach")
|
|
639
|
+
row("active browser connections", bool(connections), str(len(connections)))
|
|
640
|
+
for conn in connections:
|
|
641
|
+
page = conn.get("page")
|
|
642
|
+
if page:
|
|
643
|
+
title = _doctor_short_text(page["title"])
|
|
644
|
+
url = _doctor_short_text(page["url"])
|
|
645
|
+
print(f" {conn['name']} — active page: {title} — {url}")
|
|
646
|
+
else:
|
|
647
|
+
print(f" {conn['name']} — active page: (no real page)")
|
|
648
|
+
row("profile-use installed", profile_use, "" if profile_use else "optional: curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
649
|
+
row("BROWSER_USE_API_KEY set", api_key, "" if api_key else "optional: needed only for cloud browsers / profile sync")
|
|
650
|
+
# Core health = chrome + daemon. Profile-use/api-key are optional.
|
|
651
|
+
return 0 if (chrome and daemon) else 1
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _prompt_yes(question, default_yes=True, yes=False):
|
|
655
|
+
if yes:
|
|
656
|
+
return True
|
|
657
|
+
suffix = "[Y/n]" if default_yes else "[y/N]"
|
|
658
|
+
try:
|
|
659
|
+
ans = input(f"{question} {suffix} ").strip().lower()
|
|
660
|
+
except EOFError:
|
|
661
|
+
return default_yes
|
|
662
|
+
if not ans:
|
|
663
|
+
return default_yes
|
|
664
|
+
return ans.startswith("y")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def run_update(yes=False):
|
|
668
|
+
"""Pull the latest version and (after prompt) restart the daemon so it picks up changed code.
|
|
669
|
+
|
|
670
|
+
Exit 0 on success, non-zero on failure."""
|
|
671
|
+
import subprocess, sys
|
|
672
|
+
cur, latest, newer = check_for_update()
|
|
673
|
+
# Only short-circuit as "up to date" when we actually know the installed
|
|
674
|
+
# version. Otherwise `newer=False` just means "couldn't compare" — proceed.
|
|
675
|
+
if cur and latest and not newer:
|
|
676
|
+
print(f"browser-harness is up to date ({cur}).")
|
|
677
|
+
return 0
|
|
678
|
+
if cur and latest:
|
|
679
|
+
print(f"updating browser-harness: {cur} -> {latest}")
|
|
680
|
+
elif latest:
|
|
681
|
+
print(f"installed version unknown; will try to update to {latest}.")
|
|
682
|
+
else:
|
|
683
|
+
print("could not reach github; will try to update anyway.")
|
|
684
|
+
|
|
685
|
+
mode = _install_mode()
|
|
686
|
+
if mode == "git":
|
|
687
|
+
repo = _repo_dir()
|
|
688
|
+
status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], capture_output=True, text=True)
|
|
689
|
+
if status.returncode != 0:
|
|
690
|
+
print(f"git status failed: {status.stderr.strip()}", file=sys.stderr)
|
|
691
|
+
return 1
|
|
692
|
+
if status.stdout.strip():
|
|
693
|
+
print(f"refusing to update: uncommitted changes in {repo}", file=sys.stderr)
|
|
694
|
+
print("commit or stash them first, or run `git -C %s pull` yourself." % repo, file=sys.stderr)
|
|
695
|
+
return 1
|
|
696
|
+
r = subprocess.run(["git", "-C", str(repo), "pull", "--ff-only"])
|
|
697
|
+
if r.returncode != 0:
|
|
698
|
+
return r.returncode
|
|
699
|
+
elif mode == "pypi":
|
|
700
|
+
tool_upgrade = subprocess.run(["uv", "tool", "upgrade", "browser-harness"])
|
|
701
|
+
if tool_upgrade.returncode != 0:
|
|
702
|
+
# Fall back to pip in case this wasn't a `uv tool install`.
|
|
703
|
+
pip = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "browser-harness"])
|
|
704
|
+
if pip.returncode != 0:
|
|
705
|
+
return pip.returncode
|
|
706
|
+
else:
|
|
707
|
+
print("unknown install mode; can't auto-update.", file=sys.stderr)
|
|
708
|
+
return 1
|
|
709
|
+
|
|
710
|
+
# Invalidate banner/tag cache so the new version doesn't keep nagging.
|
|
711
|
+
cache = _cache_read()
|
|
712
|
+
cache.pop("banner_shown_on", None)
|
|
713
|
+
_cache_write(cache)
|
|
714
|
+
|
|
715
|
+
if daemon_alive():
|
|
716
|
+
if _prompt_yes("restart the running daemon so it picks up the new code?", default_yes=True, yes=yes):
|
|
717
|
+
restart_daemon()
|
|
718
|
+
print("daemon stopped; it will auto-restart on next `browser-harness` call.")
|
|
719
|
+
else:
|
|
720
|
+
print("daemon left running on old code. run `browser-harness` and it'll use the new code after the daemon recycles.")
|
|
721
|
+
print("update complete.")
|
|
722
|
+
return 0
|