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,395 @@
|
|
|
1
|
+
"""Auth provider abstraction for the v0.5 `cloud` backend.
|
|
2
|
+
|
|
3
|
+
Why this exists (spec §8.1.1):
|
|
4
|
+
|
|
5
|
+
The v0.1 `env` backend covered cloud browsers (Browser Use / Browserless /
|
|
6
|
+
Hyperbrowser) by transparently forwarding a user-supplied ws URL — works
|
|
7
|
+
fine when the cloud service accepts URL-embedded auth (`?api_key=...` or
|
|
8
|
+
`wss://user:pass@host/`). But cloud services that require HTTP-header auth
|
|
9
|
+
(`Authorization: Bearer ...` / `X-API-Key: ...`) or mTLS client certs can't
|
|
10
|
+
be reached this way: in Mode A the daemon hands the URL to the Skill which
|
|
11
|
+
opens the ws and there's no header injection point; in Mode B the daemon
|
|
12
|
+
itself opens the upstream ws and needs to know the headers.
|
|
13
|
+
|
|
14
|
+
`cloud` backend solves this by parameterizing per-backend auth via an
|
|
15
|
+
`AuthProvider` Protocol. Three concrete starter implementations cover the
|
|
16
|
+
realistic 0.5 surface:
|
|
17
|
+
|
|
18
|
+
- `BearerTokenAuth` — `Authorization: Bearer <token>` (Browser Use,
|
|
19
|
+
Hyperbrowser-style "API key" services)
|
|
20
|
+
- `BasicAuth` — RFC 7617 `Authorization: Basic <base64>` OR fall back to
|
|
21
|
+
URL-embedded `user:pass@` so the v0.1 env-backend behavior stays
|
|
22
|
+
expressible through the same abstraction (no regression for users who
|
|
23
|
+
already had basic-auth URLs working)
|
|
24
|
+
- `MtlsAuth` — client cert + key, surfaced as an `ssl.SSLContext` for the
|
|
25
|
+
upstream ws connect path (websockets accepts `ssl=` kwarg)
|
|
26
|
+
|
|
27
|
+
`OAuth2Auth` is a stub Protocol marker for v0.6 — we expose the type so
|
|
28
|
+
callers can `isinstance(provider, OAuth2Auth)` to render an "OAuth flow
|
|
29
|
+
coming v0.6" hint in `doctor` output.
|
|
30
|
+
|
|
31
|
+
Design constraints:
|
|
32
|
+
|
|
33
|
+
- Auth is **read-only resolution** at this layer. No HTTP requests inside
|
|
34
|
+
`headers()` / `url_with_auth()` — those are pure functions over the
|
|
35
|
+
provider's static config. The OAuth refresh hook is the one exception
|
|
36
|
+
(it can do a token-refresh round-trip), called by the daemon when the
|
|
37
|
+
upstream connect 401s.
|
|
38
|
+
- Providers are **synchronous to construct + async to consume**. That
|
|
39
|
+
matches every other backend in the package.
|
|
40
|
+
- No magical credential discovery (`AWS_*`, `BROWSER_USE_API_KEY` is
|
|
41
|
+
named in config — there's no `~/.cloud-creds.json` walker). Spec keeps
|
|
42
|
+
the security surface minimal: tokens come from explicit env vars or
|
|
43
|
+
explicit file paths in `config.toml`.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import base64
|
|
48
|
+
import os
|
|
49
|
+
import ssl
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Awaitable, Callable, Protocol, runtime_checkable
|
|
53
|
+
from urllib.parse import quote, urlparse, urlunparse
|
|
54
|
+
|
|
55
|
+
from .errors import UserError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---- Protocol --------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@runtime_checkable
|
|
62
|
+
class AuthProvider(Protocol):
|
|
63
|
+
"""Per-cloud-backend auth strategy.
|
|
64
|
+
|
|
65
|
+
Mode B upstream ws connect reads `headers()` + `ssl_context()` and
|
|
66
|
+
passes them to `websockets.connect()`. Mode A `url` resolver reads
|
|
67
|
+
`url_with_auth()` to fold any URL-embeddable credential into the
|
|
68
|
+
output URL.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
kind: str
|
|
72
|
+
"""Stable identifier — used in doctor output + config validation."""
|
|
73
|
+
|
|
74
|
+
async def headers(self) -> dict[str, str]:
|
|
75
|
+
"""Headers to add to the upstream ws handshake (Mode B).
|
|
76
|
+
|
|
77
|
+
Returns `{}` for auth styles that can't be header-encoded (mTLS,
|
|
78
|
+
URL-embedded). Pure: no I/O unless `refresh()` was just called.
|
|
79
|
+
"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
async def url_with_auth(self, url: str) -> str:
|
|
83
|
+
"""Fold credentials into the URL when possible (basic auth,
|
|
84
|
+
URL-embedded tokens). Returns `url` unchanged for header-based or
|
|
85
|
+
cert-based auth.
|
|
86
|
+
|
|
87
|
+
Used by Mode A `browserwright-daemon url` so Skills that open their own
|
|
88
|
+
ws still get the credential through.
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
def ssl_context(self) -> ssl.SSLContext | None:
|
|
93
|
+
"""Build an `ssl.SSLContext` with client cert/key when mTLS is in
|
|
94
|
+
play. Returns None for non-mTLS providers.
|
|
95
|
+
|
|
96
|
+
Called once at upstream-open time; not pure (reads cert files).
|
|
97
|
+
"""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
async def refresh(self) -> None:
|
|
101
|
+
"""Refresh expired credentials (OAuth2 access tokens, time-bounded
|
|
102
|
+
STS sessions). For static credential styles (bearer / basic /
|
|
103
|
+
mTLS) this is a no-op. Called by the daemon when an upstream
|
|
104
|
+
connect returns HTTP 401.
|
|
105
|
+
"""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
def supports_websocket_auth(self) -> bool:
|
|
109
|
+
"""Whether this provider produces something usable for ws-level
|
|
110
|
+
auth. Doctor uses this to print a "yes / no" hint."""
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---- BearerTokenAuth -------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class BearerTokenAuth:
|
|
119
|
+
"""`Authorization: Bearer <token>` header injection.
|
|
120
|
+
|
|
121
|
+
Resolution order (highest first):
|
|
122
|
+
1. Explicit `token=` constructor arg (test seam)
|
|
123
|
+
2. Env var named by `token_env` (set in config.toml)
|
|
124
|
+
3. Static `token` field (deprecated but config-supported for one-off
|
|
125
|
+
hard-coded keys — strongly discouraged but not forbidden)
|
|
126
|
+
|
|
127
|
+
Surface ambiguity NOTE: cloud providers vary in header name. Browser
|
|
128
|
+
Use accepts `Authorization: Bearer X-API-Key`; Browserless takes
|
|
129
|
+
`?token=` (URL-embedded → use env backend); Hyperbrowser uses
|
|
130
|
+
`X-API-Key`. The `header_name` field lets each cloud-config row tune
|
|
131
|
+
it without forking the class.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
kind: str = "bearer"
|
|
135
|
+
token: str | None = None
|
|
136
|
+
token_env: str | None = None
|
|
137
|
+
header_name: str = "Authorization"
|
|
138
|
+
header_prefix: str = "Bearer "
|
|
139
|
+
|
|
140
|
+
def _read_token(self) -> str:
|
|
141
|
+
if self.token is not None:
|
|
142
|
+
return self.token
|
|
143
|
+
if self.token_env:
|
|
144
|
+
v = os.environ.get(self.token_env, "")
|
|
145
|
+
if not v:
|
|
146
|
+
raise UserError(
|
|
147
|
+
f"BearerTokenAuth: env var {self.token_env!r} is unset "
|
|
148
|
+
f"or empty")
|
|
149
|
+
return v
|
|
150
|
+
raise UserError(
|
|
151
|
+
"BearerTokenAuth requires either `token` or `token_env` to be set")
|
|
152
|
+
|
|
153
|
+
async def headers(self) -> dict[str, str]:
|
|
154
|
+
return {self.header_name: f"{self.header_prefix}{self._read_token()}"}
|
|
155
|
+
|
|
156
|
+
async def url_with_auth(self, url: str) -> str:
|
|
157
|
+
return url # bearer tokens are header-only by convention
|
|
158
|
+
|
|
159
|
+
def ssl_context(self) -> ssl.SSLContext | None:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
async def refresh(self) -> None:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def supports_websocket_auth(self) -> bool:
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---- BasicAuth -------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class BasicAuth:
|
|
174
|
+
"""RFC 7617 basic auth. Two flavors:
|
|
175
|
+
|
|
176
|
+
- **header mode** (default, `embed_in_url=False`): emits the
|
|
177
|
+
`Authorization: Basic <base64(user:pass)>` header for Mode B upstream.
|
|
178
|
+
Mode A `url_with_auth()` still falls back to URL-embedded so Skills
|
|
179
|
+
that consume Mode A URLs continue to work — there's no header
|
|
180
|
+
injection point on Mode A.
|
|
181
|
+
- **URL-embedded mode** (`embed_in_url=True`): forces `user:pass@host`
|
|
182
|
+
embedding even in Mode B. Compatible with the v0.1 env-backend
|
|
183
|
+
behavior; useful when the upstream server only accepts basic-auth
|
|
184
|
+
via URL (rare but seen in fingerprint-browser farms).
|
|
185
|
+
|
|
186
|
+
Credentials come from:
|
|
187
|
+
- constructor `username`/`password` (test seam)
|
|
188
|
+
- `username_env`/`password_env` (recommended for production)
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
kind: str = "basic"
|
|
192
|
+
username: str | None = None
|
|
193
|
+
password: str | None = None
|
|
194
|
+
username_env: str | None = None
|
|
195
|
+
password_env: str | None = None
|
|
196
|
+
embed_in_url: bool = False
|
|
197
|
+
|
|
198
|
+
def _resolve(self) -> tuple[str, str]:
|
|
199
|
+
u = self.username
|
|
200
|
+
if u is None and self.username_env:
|
|
201
|
+
u = os.environ.get(self.username_env)
|
|
202
|
+
p = self.password
|
|
203
|
+
if p is None and self.password_env:
|
|
204
|
+
p = os.environ.get(self.password_env)
|
|
205
|
+
if not u or p is None:
|
|
206
|
+
raise UserError(
|
|
207
|
+
"BasicAuth: username + password must be resolvable from either "
|
|
208
|
+
"constructor args or `username_env`/`password_env`")
|
|
209
|
+
return u, p
|
|
210
|
+
|
|
211
|
+
async def headers(self) -> dict[str, str]:
|
|
212
|
+
if self.embed_in_url:
|
|
213
|
+
return {} # credential goes in URL, not header
|
|
214
|
+
u, p = self._resolve()
|
|
215
|
+
token = base64.b64encode(f"{u}:{p}".encode("utf-8")).decode("ascii")
|
|
216
|
+
return {"Authorization": f"Basic {token}"}
|
|
217
|
+
|
|
218
|
+
async def url_with_auth(self, url: str) -> str:
|
|
219
|
+
# We embed when Mode A asks OR when embed_in_url forces it.
|
|
220
|
+
# `url_with_auth` callers are Mode-A code paths — header injection
|
|
221
|
+
# isn't available there, so embedding is the only way to surface
|
|
222
|
+
# the credential.
|
|
223
|
+
u, p = self._resolve()
|
|
224
|
+
parsed = urlparse(url)
|
|
225
|
+
if parsed.username or parsed.password:
|
|
226
|
+
return url # already embedded, don't double-stamp
|
|
227
|
+
# urllib.parse percent-encoding leaves `@` / `:` alone — manual:
|
|
228
|
+
creds = f"{quote(u, safe='')}:{quote(p, safe='')}"
|
|
229
|
+
netloc = f"{creds}@{parsed.hostname or ''}"
|
|
230
|
+
if parsed.port:
|
|
231
|
+
netloc += f":{parsed.port}"
|
|
232
|
+
return urlunparse((
|
|
233
|
+
parsed.scheme, netloc, parsed.path, parsed.params,
|
|
234
|
+
parsed.query, parsed.fragment,
|
|
235
|
+
))
|
|
236
|
+
|
|
237
|
+
def ssl_context(self) -> ssl.SSLContext | None:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
async def refresh(self) -> None:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def supports_websocket_auth(self) -> bool:
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---- MtlsAuth --------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass
|
|
251
|
+
class MtlsAuth:
|
|
252
|
+
"""Mutual TLS via client certificate + private key.
|
|
253
|
+
|
|
254
|
+
Both files must be PEM-encoded. The optional `ca_file` lets the user
|
|
255
|
+
pin a custom CA (e.g., self-signed cloud-provider CA). The optional
|
|
256
|
+
`key_password_env` covers encrypted keys without putting the password
|
|
257
|
+
in config.toml.
|
|
258
|
+
|
|
259
|
+
Surface: `ssl_context()` is the only meaningful method here —
|
|
260
|
+
`headers()` returns `{}` because mTLS authenticates at the TLS layer
|
|
261
|
+
below HTTP/ws. The CDP transport layer (`server/upstream.py`) passes
|
|
262
|
+
the SSLContext to `websockets.connect(ssl=...)`.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
kind: str = "mtls"
|
|
266
|
+
cert_file: str = ""
|
|
267
|
+
key_file: str = ""
|
|
268
|
+
ca_file: str | None = None
|
|
269
|
+
key_password_env: str | None = None
|
|
270
|
+
|
|
271
|
+
def _resolve_password(self) -> str | None:
|
|
272
|
+
if not self.key_password_env:
|
|
273
|
+
return None
|
|
274
|
+
v = os.environ.get(self.key_password_env, "")
|
|
275
|
+
return v or None
|
|
276
|
+
|
|
277
|
+
async def headers(self) -> dict[str, str]:
|
|
278
|
+
return {}
|
|
279
|
+
|
|
280
|
+
async def url_with_auth(self, url: str) -> str:
|
|
281
|
+
return url
|
|
282
|
+
|
|
283
|
+
def ssl_context(self) -> ssl.SSLContext | None:
|
|
284
|
+
if not self.cert_file or not self.key_file:
|
|
285
|
+
raise UserError(
|
|
286
|
+
"MtlsAuth: cert_file + key_file are required")
|
|
287
|
+
cert_path = Path(self.cert_file).expanduser()
|
|
288
|
+
key_path = Path(self.key_file).expanduser()
|
|
289
|
+
if not cert_path.is_file():
|
|
290
|
+
raise UserError(f"MtlsAuth: cert_file does not exist: {cert_path}")
|
|
291
|
+
if not key_path.is_file():
|
|
292
|
+
raise UserError(f"MtlsAuth: key_file does not exist: {key_path}")
|
|
293
|
+
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
|
294
|
+
pwd = self._resolve_password()
|
|
295
|
+
ctx.load_cert_chain(str(cert_path), str(key_path), password=pwd)
|
|
296
|
+
if self.ca_file:
|
|
297
|
+
ca_path = Path(self.ca_file).expanduser()
|
|
298
|
+
if not ca_path.is_file():
|
|
299
|
+
raise UserError(f"MtlsAuth: ca_file does not exist: {ca_path}")
|
|
300
|
+
ctx.load_verify_locations(str(ca_path))
|
|
301
|
+
return ctx
|
|
302
|
+
|
|
303
|
+
async def refresh(self) -> None:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def supports_websocket_auth(self) -> bool:
|
|
307
|
+
return True
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---- OAuth2Auth (v0.6 stub) ------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass
|
|
314
|
+
class OAuth2Auth:
|
|
315
|
+
"""Placeholder for OAuth2 access-token-with-refresh flows. v0.6 will
|
|
316
|
+
fill this in (token cache file + refresh round-trip). v0.5 surfaces
|
|
317
|
+
the type so doctor / install wizard can render a "coming v0.6" row
|
|
318
|
+
without conditional imports.
|
|
319
|
+
|
|
320
|
+
Calling any method raises `UserError("not implemented in v0.5")`.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
kind: str = "oauth2"
|
|
324
|
+
issuer_url: str = ""
|
|
325
|
+
client_id: str = ""
|
|
326
|
+
client_secret_env: str | None = None
|
|
327
|
+
refresh_token_file: str | None = None
|
|
328
|
+
scopes: tuple[str, ...] = ()
|
|
329
|
+
|
|
330
|
+
def _not_yet(self) -> "UserError":
|
|
331
|
+
return UserError(
|
|
332
|
+
"OAuth2Auth is a v0.6 placeholder; please use BearerTokenAuth "
|
|
333
|
+
"with a manually-obtained access token until v0.6 ships")
|
|
334
|
+
|
|
335
|
+
async def headers(self) -> dict[str, str]:
|
|
336
|
+
raise self._not_yet()
|
|
337
|
+
|
|
338
|
+
async def url_with_auth(self, url: str) -> str:
|
|
339
|
+
raise self._not_yet()
|
|
340
|
+
|
|
341
|
+
def ssl_context(self) -> ssl.SSLContext | None:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
async def refresh(self) -> None:
|
|
345
|
+
raise self._not_yet()
|
|
346
|
+
|
|
347
|
+
def supports_websocket_auth(self) -> bool:
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---- factory ---------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def build_auth_provider(
|
|
355
|
+
auth_kind: str, config: dict,
|
|
356
|
+
) -> AuthProvider:
|
|
357
|
+
"""Build the provider from a `[backends.cloud.auth.<kind>]` config dict.
|
|
358
|
+
|
|
359
|
+
Centralized factory keeps the cloud-backend module from depending on
|
|
360
|
+
every concrete class directly — it just dispatches on the string kind.
|
|
361
|
+
Unknown kinds raise UserError (caught by the resolver → exit code 1).
|
|
362
|
+
"""
|
|
363
|
+
config = config or {}
|
|
364
|
+
if auth_kind == "bearer":
|
|
365
|
+
return BearerTokenAuth(
|
|
366
|
+
token=config.get("token"),
|
|
367
|
+
token_env=config.get("token_env"),
|
|
368
|
+
header_name=config.get("header_name", "Authorization"),
|
|
369
|
+
header_prefix=config.get("header_prefix", "Bearer "),
|
|
370
|
+
)
|
|
371
|
+
if auth_kind == "basic":
|
|
372
|
+
return BasicAuth(
|
|
373
|
+
username=config.get("username"),
|
|
374
|
+
password=config.get("password"),
|
|
375
|
+
username_env=config.get("username_env"),
|
|
376
|
+
password_env=config.get("password_env"),
|
|
377
|
+
embed_in_url=bool(config.get("embed_in_url", False)),
|
|
378
|
+
)
|
|
379
|
+
if auth_kind == "mtls":
|
|
380
|
+
return MtlsAuth(
|
|
381
|
+
cert_file=config.get("cert_file", ""),
|
|
382
|
+
key_file=config.get("key_file", ""),
|
|
383
|
+
ca_file=config.get("ca_file"),
|
|
384
|
+
key_password_env=config.get("key_password_env"),
|
|
385
|
+
)
|
|
386
|
+
if auth_kind == "oauth2":
|
|
387
|
+
return OAuth2Auth(
|
|
388
|
+
issuer_url=config.get("issuer_url", ""),
|
|
389
|
+
client_id=config.get("client_id", ""),
|
|
390
|
+
client_secret_env=config.get("client_secret_env"),
|
|
391
|
+
refresh_token_file=config.get("refresh_token_file"),
|
|
392
|
+
scopes=tuple(config.get("scopes", ()) or ()),
|
|
393
|
+
)
|
|
394
|
+
raise UserError(
|
|
395
|
+
f"unknown auth_kind {auth_kind!r}; supported: bearer, basic, mtls, oauth2")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Backend registry.
|
|
2
|
+
|
|
3
|
+
Backends are plain Python classes registered explicitly — spec §8.5 forbids
|
|
4
|
+
plugin systems / dynamic loading. The order returned by `all_backends()` is
|
|
5
|
+
also the default fallback chain used by the resolver when `--backend` is unset.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from .base import Backend
|
|
12
|
+
from .env import EnvBackend
|
|
13
|
+
from .rdp import RdpBackend
|
|
14
|
+
from .extension import ExtensionBackend
|
|
15
|
+
from .cloud import CloudBackend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# (name, factory) — factories take a Config and return a Backend instance.
|
|
19
|
+
# Order is the documented fallback order: cheapest + most explicit first.
|
|
20
|
+
# `cloud` requires explicit config (endpoint + auth_kind) so we register it
|
|
21
|
+
# at the end — `resolver.resolve()` skips it in the fallback chain via
|
|
22
|
+
# the same exclusion list that also covers `extension`. Users must opt
|
|
23
|
+
# in with `--backend cloud` or `BD_BACKEND=cloud`.
|
|
24
|
+
_REGISTRY: list[tuple[str, Callable[..., Backend]]] = [
|
|
25
|
+
("env", EnvBackend),
|
|
26
|
+
("rdp", RdpBackend),
|
|
27
|
+
("extension", ExtensionBackend),
|
|
28
|
+
("cloud", CloudBackend),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def all_backends(cfg) -> list[Backend]:
|
|
33
|
+
return [factory(cfg) for _, factory in _REGISTRY]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def names() -> list[str]:
|
|
37
|
+
return [name for name, _ in _REGISTRY]
|
|
38
|
+
|
|
39
|
+
def kind_for(name: str) -> str | None:
|
|
40
|
+
"""The ``BackendKind`` of a registered backend (class attribute, no
|
|
41
|
+
instantiation), or ``None`` for unknown/unresolved names like ``"auto"``."""
|
|
42
|
+
for n, factory in _REGISTRY:
|
|
43
|
+
if n == name:
|
|
44
|
+
return getattr(factory, "kind", None)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_backend(name: str, cfg) -> Backend:
|
|
49
|
+
from ..errors import UserError
|
|
50
|
+
|
|
51
|
+
for n, factory in _REGISTRY:
|
|
52
|
+
if n == name:
|
|
53
|
+
return factory(cfg)
|
|
54
|
+
raise UserError(
|
|
55
|
+
f"unknown backend {name!r}; known: {', '.join(names())}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["Backend", "all_backends", "names", "get_backend", "kind_for"]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Backend Protocol + result types.
|
|
2
|
+
|
|
3
|
+
Spec §4.2: every backend answers exactly one question — "give me a browser-level
|
|
4
|
+
CDP ws URL or explain why you can't." Both `probe` (cheap, no side effects, drives
|
|
5
|
+
doctor) and `resolve` (returns a URL, still no ws open — only HTTP discovery /
|
|
6
|
+
filesystem reads) are mandatory.
|
|
7
|
+
|
|
8
|
+
The hard rule from §2.3 (handshake ↔ banner sync): even `resolve` must NOT open
|
|
9
|
+
a ws. Opening the ws is always the caller's job. Backends only return URLs.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Literal, Protocol
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---- enums ------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
BackendKind = Literal["UPSTREAM_WS", "LOCAL_RELAY"]
|
|
20
|
+
Mode = Literal["A", "B"]
|
|
21
|
+
# §5.2 enum (locked under schema_version=1).
|
|
22
|
+
# v0.5 added "auth-required" for the cloud backend (header/mTLS auth must be
|
|
23
|
+
# configured before connect). Adding values to this enum is a minor schema
|
|
24
|
+
# bump — Skill code matches by value, so new entries don't break old skills,
|
|
25
|
+
# they just appear as "unknown ux_cost" until skill catches up.
|
|
26
|
+
UxCost = Literal[
|
|
27
|
+
"none", "banner", "popup-per-ws+banner",
|
|
28
|
+
"extension-permission", "auth-required",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- result dataclasses -----------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ResolveResult:
|
|
36
|
+
"""Successful `resolve` return value.
|
|
37
|
+
|
|
38
|
+
`extras` mirrors §5.1's `--json` extras block. `isolated_profile` is only
|
|
39
|
+
meaningful for rdp; other backends leave it None.
|
|
40
|
+
"""
|
|
41
|
+
ws_url: str
|
|
42
|
+
backend: str
|
|
43
|
+
extras: dict = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class DoctorResult:
|
|
48
|
+
"""Per-backend doctor entry. Field set is locked under schema_version=1
|
|
49
|
+
(§5.2). Even when a value is unknown, the key must appear (=None) so Skill
|
|
50
|
+
code never needs existence checks.
|
|
51
|
+
|
|
52
|
+
`extras` (v0.5): per-backend free-form dict for backend-specific fields
|
|
53
|
+
that don't fit the locked schema. Skill code uses these via
|
|
54
|
+
`entry["extras"].get("...")`. Adding extras keys is a per-backend minor
|
|
55
|
+
bump, not a schema-level bump — `schema_version` stays 1.
|
|
56
|
+
"""
|
|
57
|
+
name: str
|
|
58
|
+
available: bool
|
|
59
|
+
ws_url: str | None = None # always None unless --probe-ws fired and succeeded
|
|
60
|
+
detail: str = ""
|
|
61
|
+
ux_warning: str | None = None
|
|
62
|
+
needs_user_action: str | None = None
|
|
63
|
+
ux_cost: UxCost = "none"
|
|
64
|
+
extras: dict = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- Protocol ---------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
class Backend(Protocol):
|
|
70
|
+
"""All four v0.1 backends implement this. The Protocol is structural; no
|
|
71
|
+
ABC inheritance required — a duck-typed class works (mirrors browser-harness
|
|
72
|
+
style: protocols, not registries).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
kind: BackendKind
|
|
77
|
+
recommended_mode: Mode
|
|
78
|
+
ux_cost: UxCost
|
|
79
|
+
|
|
80
|
+
async def probe(self) -> DoctorResult:
|
|
81
|
+
"""Cheap, side-effect-free. NO ws open. NO Chrome popup."""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
async def resolve(self, timeout: float) -> ResolveResult:
|
|
85
|
+
"""Return a ws URL, or raise `Unavailable`. Still no ws open."""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
# ---- session-model capability verbs (P4) --------------------------------
|
|
89
|
+
# caps() is implemented now; the workspace_*/page_* verbs are filled in by
|
|
90
|
+
# Phase 5 (extension per-session bucketing) and Phase 6 (rdp per-session
|
|
91
|
+
# daemon). They live on the Protocol so the session layer can dispatch
|
|
92
|
+
# backend-agnostically.
|
|
93
|
+
|
|
94
|
+
def caps(self) -> dict:
|
|
95
|
+
"""Static capability flags:
|
|
96
|
+
``{"owns_browser": bool, "supports_browser_context": bool}``.
|
|
97
|
+
|
|
98
|
+
``owns_browser`` — this backend launches/owns the Chrome process (rdp
|
|
99
|
+
--create) vs. attaches to the user's existing browser (extension).
|
|
100
|
+
``supports_browser_context`` — can isolate sessions via real
|
|
101
|
+
browser contexts rather than tab groups.
|
|
102
|
+
"""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
async def workspace_create(self, session_id: str) -> dict:
|
|
106
|
+
"""Create a per-session workspace (extension: a tab group; rdp: noop/
|
|
107
|
+
launch). Phase 5/6."""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
async def workspace_attach(self, session_id: str, target) -> dict:
|
|
111
|
+
"""Attach an existing workspace/browser to a session. Phase 5/6."""
|
|
112
|
+
...
|
|
113
|
+
|
|
114
|
+
async def page_new(self, session_id: str, url: str) -> dict:
|
|
115
|
+
"""Open a new page inside the session's workspace. Phase 5/6."""
|
|
116
|
+
...
|
|
117
|
+
|
|
118
|
+
async def page_attach_active(self, session_id: str) -> dict:
|
|
119
|
+
"""Pull the focused tab into the session's workspace. Phase 5/6."""
|
|
120
|
+
...
|