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,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
+ ...