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.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. 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."""