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,222 @@
|
|
|
1
|
+
"""cloud backend — v0.5 (spec §7 + §8.1.1).
|
|
2
|
+
|
|
3
|
+
The cloud backend exists to cover what the v0.1 `env` backend couldn't:
|
|
4
|
+
HTTP-header auth and mTLS for remote browser services (Browser Use,
|
|
5
|
+
Browserless, Hyperbrowser, etc.). It still produces an upstream CDP ws
|
|
6
|
+
URL (so `kind=UPSTREAM_WS`, not LOCAL_RELAY) — the difference is that
|
|
7
|
+
the daemon's Mode B upstream-ws connect needs the auth provider's
|
|
8
|
+
headers / ssl context to authenticate the handshake. Mode A is degraded
|
|
9
|
+
but supported: for URL-embeddable auth styles (basic, URL-token), we
|
|
10
|
+
fold credentials into the output URL; for header / mTLS auth, Mode A
|
|
11
|
+
emits a warning that the resulting URL won't authenticate (the user
|
|
12
|
+
needs Mode B serve to actually connect).
|
|
13
|
+
|
|
14
|
+
Endpoint resolution:
|
|
15
|
+
- `wss://...` / `ws://...` → used as-is
|
|
16
|
+
- `https://...` / `http://...` → HTTP GET `{endpoint}/json/version`
|
|
17
|
+
and read `webSocketDebuggerUrl` (same shape as env backend's
|
|
18
|
+
BD_CDP_URL path). For Authorization-header auth, the discovery GET
|
|
19
|
+
also carries the header — otherwise some services 401 the GET.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from ..auth import AuthProvider, build_auth_provider
|
|
29
|
+
from ..config import Config
|
|
30
|
+
from ..errors import Unavailable, UserError
|
|
31
|
+
from .base import Backend, DoctorResult, ResolveResult
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CloudBackend(Backend):
|
|
37
|
+
name = "cloud"
|
|
38
|
+
kind = "UPSTREAM_WS"
|
|
39
|
+
recommended_mode: str = "B" # header / mTLS auth needs Mode B to inject
|
|
40
|
+
ux_cost = "auth-required" # v0.5 — new enum value, doctor contract
|
|
41
|
+
|
|
42
|
+
def __init__(self, cfg: Config):
|
|
43
|
+
self._cfg = cfg
|
|
44
|
+
self._cloud = cfg.backends.cloud
|
|
45
|
+
|
|
46
|
+
# ---- internal: build the AuthProvider (cached per-instance) ---------
|
|
47
|
+
|
|
48
|
+
_provider_cache: AuthProvider | None = None
|
|
49
|
+
|
|
50
|
+
def _provider(self) -> AuthProvider | None:
|
|
51
|
+
if self._provider_cache is not None:
|
|
52
|
+
return self._provider_cache
|
|
53
|
+
if not self._cloud.auth_kind:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
p = build_auth_provider(self._cloud.auth_kind, self._cloud.auth)
|
|
57
|
+
except UserError:
|
|
58
|
+
raise
|
|
59
|
+
self._provider_cache = p
|
|
60
|
+
return p
|
|
61
|
+
|
|
62
|
+
# ---- probe ----------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _extras(self, *, configured: bool) -> dict:
|
|
65
|
+
"""Doctor JSON `extras` contract (v0.5) for cloud backend. Matches
|
|
66
|
+
skill-impl-2's `_extension_backend_available()` mirror pattern —
|
|
67
|
+
skill reads `entry["extras"]["configured"]` in install wizard.
|
|
68
|
+
|
|
69
|
+
Schema (stable, additions are minor backend bumps not schema-level):
|
|
70
|
+
provider: str (browser-use | browserless | hyperbrowser | generic)
|
|
71
|
+
endpoint: str | None (configured ws / https URL, or None)
|
|
72
|
+
auth_kind: str | None (bearer | basic | mtls | oauth2 | None)
|
|
73
|
+
configured: bool (True iff endpoint + auth_kind both present
|
|
74
|
+
AND auth provider loads without raising)
|
|
75
|
+
"""
|
|
76
|
+
return {
|
|
77
|
+
"provider": self._cloud.provider_hint or "generic",
|
|
78
|
+
"endpoint": self._cloud.endpoint,
|
|
79
|
+
"auth_kind": self._cloud.auth_kind,
|
|
80
|
+
"configured": configured,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async def probe(self) -> DoctorResult:
|
|
84
|
+
"""Doctor probe — never opens a ws and **never** TCP-connects to the
|
|
85
|
+
cloud endpoint (spec §H3 / §5.2: zero side effects).
|
|
86
|
+
|
|
87
|
+
Contract (v0.5):
|
|
88
|
+
- `available=true` iff cloud config is complete AND auth provider
|
|
89
|
+
loads cleanly. We do NOT round-trip to the cloud server — that
|
|
90
|
+
would mean a TLS handshake (TCP connect + cert exchange) which
|
|
91
|
+
is a network side effect doctor must not have.
|
|
92
|
+
- `available=false + detail="not configured; ..."` when no endpoint
|
|
93
|
+
is configured.
|
|
94
|
+
- `available=false + detail="auth misconfigured: ..."` when auth
|
|
95
|
+
provider construction or eval fails (bad token_env, missing
|
|
96
|
+
cert file).
|
|
97
|
+
- `ux_cost="auth-required"` always (new enum value v0.5).
|
|
98
|
+
- `extras` carries `{provider, endpoint, auth_kind, configured}`.
|
|
99
|
+
"""
|
|
100
|
+
ep = self._cloud.endpoint
|
|
101
|
+
if not ep:
|
|
102
|
+
return DoctorResult(
|
|
103
|
+
name=self.name,
|
|
104
|
+
available=False,
|
|
105
|
+
ws_url=None,
|
|
106
|
+
detail="not configured; configure under [backends.cloud] in config.toml",
|
|
107
|
+
needs_user_action=(
|
|
108
|
+
"configure `[backends.cloud]` in config.toml — "
|
|
109
|
+
"see README §cloud backend"),
|
|
110
|
+
ux_cost=self.ux_cost,
|
|
111
|
+
extras=self._extras(configured=False),
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
provider = self._provider()
|
|
115
|
+
except UserError as e:
|
|
116
|
+
return DoctorResult(
|
|
117
|
+
name=self.name, available=False, ws_url=None,
|
|
118
|
+
detail=f"auth misconfigured: {e}",
|
|
119
|
+
needs_user_action="fix [backends.cloud.auth.*] in config.toml",
|
|
120
|
+
ux_cost=self.ux_cost,
|
|
121
|
+
extras=self._extras(configured=False),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Validate the AuthProvider can produce headers / ssl context. This
|
|
125
|
+
# touches local config (env vars, cert files) but does NOT do any
|
|
126
|
+
# network I/O — preserves the zero-side-effect doctor contract.
|
|
127
|
+
try:
|
|
128
|
+
if provider is not None:
|
|
129
|
+
_ = await provider.headers()
|
|
130
|
+
# ssl_context() reads cert files; raises UserError on bad path.
|
|
131
|
+
_ = provider.ssl_context()
|
|
132
|
+
except UserError as e:
|
|
133
|
+
return DoctorResult(
|
|
134
|
+
name=self.name, available=False, ws_url=None,
|
|
135
|
+
detail=f"auth misconfigured: {e}",
|
|
136
|
+
needs_user_action="check env vars / cert files",
|
|
137
|
+
ux_cost=self.ux_cost,
|
|
138
|
+
extras=self._extras(configured=False),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
auth_kind = self._cloud.auth_kind or "(none)"
|
|
142
|
+
hint = self._cloud.provider_hint or "generic"
|
|
143
|
+
return DoctorResult(
|
|
144
|
+
name=self.name,
|
|
145
|
+
available=True,
|
|
146
|
+
ws_url=None,
|
|
147
|
+
detail=f"provider={hint} (auth_kind={auth_kind}, endpoint={ep})",
|
|
148
|
+
ux_cost=self.ux_cost,
|
|
149
|
+
extras=self._extras(configured=True),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# ---- resolve --------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
async def resolve(self, timeout: float) -> ResolveResult:
|
|
155
|
+
ep = self._cloud.endpoint
|
|
156
|
+
if not ep:
|
|
157
|
+
raise Unavailable(
|
|
158
|
+
"cloud backend has no endpoint configured",
|
|
159
|
+
attempts={self.name: "no endpoint"},
|
|
160
|
+
)
|
|
161
|
+
provider = self._provider()
|
|
162
|
+
# Pre-baked direct ws URL — just fold URL-embeddable auth in.
|
|
163
|
+
if ep.startswith(("ws://", "wss://")):
|
|
164
|
+
url = ep
|
|
165
|
+
if provider is not None:
|
|
166
|
+
url = await provider.url_with_auth(url)
|
|
167
|
+
extras: dict[str, Any] = {
|
|
168
|
+
"auth_kind": self._cloud.auth_kind or "none",
|
|
169
|
+
"provider": self._cloud.provider_hint or "generic",
|
|
170
|
+
}
|
|
171
|
+
if provider is not None and not provider.supports_websocket_auth():
|
|
172
|
+
# OAuth2 stub etc. — surface the limitation explicitly.
|
|
173
|
+
extras["warning"] = (
|
|
174
|
+
f"auth_kind={self._cloud.auth_kind} doesn't support "
|
|
175
|
+
"websocket auth in v0.5; Mode A URL will not "
|
|
176
|
+
"authenticate by itself")
|
|
177
|
+
return ResolveResult(ws_url=url, backend=self.name, extras=extras)
|
|
178
|
+
|
|
179
|
+
# HTTP endpoint → discover via /json/version (matching env backend's
|
|
180
|
+
# BD_CDP_URL shape).
|
|
181
|
+
try:
|
|
182
|
+
headers = await provider.headers() if provider is not None else {}
|
|
183
|
+
except UserError as e:
|
|
184
|
+
raise Unavailable(
|
|
185
|
+
f"cloud backend auth headers unresolvable: {e}",
|
|
186
|
+
attempts={self.name: str(e)},
|
|
187
|
+
)
|
|
188
|
+
url = ep.rstrip("/") + "/json/version"
|
|
189
|
+
try:
|
|
190
|
+
async with httpx.AsyncClient(
|
|
191
|
+
timeout=timeout, trust_env=False, mounts={}) as client:
|
|
192
|
+
resp = await client.get(url, headers=headers)
|
|
193
|
+
except (httpx.HTTPError, OSError) as e:
|
|
194
|
+
raise Unavailable(
|
|
195
|
+
f"cloud backend discovery failed: {e}",
|
|
196
|
+
attempts={self.name: f"{type(e).__name__}: {e}"},
|
|
197
|
+
)
|
|
198
|
+
if resp.status_code != 200:
|
|
199
|
+
raise Unavailable(
|
|
200
|
+
f"cloud backend discovery returned HTTP {resp.status_code}",
|
|
201
|
+
attempts={self.name: f"HTTP {resp.status_code} from {url}"},
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
body = resp.json()
|
|
205
|
+
except ValueError:
|
|
206
|
+
body = {}
|
|
207
|
+
ws = body.get("webSocketDebuggerUrl") if isinstance(body, dict) else None
|
|
208
|
+
if not isinstance(ws, str) or not ws:
|
|
209
|
+
raise Unavailable(
|
|
210
|
+
f"cloud backend /json/version has no webSocketDebuggerUrl",
|
|
211
|
+
attempts={self.name: f"response body: {body!r}"},
|
|
212
|
+
)
|
|
213
|
+
if provider is not None:
|
|
214
|
+
ws = await provider.url_with_auth(ws)
|
|
215
|
+
return ResolveResult(
|
|
216
|
+
ws_url=ws,
|
|
217
|
+
backend=self.name,
|
|
218
|
+
extras={
|
|
219
|
+
"auth_kind": self._cloud.auth_kind or "none",
|
|
220
|
+
"provider": self._cloud.provider_hint or "generic",
|
|
221
|
+
},
|
|
222
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""env backend — caller injects the ws URL out-of-band.
|
|
2
|
+
|
|
3
|
+
Spec §8.1 + §8.1.1: BD_CDP_WS is trusted verbatim (no parsing, no rewriting —
|
|
4
|
+
that's the whole point of the env path for cloud / fingerprint browsers with
|
|
5
|
+
URL-embedded tokens). BD_CDP_URL goes through `/json/version` to pull the
|
|
6
|
+
webSocketDebuggerUrl field, same as rdp but pointed at an arbitrary host.
|
|
7
|
+
|
|
8
|
+
BU_CDP_WS / BU_CDP_URL are compat aliases honored at the Config layer
|
|
9
|
+
(config.py records `cdp_ws_source`); doctor surfaces a "please migrate" hint
|
|
10
|
+
when those fired.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ..config import Config
|
|
17
|
+
from ..errors import Unavailable
|
|
18
|
+
from .base import Backend, DoctorResult, ResolveResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EnvBackend(Backend):
|
|
22
|
+
name = "env"
|
|
23
|
+
kind = "UPSTREAM_WS"
|
|
24
|
+
recommended_mode: str = "A"
|
|
25
|
+
ux_cost = "none"
|
|
26
|
+
|
|
27
|
+
def __init__(self, cfg: Config):
|
|
28
|
+
self._cfg = cfg
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------ probe
|
|
31
|
+
|
|
32
|
+
async def probe(self) -> DoctorResult:
|
|
33
|
+
cfg = self._cfg
|
|
34
|
+
if cfg.cdp_ws:
|
|
35
|
+
return DoctorResult(
|
|
36
|
+
name=self.name,
|
|
37
|
+
available=True,
|
|
38
|
+
ws_url=None, # doctor never opens a ws — spec §5.2 contract
|
|
39
|
+
detail=self._origin_detail("BD_CDP_WS" if cfg.cdp_ws_source == "BD_CDP_WS"
|
|
40
|
+
else "BU_CDP_WS"),
|
|
41
|
+
ux_cost=self.ux_cost,
|
|
42
|
+
)
|
|
43
|
+
if cfg.cdp_url:
|
|
44
|
+
return DoctorResult(
|
|
45
|
+
name=self.name,
|
|
46
|
+
available=True,
|
|
47
|
+
ws_url=None,
|
|
48
|
+
detail=self._origin_detail("BD_CDP_URL" if cfg.cdp_url_source == "BD_CDP_URL"
|
|
49
|
+
else "BU_CDP_URL"),
|
|
50
|
+
ux_cost=self.ux_cost,
|
|
51
|
+
)
|
|
52
|
+
return DoctorResult(
|
|
53
|
+
name=self.name,
|
|
54
|
+
available=False,
|
|
55
|
+
ws_url=None,
|
|
56
|
+
detail="BD_CDP_WS not set, BD_CDP_URL not set",
|
|
57
|
+
ux_cost=self.ux_cost,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _origin_detail(self, source: str) -> str:
|
|
61
|
+
if source.startswith("BU_"):
|
|
62
|
+
return f"{source} set (deprecated, please rename to BD_{source[3:]})"
|
|
63
|
+
return f"{source} set"
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------- resolve
|
|
66
|
+
|
|
67
|
+
async def resolve(self, timeout: float) -> ResolveResult:
|
|
68
|
+
cfg = self._cfg
|
|
69
|
+
if cfg.cdp_ws:
|
|
70
|
+
return ResolveResult(ws_url=cfg.cdp_ws, backend=self.name, extras={})
|
|
71
|
+
if cfg.cdp_url:
|
|
72
|
+
ws = await _resolve_via_json_version(cfg.cdp_url, timeout)
|
|
73
|
+
return ResolveResult(
|
|
74
|
+
ws_url=ws,
|
|
75
|
+
backend=self.name,
|
|
76
|
+
extras={"discovery_url": cfg.cdp_url},
|
|
77
|
+
)
|
|
78
|
+
raise Unavailable(
|
|
79
|
+
"env backend: neither BD_CDP_WS nor BD_CDP_URL is set",
|
|
80
|
+
attempts={self.name: "BD_CDP_WS not set, BD_CDP_URL not set"},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _resolve_via_json_version(base_url: str, timeout: float) -> str:
|
|
85
|
+
"""HTTP GET {base_url}/json/version and return webSocketDebuggerUrl.
|
|
86
|
+
|
|
87
|
+
Spec §8.1: this is the standard CDP HTTP discovery shape that all real and
|
|
88
|
+
fingerprint browsers, plus most cloud providers, agree on. We keep this
|
|
89
|
+
function shared with `rdp`-style probes (re-imported there) so the two
|
|
90
|
+
paths can never diverge on edge handling (timeout / non-200 / missing key).
|
|
91
|
+
"""
|
|
92
|
+
url = f"{base_url.rstrip('/')}/json/version"
|
|
93
|
+
try:
|
|
94
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
95
|
+
resp = await client.get(url)
|
|
96
|
+
except (httpx.HTTPError, OSError) as e:
|
|
97
|
+
raise Unavailable(
|
|
98
|
+
f"env backend: HTTP discovery failed for {url}: {e}",
|
|
99
|
+
attempts={"env": f"GET {url} -> {type(e).__name__}: {e}"},
|
|
100
|
+
) from e
|
|
101
|
+
if resp.status_code != 200:
|
|
102
|
+
raise Unavailable(
|
|
103
|
+
f"env backend: {url} returned HTTP {resp.status_code}",
|
|
104
|
+
attempts={"env": f"GET {url} -> HTTP {resp.status_code}"},
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
body = resp.json()
|
|
108
|
+
except ValueError as e:
|
|
109
|
+
raise Unavailable(
|
|
110
|
+
f"env backend: {url} returned non-JSON body: {e}",
|
|
111
|
+
attempts={"env": f"GET {url} -> non-JSON"},
|
|
112
|
+
) from e
|
|
113
|
+
ws = body.get("webSocketDebuggerUrl") if isinstance(body, dict) else None
|
|
114
|
+
if not isinstance(ws, str) or not ws:
|
|
115
|
+
raise Unavailable(
|
|
116
|
+
f"env backend: {url} JSON has no webSocketDebuggerUrl field",
|
|
117
|
+
attempts={"env": f"GET {url} -> missing webSocketDebuggerUrl"},
|
|
118
|
+
)
|
|
119
|
+
return ws
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""extension backend — v0.4 real implementation (spec §8.4).
|
|
2
|
+
|
|
3
|
+
Architecture: this backend doesn't return an upstream ws URL — Chrome's
|
|
4
|
+
CDP isn't the upstream here. Instead, the daemon (Mode B `serve`) starts a
|
|
5
|
+
local relay ws server (`server/relay.py`) on `127.0.0.1:19989`, and the
|
|
6
|
+
user's Chrome extension connects TO us. The daemon then proxies standard
|
|
7
|
+
CDP between Skill clients and the extension's `chrome.debugger` API.
|
|
8
|
+
|
|
9
|
+
Implications:
|
|
10
|
+
|
|
11
|
+
- `kind=LOCAL_RELAY` (spec §4.3): not a URL passthrough; the daemon
|
|
12
|
+
emulates the upstream side.
|
|
13
|
+
- `resolve()` in Mode A still raises — Mode A `url` is meaningless when the
|
|
14
|
+
daemon IS the relay; Skill must drive Mode B.
|
|
15
|
+
- `probe()` does a real check: GET `http://127.0.0.1:19989/__status__` and
|
|
16
|
+
inspect the response for a connected extension. If reachable + at least
|
|
17
|
+
one extension has sent `hello`, `available=true`.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from ..config import Config
|
|
29
|
+
from ..errors import Unavailable
|
|
30
|
+
from .base import Backend, DoctorResult, ResolveResult
|
|
31
|
+
from ..server.relay import DEFAULT_RELAY_PORT
|
|
32
|
+
from browserwright.version import EXTENSION_PROTOCOL_VERSION, package_version
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ExtensionBackend(Backend):
|
|
38
|
+
name = "extension"
|
|
39
|
+
kind = "LOCAL_RELAY"
|
|
40
|
+
recommended_mode: str = "B"
|
|
41
|
+
ux_cost = "extension-permission"
|
|
42
|
+
|
|
43
|
+
def __init__(self, cfg: Config):
|
|
44
|
+
self._cfg = cfg
|
|
45
|
+
# v0.5.3 F-5 / Task #24: single source of truth for host+port.
|
|
46
|
+
# Honors CLI --extension-port > BD_EXTENSION_PORT > toml port >
|
|
47
|
+
# toml relay_url > DEFAULT_RELAY_PORT.
|
|
48
|
+
self._host, self._port = cfg.backends.extension.resolved_host_port()
|
|
49
|
+
|
|
50
|
+
def caps(self) -> dict:
|
|
51
|
+
# Attaches to the user's existing Chrome; isolates sessions via tab
|
|
52
|
+
# groups, not browser contexts (P4).
|
|
53
|
+
return {"owns_browser": False, "supports_browser_context": False}
|
|
54
|
+
|
|
55
|
+
async def probe(self) -> DoctorResult:
|
|
56
|
+
"""HTTP GET 127.0.0.1:19989/__status__.
|
|
57
|
+
|
|
58
|
+
Three outcomes:
|
|
59
|
+
1. connection refused → no daemon-serve running with extension
|
|
60
|
+
backend → available=false + needs_user_action
|
|
61
|
+
2. running but `extensions == 0` → relay up, user hasn't installed
|
|
62
|
+
/ loaded the extension → available=false + needs_user_action
|
|
63
|
+
3. running with extensions → available=true
|
|
64
|
+
"""
|
|
65
|
+
url = f"http://127.0.0.1:{self._port}/__status__"
|
|
66
|
+
# `trust_env=False` + explicit `mounts={}` makes httpx ignore the
|
|
67
|
+
# user's HTTPS_PROXY / SOCKS_PROXY / ALL_PROXY env vars. Loopback
|
|
68
|
+
# traffic must never go through a proxy — same trick the other
|
|
69
|
+
# backends use for their `/json/version` probes.
|
|
70
|
+
try:
|
|
71
|
+
async with httpx.AsyncClient(
|
|
72
|
+
timeout=2.0, trust_env=False, mounts={},
|
|
73
|
+
) as client:
|
|
74
|
+
resp = await client.get(url)
|
|
75
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
76
|
+
return DoctorResult(
|
|
77
|
+
name=self.name,
|
|
78
|
+
available=False,
|
|
79
|
+
ws_url=None,
|
|
80
|
+
detail=(f"no extension relay listening on 127.0.0.1:{self._port} "
|
|
81
|
+
f"— start `browserwright-daemon serve`"),
|
|
82
|
+
ux_warning=None,
|
|
83
|
+
needs_user_action=(
|
|
84
|
+
"start the single global daemon, "
|
|
85
|
+
"then load the Chrome extension from `chrome-extension/`"),
|
|
86
|
+
ux_cost=self.ux_cost,
|
|
87
|
+
)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return DoctorResult(
|
|
90
|
+
name=self.name,
|
|
91
|
+
available=False,
|
|
92
|
+
ws_url=None,
|
|
93
|
+
detail=f"relay status probe failed: {e!r}",
|
|
94
|
+
ux_warning=None,
|
|
95
|
+
needs_user_action="check daemon logs",
|
|
96
|
+
ux_cost=self.ux_cost,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if resp.status_code != 200:
|
|
100
|
+
return DoctorResult(
|
|
101
|
+
name=self.name,
|
|
102
|
+
available=False,
|
|
103
|
+
ws_url=None,
|
|
104
|
+
detail=f"relay /__status__ returned HTTP {resp.status_code}",
|
|
105
|
+
needs_user_action="restart daemon-serve",
|
|
106
|
+
ux_cost=self.ux_cost,
|
|
107
|
+
)
|
|
108
|
+
try:
|
|
109
|
+
status = resp.json()
|
|
110
|
+
except (ValueError, json.JSONDecodeError):
|
|
111
|
+
status = {}
|
|
112
|
+
|
|
113
|
+
ext_count = int(status.get("extensions", 0))
|
|
114
|
+
if ext_count == 0:
|
|
115
|
+
return DoctorResult(
|
|
116
|
+
name=self.name,
|
|
117
|
+
available=False,
|
|
118
|
+
ws_url=None,
|
|
119
|
+
detail=("extension relay is running but no Chrome extension "
|
|
120
|
+
"has connected yet"),
|
|
121
|
+
needs_user_action=(
|
|
122
|
+
"load `chrome-extension/` as an unpacked extension in "
|
|
123
|
+
"Chrome (chrome://extensions/ → enable Developer mode "
|
|
124
|
+
"→ Load unpacked)"),
|
|
125
|
+
ux_cost=self.ux_cost,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
install_ids = status.get("install_ids") or []
|
|
129
|
+
details = status.get("extension_details") or []
|
|
130
|
+
incompatible = [
|
|
131
|
+
item for item in details
|
|
132
|
+
if item.get("compatible") is False
|
|
133
|
+
]
|
|
134
|
+
version_mismatch = [
|
|
135
|
+
item for item in details
|
|
136
|
+
if item.get("browserwright_version")
|
|
137
|
+
and item.get("browserwright_version") != package_version()
|
|
138
|
+
]
|
|
139
|
+
if incompatible:
|
|
140
|
+
return DoctorResult(
|
|
141
|
+
name=self.name,
|
|
142
|
+
available=False,
|
|
143
|
+
ws_url=None,
|
|
144
|
+
detail=(
|
|
145
|
+
"extension relay connected, but protocol is incompatible "
|
|
146
|
+
f"(daemon expects {EXTENSION_PROTOCOL_VERSION})"
|
|
147
|
+
),
|
|
148
|
+
ux_warning="reload the unpacked extension from the matching release",
|
|
149
|
+
needs_user_action="reload `chrome-extension/` in Chrome",
|
|
150
|
+
ux_cost=self.ux_cost,
|
|
151
|
+
)
|
|
152
|
+
warning = None
|
|
153
|
+
if version_mismatch:
|
|
154
|
+
seen = sorted({
|
|
155
|
+
str(item.get("browserwright_version"))
|
|
156
|
+
for item in version_mismatch
|
|
157
|
+
if item.get("browserwright_version")
|
|
158
|
+
})
|
|
159
|
+
warning = (
|
|
160
|
+
f"extension version(s) {seen} do not match package "
|
|
161
|
+
f"{package_version()}; reload the unpacked extension"
|
|
162
|
+
)
|
|
163
|
+
tabs = int(status.get("tab_count", 0))
|
|
164
|
+
return DoctorResult(
|
|
165
|
+
name=self.name,
|
|
166
|
+
available=True,
|
|
167
|
+
ws_url=None,
|
|
168
|
+
detail=(f"{ext_count} extension(s) connected "
|
|
169
|
+
f"(install_ids={install_ids}, attached tabs={tabs})"),
|
|
170
|
+
ux_warning=warning,
|
|
171
|
+
needs_user_action=None,
|
|
172
|
+
ux_cost=self.ux_cost,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def resolve(self, timeout: float) -> ResolveResult:
|
|
176
|
+
"""Mode A `url` isn't meaningful for LOCAL_RELAY: there's no
|
|
177
|
+
externally-connectable upstream ws — the daemon IS the relay. So we
|
|
178
|
+
raise Unavailable with a hint that points to Mode B.
|
|
179
|
+
"""
|
|
180
|
+
raise Unavailable(
|
|
181
|
+
"extension backend is a LOCAL_RELAY — it cannot be used via "
|
|
182
|
+
"`browserwright-daemon url`. Run `browserwright-daemon serve` "
|
|
183
|
+
"instead and connect via the daemon's unix socket.",
|
|
184
|
+
attempts={self.name: "LOCAL_RELAY backend requires Mode B serve"},
|
|
185
|
+
)
|