browserwright 0.6.2__py3-none-any.whl
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.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Config + env merge.
|
|
2
|
+
|
|
3
|
+
Source of truth ordering (highest precedence first):
|
|
4
|
+
CLI flag > BD_*-prefixed env var > BU_*-prefixed env var (compat) > toml file > defaults
|
|
5
|
+
|
|
6
|
+
Spec §5.1 environment-variable table lives here. The toml shape is intentionally
|
|
7
|
+
flat — pydantic / dynaconf are explicitly rejected (附录 B).
|
|
8
|
+
|
|
9
|
+
The toml file is *optional*. If it doesn't parse, we report a UserError up-stream
|
|
10
|
+
so the CLI can show a clean message instead of a stack trace.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Path-traversal guard for the `launch-chrome --profile` name — sourced from
|
|
22
|
+
# browser-harness _ipc.py:31-33. (No longer used for a daemon instance name;
|
|
23
|
+
# BD_NAME was removed — see docs/refactor-single-daemon.md.)
|
|
24
|
+
_NAME_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z")
|
|
25
|
+
|
|
26
|
+
# Default port for the Playwright-facing CDP facade. Distinct from the
|
|
27
|
+
# extension relay (19989) and playwriter's 19988 so all three can coexist on one
|
|
28
|
+
# machine. Defined here (not facade.py) so config has no import cycle; facade.py
|
|
29
|
+
# re-exports it for backwards compat.
|
|
30
|
+
DEFAULT_FACADE_PORT = 19990
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_name(name: str) -> str:
|
|
34
|
+
"""Path-traversal guard for filesystem-bound names (e.g. `--profile`)."""
|
|
35
|
+
if not _NAME_RE.match(name or ""):
|
|
36
|
+
from .errors import UserError
|
|
37
|
+
|
|
38
|
+
raise UserError(
|
|
39
|
+
f"invalid name {name!r}: must match [A-Za-z0-9_-]{{1,64}}"
|
|
40
|
+
)
|
|
41
|
+
return name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RdpConfig:
|
|
46
|
+
port: int = 9222
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ExtensionConfig:
|
|
51
|
+
"""v0.5.3 REVIEW.md F-5 / Task #24: relay endpoint config.
|
|
52
|
+
|
|
53
|
+
Two ways to override the default `ws://127.0.0.1:19989`:
|
|
54
|
+
- `relay_url`: full URL — most expressive, can change host too
|
|
55
|
+
- `port`: int — common case ("19989 is occupied, use 29989")
|
|
56
|
+
|
|
57
|
+
Precedence (highest first):
|
|
58
|
+
CLI `--extension-port N` > `BD_EXTENSION_PORT` env > toml `port`
|
|
59
|
+
> toml `relay_url` (parsed for port) > `DEFAULT_RELAY_PORT` (19989)
|
|
60
|
+
|
|
61
|
+
`host` follows the same source: explicit `port` knobs preserve the
|
|
62
|
+
existing host (or 127.0.0.1 default), `relay_url` carries both.
|
|
63
|
+
"""
|
|
64
|
+
relay_url: str | None = None
|
|
65
|
+
port: int | None = None
|
|
66
|
+
|
|
67
|
+
def resolved_host_port(self) -> tuple[str, int]:
|
|
68
|
+
"""Single source of truth for "what host:port should the relay
|
|
69
|
+
ws server bind to" — consumers (ExtensionBackend ctor for probing,
|
|
70
|
+
listener.run_serve for binding) call this so they don't each
|
|
71
|
+
re-implement the precedence rules.
|
|
72
|
+
|
|
73
|
+
Returns `(host, port)`. `port` is non-None — we fall back to
|
|
74
|
+
`DEFAULT_RELAY_PORT` from server/relay.py if nothing else is set.
|
|
75
|
+
"""
|
|
76
|
+
# Local import dodges the circular hazard between config and server.
|
|
77
|
+
from .server.relay import DEFAULT_RELAY_PORT
|
|
78
|
+
host = "127.0.0.1"
|
|
79
|
+
port = DEFAULT_RELAY_PORT
|
|
80
|
+
if self.relay_url:
|
|
81
|
+
from urllib.parse import urlparse
|
|
82
|
+
parsed = urlparse(self.relay_url)
|
|
83
|
+
if parsed.hostname:
|
|
84
|
+
host = parsed.hostname
|
|
85
|
+
if parsed.port:
|
|
86
|
+
port = parsed.port
|
|
87
|
+
if self.port is not None:
|
|
88
|
+
port = self.port
|
|
89
|
+
return host, port
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class CloudConfig:
|
|
94
|
+
"""v0.5 cloud backend config.
|
|
95
|
+
|
|
96
|
+
`endpoint` is the upstream URL. Two shapes accepted:
|
|
97
|
+
- `wss://host/path` → used directly as the ws URL
|
|
98
|
+
- `https://host` → daemon HTTP-GETs `/json/version` and reads
|
|
99
|
+
`webSocketDebuggerUrl` (same trick as the `env`
|
|
100
|
+
backend's `BD_CDP_URL` path)
|
|
101
|
+
|
|
102
|
+
`auth_kind` picks one of the registered AuthProvider impls in
|
|
103
|
+
`browserwright.daemon.auth`. The kind-specific config dict is `auth` (raw
|
|
104
|
+
toml subtable, dispatched by `build_auth_provider`).
|
|
105
|
+
|
|
106
|
+
`provider_hint` is purely informational — surfaces in doctor output
|
|
107
|
+
("connected to provider=browser-use, auth=bearer"). The daemon doesn't
|
|
108
|
+
behave differently per provider.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
endpoint: str | None = None
|
|
112
|
+
auth_kind: str | None = None # "bearer" | "basic" | "mtls" | "oauth2"
|
|
113
|
+
auth: dict = field(default_factory=dict) # raw, fed to build_auth_provider
|
|
114
|
+
provider_hint: str | None = None # display name, e.g. "browser-use"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class BackendsConfig:
|
|
119
|
+
rdp: RdpConfig = field(default_factory=RdpConfig)
|
|
120
|
+
cloud: CloudConfig = field(default_factory=CloudConfig)
|
|
121
|
+
extension: ExtensionConfig = field(default_factory=ExtensionConfig)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Config:
|
|
126
|
+
"""Resolved daemon config — flat, immutable after build."""
|
|
127
|
+
|
|
128
|
+
backend: str | None = None # explicit --backend / BD_BACKEND
|
|
129
|
+
timeout: float = 5.0 # per-backend resolve timeout, seconds
|
|
130
|
+
cdp_ws: str | None = None # BD_CDP_WS / BU_CDP_WS — env backend uses this
|
|
131
|
+
cdp_url: str | None = None # BD_CDP_URL / BU_CDP_URL — env backend uses this
|
|
132
|
+
chrome_binary: str | None = None # BD_CHROME_BINARY — launch-chrome
|
|
133
|
+
idle_close_after: float | None = None # seconds; None = never (default)
|
|
134
|
+
# Playwright-facing CDP facade. `serve` binds an additional TCP ws+HTTP
|
|
135
|
+
# endpoint that a real Playwright client can `connect_over_cdp` to.
|
|
136
|
+
#
|
|
137
|
+
# Phase C semantics (auto-enable): the facade is now ON by default — the
|
|
138
|
+
# skill layer's heredoc `page`/`context` depend on it. Tri-state:
|
|
139
|
+
# - None (unset) -> auto-enable on DEFAULT_FACADE_PORT (the new default).
|
|
140
|
+
# - 0 -> explicitly DISABLED.
|
|
141
|
+
# - >0 -> explicit override port (CLI/env/toml).
|
|
142
|
+
# Use `resolved_facade_port()` to collapse this to "bind / don't bind".
|
|
143
|
+
facade_port: int | None = None
|
|
144
|
+
backends: BackendsConfig = field(default_factory=BackendsConfig)
|
|
145
|
+
# Provenance for `env`: which alias key actually fired. Diagnostic-only.
|
|
146
|
+
cdp_ws_source: str | None = None # "BD_CDP_WS" | "BU_CDP_WS" | None
|
|
147
|
+
cdp_url_source: str | None = None # "BD_CDP_URL" | "BU_CDP_URL" | None
|
|
148
|
+
|
|
149
|
+
def resolved_facade_port(self) -> int | None:
|
|
150
|
+
"""Collapse the tri-state ``facade_port`` to a bind decision.
|
|
151
|
+
|
|
152
|
+
Returns the port to bind the Playwright facade on, or ``None`` when the
|
|
153
|
+
facade should NOT be bound:
|
|
154
|
+
- ``facade_port is None`` -> ``DEFAULT_FACADE_PORT`` (auto-enable).
|
|
155
|
+
- ``facade_port == 0`` -> ``None`` (explicitly disabled).
|
|
156
|
+
- ``facade_port > 0`` -> that port (explicit override).
|
|
157
|
+
"""
|
|
158
|
+
if self.facade_port is None:
|
|
159
|
+
return DEFAULT_FACADE_PORT
|
|
160
|
+
if self.facade_port == 0:
|
|
161
|
+
return None
|
|
162
|
+
return self.facade_port
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _read_toml(path: Path) -> dict:
|
|
166
|
+
from .errors import UserError
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return tomllib.loads(path.read_text())
|
|
170
|
+
except FileNotFoundError:
|
|
171
|
+
return {}
|
|
172
|
+
except (tomllib.TOMLDecodeError, OSError) as e:
|
|
173
|
+
raise UserError(f"failed to parse config {path}: {e}") from e
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load(
|
|
177
|
+
cli_backend: str | None = None,
|
|
178
|
+
cli_timeout: float | None = None,
|
|
179
|
+
cli_port: int | None = None,
|
|
180
|
+
cli_chrome_binary: str | None = None,
|
|
181
|
+
cli_config_path: str | None = None,
|
|
182
|
+
cli_extension_port: int | None = None,
|
|
183
|
+
cli_facade_port: int | None = None,
|
|
184
|
+
env: dict[str, str] | None = None,
|
|
185
|
+
) -> Config:
|
|
186
|
+
"""Build a Config from CLI flags + env + optional toml file.
|
|
187
|
+
|
|
188
|
+
`env` defaults to os.environ — injecting a dict lets unit tests pin state.
|
|
189
|
+
"""
|
|
190
|
+
e = os.environ if env is None else env
|
|
191
|
+
|
|
192
|
+
# 1) Optional toml
|
|
193
|
+
cfg_path = cli_config_path or e.get("BD_CONFIG")
|
|
194
|
+
toml: dict = {}
|
|
195
|
+
if cfg_path:
|
|
196
|
+
toml = _read_toml(Path(cfg_path).expanduser())
|
|
197
|
+
|
|
198
|
+
# 2) Build a Config piece by piece, with each level overriding the prior
|
|
199
|
+
cfg = Config()
|
|
200
|
+
|
|
201
|
+
# toml level
|
|
202
|
+
if "timeout" in toml and isinstance(toml["timeout"], (int, float)):
|
|
203
|
+
cfg.timeout = float(toml["timeout"])
|
|
204
|
+
# v0.5.2 Task #14: `default_backend` from config.toml. The README has
|
|
205
|
+
# advertised this key since v0.1 but the parser silently ignored it —
|
|
206
|
+
# users wrote it expecting it to lock the backend and got the auto
|
|
207
|
+
# fallback chain instead. Precedence (highest first):
|
|
208
|
+
# CLI --backend > BD_BACKEND env > toml `default_backend`
|
|
209
|
+
# Backend-name validity is checked at resolve-time (we don't import
|
|
210
|
+
# backends.names at module scope — circular), so an invalid name like
|
|
211
|
+
# `"garbage"` is stored as-is and surfaces at `browserwright-daemon url` as
|
|
212
|
+
# UserError("unknown backend ...").
|
|
213
|
+
# v0.5.3 F-9 #14: type is validated though — `default_backend = 42`
|
|
214
|
+
# (integer) silently dropped before, now raises so the user can fix it.
|
|
215
|
+
if "default_backend" in toml:
|
|
216
|
+
v = toml["default_backend"]
|
|
217
|
+
if not isinstance(v, str):
|
|
218
|
+
from .errors import UserError
|
|
219
|
+
raise UserError(
|
|
220
|
+
f"config.toml `default_backend` must be a string, got "
|
|
221
|
+
f"{type(v).__name__}: {v!r}")
|
|
222
|
+
cfg.backend = v
|
|
223
|
+
backends = toml.get("backends", {}) if isinstance(toml.get("backends"), dict) else {}
|
|
224
|
+
rdp = backends.get("rdp", {}) if isinstance(backends.get("rdp"), dict) else {}
|
|
225
|
+
if "port" in rdp and isinstance(rdp["port"], int):
|
|
226
|
+
cfg.backends.rdp.port = rdp["port"]
|
|
227
|
+
# extension.relay_url substitutes for DEFAULT_RELAY_PORT when set.
|
|
228
|
+
ext = backends.get("extension", {}) if isinstance(backends.get("extension"), dict) else {}
|
|
229
|
+
if isinstance(ext.get("relay_url"), str):
|
|
230
|
+
cfg.backends.extension.relay_url = ext["relay_url"]
|
|
231
|
+
if isinstance(ext.get("port"), int):
|
|
232
|
+
cfg.backends.extension.port = ext["port"]
|
|
233
|
+
cloud = backends.get("cloud", {}) if isinstance(backends.get("cloud"), dict) else {}
|
|
234
|
+
if "endpoint" in cloud and isinstance(cloud["endpoint"], str):
|
|
235
|
+
cfg.backends.cloud.endpoint = cloud["endpoint"]
|
|
236
|
+
if "auth_kind" in cloud and isinstance(cloud["auth_kind"], str):
|
|
237
|
+
cfg.backends.cloud.auth_kind = cloud["auth_kind"]
|
|
238
|
+
if "provider_hint" in cloud and isinstance(cloud["provider_hint"], str):
|
|
239
|
+
cfg.backends.cloud.provider_hint = cloud["provider_hint"]
|
|
240
|
+
# `[backends.cloud.auth.<kind>]` subtables are passed verbatim to
|
|
241
|
+
# `build_auth_provider`. We pick out the subtable matching `auth_kind`
|
|
242
|
+
# so the config-time schema stays per-kind validated downstream.
|
|
243
|
+
auth_subtables = cloud.get("auth", {}) if isinstance(cloud.get("auth"), dict) else {}
|
|
244
|
+
if cfg.backends.cloud.auth_kind and isinstance(
|
|
245
|
+
auth_subtables.get(cfg.backends.cloud.auth_kind), dict):
|
|
246
|
+
cfg.backends.cloud.auth = dict(auth_subtables[cfg.backends.cloud.auth_kind])
|
|
247
|
+
if "idle_close_after" in toml and isinstance(toml["idle_close_after"], (int, float)):
|
|
248
|
+
cfg.idle_close_after = float(toml["idle_close_after"])
|
|
249
|
+
# Playwright facade port (phase A1). toml key `facade_port`.
|
|
250
|
+
if "facade_port" in toml and isinstance(toml["facade_port"], int):
|
|
251
|
+
cfg.facade_port = toml["facade_port"]
|
|
252
|
+
|
|
253
|
+
# env level — BD_* wins over BU_*
|
|
254
|
+
if "BD_TIMEOUT" in e:
|
|
255
|
+
try:
|
|
256
|
+
cfg.timeout = float(e["BD_TIMEOUT"])
|
|
257
|
+
except ValueError:
|
|
258
|
+
from .errors import UserError
|
|
259
|
+
|
|
260
|
+
raise UserError(f"BD_TIMEOUT must be a number, got {e['BD_TIMEOUT']!r}")
|
|
261
|
+
if "BD_BACKEND" in e:
|
|
262
|
+
cfg.backend = e["BD_BACKEND"]
|
|
263
|
+
if "BD_IDLE_CLOSE_AFTER" in e:
|
|
264
|
+
try:
|
|
265
|
+
v = float(e["BD_IDLE_CLOSE_AFTER"])
|
|
266
|
+
cfg.idle_close_after = v if v > 0 else None
|
|
267
|
+
except ValueError:
|
|
268
|
+
from .errors import UserError
|
|
269
|
+
raise UserError(
|
|
270
|
+
f"BD_IDLE_CLOSE_AFTER must be a number, got {e['BD_IDLE_CLOSE_AFTER']!r}")
|
|
271
|
+
if "BD_CDP_WS" in e:
|
|
272
|
+
cfg.cdp_ws = e["BD_CDP_WS"]; cfg.cdp_ws_source = "BD_CDP_WS"
|
|
273
|
+
elif "BU_CDP_WS" in e:
|
|
274
|
+
cfg.cdp_ws = e["BU_CDP_WS"]; cfg.cdp_ws_source = "BU_CDP_WS"
|
|
275
|
+
if "BD_CDP_URL" in e:
|
|
276
|
+
cfg.cdp_url = e["BD_CDP_URL"]; cfg.cdp_url_source = "BD_CDP_URL"
|
|
277
|
+
elif "BU_CDP_URL" in e:
|
|
278
|
+
cfg.cdp_url = e["BU_CDP_URL"]; cfg.cdp_url_source = "BU_CDP_URL"
|
|
279
|
+
if "BD_CHROME_BINARY" in e:
|
|
280
|
+
cfg.chrome_binary = e["BD_CHROME_BINARY"]
|
|
281
|
+
# `BD_RDP_PORT` env override for the rdp backend's port (v0.4.1).
|
|
282
|
+
#
|
|
283
|
+
# Originally (spec §8.2 first cut) we deliberately omitted this env var:
|
|
284
|
+
# the rdp port was config-file or `--port` only, to keep the env namespace
|
|
285
|
+
# small. But the ai-e2e harness convention is "set env, run agent" — and
|
|
286
|
+
# without `BD_RDP_PORT`, callers reach for `BD_BACKEND=rdp` + (nothing),
|
|
287
|
+
# which silently falls through to the hard-coded 9222 default, which on
|
|
288
|
+
# any developer machine **is the user's daily Chrome** (Chrome 144+ auto-
|
|
289
|
+
# enables CDP without a cmdline flag). Connecting to that = Allow popup.
|
|
290
|
+
#
|
|
291
|
+
# Adding the env var makes "lock to my isolated Chrome's port" expressible
|
|
292
|
+
# without a config file. Precedence: CLI `--port` > BD_RDP_PORT > toml >
|
|
293
|
+
# 9222 default (preserves the spec §5.1 ordering rule).
|
|
294
|
+
if "BD_RDP_PORT" in e:
|
|
295
|
+
try:
|
|
296
|
+
cfg.backends.rdp.port = int(e["BD_RDP_PORT"])
|
|
297
|
+
except ValueError:
|
|
298
|
+
from .errors import UserError
|
|
299
|
+
raise UserError(
|
|
300
|
+
f"BD_RDP_PORT must be an integer, got {e['BD_RDP_PORT']!r}")
|
|
301
|
+
elif "BD_PORT" in e:
|
|
302
|
+
# v0.5.3 REVIEW.md F-4c: the v0.4 popup-storm incident's root cause
|
|
303
|
+
# was a typo: `BD_PORT=9444` (intuitive name) didn't bind to anything
|
|
304
|
+
# and the rdp backend defaulted to 9222 = user's daily Chrome. We
|
|
305
|
+
# added `BD_RDP_PORT` but never defended the typo. Now we alias +
|
|
306
|
+
# warn: if user typed BD_PORT and not BD_RDP_PORT, accept the value
|
|
307
|
+
# and print a deprecation hint to stderr so they migrate.
|
|
308
|
+
try:
|
|
309
|
+
cfg.backends.rdp.port = int(e["BD_PORT"])
|
|
310
|
+
except ValueError:
|
|
311
|
+
from .errors import UserError
|
|
312
|
+
raise UserError(
|
|
313
|
+
f"BD_PORT (alias for BD_RDP_PORT) must be an integer, got "
|
|
314
|
+
f"{e['BD_PORT']!r}")
|
|
315
|
+
# Stderr (NOT stdout — `browserwright-daemon url`'s stdout is the URL).
|
|
316
|
+
# In tests we sometimes silently want to set the env without warning
|
|
317
|
+
# noise; gate on `BD_PORT_QUIET=1` for that.
|
|
318
|
+
if e.get("BD_PORT_QUIET", "") not in ("1", "true", "True"):
|
|
319
|
+
import sys
|
|
320
|
+
print(
|
|
321
|
+
f"warning: BD_PORT is a deprecated alias for BD_RDP_PORT — "
|
|
322
|
+
f"please update your env / script. (BD_PORT={e['BD_PORT']!r} "
|
|
323
|
+
f"applied to rdp.port.)",
|
|
324
|
+
file=sys.stderr,
|
|
325
|
+
)
|
|
326
|
+
# v0.5 cloud backend env overrides — useful for one-off CLI calls
|
|
327
|
+
# without writing a config.toml. Each maps to the equivalent toml key.
|
|
328
|
+
if "BD_CLOUD_ENDPOINT" in e:
|
|
329
|
+
cfg.backends.cloud.endpoint = e["BD_CLOUD_ENDPOINT"]
|
|
330
|
+
if "BD_CLOUD_AUTH_KIND" in e:
|
|
331
|
+
cfg.backends.cloud.auth_kind = e["BD_CLOUD_AUTH_KIND"]
|
|
332
|
+
if "BD_CLOUD_PROVIDER_HINT" in e:
|
|
333
|
+
cfg.backends.cloud.provider_hint = e["BD_CLOUD_PROVIDER_HINT"]
|
|
334
|
+
# v0.5.3 Task #24: extension relay port via env. Symmetric to BD_RDP_PORT
|
|
335
|
+
# — useful when the default 19989 is occupied by a stale daemon process
|
|
336
|
+
# and the user can't write a config.toml on the fly. (playwriter sits on
|
|
337
|
+
# 19988, so default conflict with it is no longer a concern.)
|
|
338
|
+
if "BD_EXTENSION_PORT" in e:
|
|
339
|
+
try:
|
|
340
|
+
cfg.backends.extension.port = int(e["BD_EXTENSION_PORT"])
|
|
341
|
+
except ValueError:
|
|
342
|
+
from .errors import UserError
|
|
343
|
+
raise UserError(
|
|
344
|
+
f"BD_EXTENSION_PORT must be an integer, got "
|
|
345
|
+
f"{e['BD_EXTENSION_PORT']!r}")
|
|
346
|
+
# Playwright facade port via env (phase A1). Symmetric to BD_EXTENSION_PORT
|
|
347
|
+
# — set BD_FACADE_PORT=19990 (or any free port) to enable the facade.
|
|
348
|
+
if "BD_FACADE_PORT" in e:
|
|
349
|
+
try:
|
|
350
|
+
cfg.facade_port = int(e["BD_FACADE_PORT"])
|
|
351
|
+
except ValueError:
|
|
352
|
+
from .errors import UserError
|
|
353
|
+
raise UserError(
|
|
354
|
+
f"BD_FACADE_PORT must be an integer, got {e['BD_FACADE_PORT']!r}")
|
|
355
|
+
# The auth payload itself is read from kind-specific env vars that the
|
|
356
|
+
# AuthProvider already knows about (`token_env`, `cert_file`, etc).
|
|
357
|
+
# The env-override layer here is just for endpoint/kind selection; we
|
|
358
|
+
# deliberately don't have `BD_CLOUD_TOKEN` style shortcuts because
|
|
359
|
+
# that would mean either (a) silently overwriting `auth.bearer.token`
|
|
360
|
+
# without provenance tracking, or (b) inventing a parallel resolution
|
|
361
|
+
# path the AuthProvider can't see. Better to use `BROWSER_USE_API_KEY`
|
|
362
|
+
# + `token_env="BROWSER_USE_API_KEY"` in config.toml.
|
|
363
|
+
|
|
364
|
+
# CLI level — last word
|
|
365
|
+
if cli_backend is not None:
|
|
366
|
+
cfg.backend = cli_backend
|
|
367
|
+
if cli_timeout is not None:
|
|
368
|
+
cfg.timeout = cli_timeout
|
|
369
|
+
if cli_port is not None:
|
|
370
|
+
cfg.backends.rdp.port = cli_port
|
|
371
|
+
if cli_chrome_binary is not None:
|
|
372
|
+
cfg.chrome_binary = cli_chrome_binary
|
|
373
|
+
if cli_extension_port is not None:
|
|
374
|
+
# v0.5.3 Task #24: CLI tops env / toml for the extension relay port.
|
|
375
|
+
cfg.backends.extension.port = cli_extension_port
|
|
376
|
+
if cli_facade_port is not None:
|
|
377
|
+
# Phase A1: CLI `--facade-port` tops env / toml.
|
|
378
|
+
cfg.facade_port = cli_facade_port
|
|
379
|
+
|
|
380
|
+
return cfg
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""doctor + list-backends subcommands.
|
|
2
|
+
|
|
3
|
+
Spec §5.2: doctor probes every backend (or a specific one) and outputs a JSON
|
|
4
|
+
object with `schema_version=1`. The shape is locked — every backend must
|
|
5
|
+
appear, every key must be present even when null, and adding a key in v0.x
|
|
6
|
+
requires version bump.
|
|
7
|
+
|
|
8
|
+
Default behavior: ZERO ws side effects. `--probe-ws` is opt-in and explicitly
|
|
9
|
+
not implemented in v0.1 beyond a clear "not yet" message — the spec mentions
|
|
10
|
+
the flag but the v0.1 scope (§7) does not include real ws handshake.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from dataclasses import asdict
|
|
16
|
+
|
|
17
|
+
from .backends import all_backends, get_backend, names
|
|
18
|
+
from .config import Config
|
|
19
|
+
from .errors import UserError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# schema_version bumped to 2 in v0.5.3 (REVIEW.md F-1+F-2). v2 contract:
|
|
23
|
+
# - `ux_cost` enum gained "auth-required" for the cloud backend
|
|
24
|
+
# - `DoctorResult` gained `extras: dict` (free-form per-backend payload)
|
|
25
|
+
# v1 clients that strict-check `ux_cost in {none,banner,popup,extension-permission}`
|
|
26
|
+
# or that count backend-entry keys (==7) will break against v0.5+ daemons —
|
|
27
|
+
# they must be updated to v2-aware. Schema-lock test enforces no further
|
|
28
|
+
# silent drift; future field additions require another version bump.
|
|
29
|
+
SCHEMA_VERSION = 2
|
|
30
|
+
|
|
31
|
+
# Backends in this preference order are eligible to be `recommended`.
|
|
32
|
+
# Driven by spec §5.2's `recommended` field: choose the lowest ux_cost available.
|
|
33
|
+
_UX_COST_RANK = {
|
|
34
|
+
"none": 0,
|
|
35
|
+
"banner": 1,
|
|
36
|
+
"extension-permission": 2,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def doctor(cfg: Config, *, backend: str | None = None, probe_ws: bool = False) -> dict:
|
|
41
|
+
"""Build the locked doctor JSON object.
|
|
42
|
+
|
|
43
|
+
`backend=None` → probe all backends. `backend="rdp"` → probe just that one
|
|
44
|
+
but still emit the full shape (other entries get the canonical 'unknown'
|
|
45
|
+
record with available=false).
|
|
46
|
+
"""
|
|
47
|
+
if probe_ws:
|
|
48
|
+
# Honest: v0.1 doesn't implement the opt-in handshake. We surface the
|
|
49
|
+
# flag rather than silently ignoring it — see spec §5.2's contract.
|
|
50
|
+
raise UserError(
|
|
51
|
+
"--probe-ws is not implemented in v0.1; remove the flag for default "
|
|
52
|
+
"zero-side-effect doctor (planned for v0.2)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if backend is not None and backend not in names():
|
|
56
|
+
raise UserError(
|
|
57
|
+
f"unknown backend {backend!r}; known: {', '.join(names())}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
backends = all_backends(cfg)
|
|
61
|
+
results = await asyncio.gather(*[
|
|
62
|
+
b.probe() if backend is None or b.name == backend else _skipped(b)
|
|
63
|
+
for b in backends
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"schema_version": SCHEMA_VERSION,
|
|
68
|
+
"recommended": _pick_recommended([_asdict(r) for r in results]),
|
|
69
|
+
"backends": [_asdict(r) for r in results],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def list_backends(cfg: Config) -> dict:
|
|
74
|
+
"""Static, no-probe view — spec §5.3."""
|
|
75
|
+
return {
|
|
76
|
+
"schema_version": SCHEMA_VERSION,
|
|
77
|
+
"backends": [
|
|
78
|
+
{
|
|
79
|
+
"name": b.name,
|
|
80
|
+
"kind": b.kind,
|
|
81
|
+
"recommended_mode": b.recommended_mode,
|
|
82
|
+
"ux_cost": b.ux_cost,
|
|
83
|
+
"needs_user_action": _needs_action(b.name),
|
|
84
|
+
}
|
|
85
|
+
for b in all_backends(cfg)
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---- helpers ---------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _skipped(backend):
|
|
94
|
+
"""Used by doctor when --backend filters to one entry: every other backend
|
|
95
|
+
still appears in output but with a canonical 'skipped, not probed' record
|
|
96
|
+
(keeps schema shape stable for Skill)."""
|
|
97
|
+
from .backends.base import DoctorResult
|
|
98
|
+
|
|
99
|
+
return DoctorResult(
|
|
100
|
+
name=backend.name,
|
|
101
|
+
available=False,
|
|
102
|
+
ws_url=None,
|
|
103
|
+
detail="skipped (--backend filter)",
|
|
104
|
+
ux_warning=None,
|
|
105
|
+
needs_user_action=None,
|
|
106
|
+
ux_cost=backend.ux_cost,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _asdict(r) -> dict:
|
|
111
|
+
"""Normalize a DoctorResult to the locked schema dict.
|
|
112
|
+
|
|
113
|
+
We do NOT use dataclasses.asdict directly so any future field addition
|
|
114
|
+
fails this function — that's the schema_version=1 trip-wire.
|
|
115
|
+
|
|
116
|
+
`extras` (v0.5) is a per-backend free-form sub-dict. It's part of the
|
|
117
|
+
serialized output because the cloud backend's install-wizard contract
|
|
118
|
+
requires `provider` / `endpoint` / `auth_kind` / `configured` to be
|
|
119
|
+
readable by skill code. Empty dict = no extras (e.g. env / rdp).
|
|
120
|
+
"""
|
|
121
|
+
return {
|
|
122
|
+
"name": r.name,
|
|
123
|
+
"available": r.available,
|
|
124
|
+
"ws_url": r.ws_url,
|
|
125
|
+
"detail": r.detail,
|
|
126
|
+
"ux_warning": r.ux_warning,
|
|
127
|
+
"needs_user_action": r.needs_user_action,
|
|
128
|
+
"ux_cost": r.ux_cost,
|
|
129
|
+
"extras": dict(r.extras) if getattr(r, "extras", None) else {},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pick_recommended(entries: list[dict]) -> str | None:
|
|
134
|
+
"""Pick the available backend with the lowest UX cost.
|
|
135
|
+
|
|
136
|
+
Tie-break: registry order (env before rdp before extension, then cloud)
|
|
137
|
+
via Python's stable `min`.
|
|
138
|
+
|
|
139
|
+
v0.5.3 REVIEW.md F-10: dropped the `!= "extension"` exclusion. v0.1 had
|
|
140
|
+
it because extension was hard-coded `available=false`; v0.4 shipped the
|
|
141
|
+
backend with real `available=true` and the exclusion became a silent
|
|
142
|
+
"this backend is never recommended even when it works" stale rule.
|
|
143
|
+
`_UX_COST_RANK["extension-permission"]` = 2 — naturally ranks below
|
|
144
|
+
"none" (0) and "banner" (1), above "popup-per-ws+banner" (3). So if
|
|
145
|
+
extension is the only available backend, it gets recommended; if rdp
|
|
146
|
+
is also available, rdp still wins on UX cost.
|
|
147
|
+
"""
|
|
148
|
+
candidates = [e for e in entries if e["available"]]
|
|
149
|
+
if not candidates:
|
|
150
|
+
return None
|
|
151
|
+
return min(
|
|
152
|
+
candidates,
|
|
153
|
+
key=lambda e: _UX_COST_RANK.get(e["ux_cost"], 99),
|
|
154
|
+
)["name"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _needs_action(backend_name: str) -> str | None:
|
|
158
|
+
"""The static install-wizard hint for list-backends (no probing).
|
|
159
|
+
|
|
160
|
+
Mirrors the actionable hint each backend would put in its doctor entry.
|
|
161
|
+
Centralized here so Skill can render the chooser without a probe.
|
|
162
|
+
|
|
163
|
+
v0.5.3 REVIEW.md F-11:
|
|
164
|
+
- `extension` row updated from the stale "planned v0.4" placeholder
|
|
165
|
+
to the v0.4-shipped install path.
|
|
166
|
+
- `cloud` row added (v0.5 ship — was missing entirely).
|
|
167
|
+
"""
|
|
168
|
+
if backend_name == "env":
|
|
169
|
+
return "set BD_CDP_WS or BD_CDP_URL to your CDP endpoint"
|
|
170
|
+
if backend_name == "rdp":
|
|
171
|
+
return "launch Chrome with --remote-debugging-port=9222 (or use launch-chrome)"
|
|
172
|
+
if backend_name == "extension":
|
|
173
|
+
return ("load the unpacked extension from browserwright-daemon/chrome-extension/ "
|
|
174
|
+
"(chrome://extensions/ → enable Developer mode → Load unpacked); "
|
|
175
|
+
"or run `browserwright install` option 3")
|
|
176
|
+
if backend_name == "cloud":
|
|
177
|
+
return ("configure [backends.cloud] in config.toml (endpoint + auth_kind + "
|
|
178
|
+
"auth subtable); or run `browserwright install` option 4")
|
|
179
|
+
return None
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Daemon-internal exception hierarchy.
|
|
2
|
+
|
|
3
|
+
These map to Mode A exit codes (§5.1):
|
|
4
|
+
- UserError -> 1
|
|
5
|
+
- Unavailable -> 2
|
|
6
|
+
- ChromeBinaryNotFound (subclass of Unavailable) -> 6 from launch-chrome (§5.5)
|
|
7
|
+
- everything else (uncaught) -> 3
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DaemonError(Exception):
|
|
13
|
+
"""Base class. Subclasses choose exit-code semantics."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UserError(DaemonError):
|
|
17
|
+
"""Bad CLI input — unknown backend name, invalid flag combination, malformed BD_NAME."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Unavailable(DaemonError):
|
|
21
|
+
"""No backend could resolve a ws URL.
|
|
22
|
+
|
|
23
|
+
Carries an optional dict mapping backend-name -> per-backend reason so the CLI
|
|
24
|
+
can show all candidates that were tried. Single-backend failure (when --backend
|
|
25
|
+
was explicit) collapses to one entry.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, attempts: dict[str, str] | None = None):
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.attempts = attempts or {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChromeBinaryNotFound(Unavailable):
|
|
34
|
+
"""launch-chrome could not locate a Chrome binary. Exit code 6."""
|