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,783 @@
1
+ """Extension relay ws server (v0.4 — spec §8.4).
2
+
3
+ The relay sits between the Chrome extension (`chrome-extension/background.js`)
4
+ and the daemon's CDP proxy. When `backend=extension` is active in Mode B,
5
+ this server replaces the conventional upstream-ws-to-Chrome path: the
6
+ "upstream" is the relay + the extension's `chrome.debugger` calls.
7
+
8
+ Protocol on the wire (extension ↔ daemon, all JSON text frames):
9
+
10
+ daemon → extension:
11
+ {"type":"command","id":N,"tabId":42,"method":"Page.navigate","params":{...}}
12
+ {"type":"queryActiveTab","id":N}
13
+ {"type":"detach","id":N,"tabId":42}
14
+
15
+ extension → daemon:
16
+ {"type":"hello","installId":"...","browser":"chrome","version":"1.2.3"}
17
+ {"type":"response","id":N,"result":{...}}
18
+ {"type":"response","id":N,"error":{"code":-32000,"message":"..."}}
19
+ {"type":"attached","tabId":42,"targetInfo":{"url":"...","title":"..."}}
20
+ {"type":"detached","tabId":42}
21
+ {"type":"event","tabId":42,"method":"Page.frameNavigated","params":{...}}
22
+ {"type":"activeTab","id":N,"tabId":42,"url":"...","title":"..."}
23
+
24
+ Design points:
25
+
26
+ - **Anti-CSRF** (§A.4 OpenCLI borrow): web-page Origins on the ws upgrade
27
+ are refused with HTTP 403. Drive-by browser pages can issue cross-origin
28
+ ws upgrades unless we filter — Origin is the only header browsers can't
29
+ lie about for ws. `chrome-extension://...` Origins are allowed (Chrome
30
+ MV3 SW does emit one on connect — earlier docs claimed otherwise; real-
31
+ world Chrome 144+ proves it does). Missing Origin is also allowed: that
32
+ shape only comes from non-browser tooling (curl, raw ws clients) that
33
+ can't be exploited through a drive-by page.
34
+ - **HTTP /__status__** doctor hook: `GET http://127.0.0.1:19989/__status__`
35
+ returns `{"running":true,"extensions":N,"installIds":[...]}` so the v0.1
36
+ doctor probe can answer `available=true` without opening a ws.
37
+ - **3-retry `chrome.debugger` conflict** (§A.4 OpenCLI borrow): when the
38
+ extension responds with `error.message` containing "already attached"
39
+ (DevTools, another extension), the relay's `send_command` retries up to
40
+ 3 times with exponential backoff. Surfacing the final failure is the
41
+ caller's job.
42
+ - **Ghost targets** (spec §8.4): the relay tracks which tabs the user has
43
+ attached via the popup; the daemon's router answers `Target.getTargets`
44
+ from this list when the extension backend is active.
45
+
46
+ The relay is intentionally **synchronous and explicit**: no auto-attach,
47
+ no retry framework, no plugin system. It mirrors the daemon's core ethos.
48
+ """
49
+ from __future__ import annotations
50
+
51
+ import asyncio
52
+ import contextlib
53
+ import http
54
+ import json
55
+ import logging
56
+ import time
57
+ from dataclasses import dataclass, field
58
+ from typing import Any, Awaitable, Callable
59
+
60
+ import websockets
61
+ from websockets.asyncio.server import ServerConnection, serve
62
+
63
+ from browserwright.version import EXTENSION_PROTOCOL_VERSION
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+
68
+ # Spec §8.4: default relay port is 19989. Originally we mirrored playwriter's
69
+ # 19988 (`playwriter/src/cdp-relay.ts:71-90`) to ride its conflict-awareness
70
+ # convention; in practice users run both daemons side-by-side, so we shifted
71
+ # one port up to coexist. Tests can override via `RelayServer(port=0)` to
72
+ # bind an ephemeral port.
73
+ DEFAULT_RELAY_PORT = 19989
74
+
75
+ # Spec §A.4: OpenCLI `extension/src/cdp.ts:96-150` retries 3 times when
76
+ # chrome.debugger.attach fails with "Another debugger is already attached".
77
+ # We mirror the same cadence — keeps the user-visible retry feel consistent
78
+ # with the playwriter / OpenCLI experience.
79
+ ATTACH_RETRY_LIMIT = 3
80
+ ATTACH_RETRY_BACKOFF = (0.1, 0.3, 0.8) # seconds; len must equal ATTACH_RETRY_LIMIT
81
+
82
+
83
+ @dataclass
84
+ class GhostTarget:
85
+ """One user-attached tab visible as a CDP target.
86
+
87
+ `target_id` is daemon-fabricated (we use `ext-tab-<tabId>`) so the regular
88
+ router session/attacher tables don't need extension-specific code.
89
+ """
90
+ target_id: str
91
+ tab_id: int
92
+ url: str = ""
93
+ title: str = ""
94
+ type: str = "page"
95
+ install_id: str = "" # which extension owns this tab — for multi-extension support
96
+
97
+
98
+ @dataclass
99
+ class _ExtensionConn:
100
+ """One connected extension. v0.4 supports multiple in theory (e.g., user
101
+ runs Chrome + Edge with the extension installed in both); the daemon
102
+ fans commands out to whichever extension owns the target by `install_id`.
103
+ """
104
+ conn: ServerConnection
105
+ install_id: str = ""
106
+ browser: str = ""
107
+ version: str = ""
108
+ browserwright_version: str = ""
109
+ extension_protocol_version: str = ""
110
+ hello_received: asyncio.Event = field(default_factory=asyncio.Event)
111
+ pending: dict[int, asyncio.Future] = field(default_factory=dict)
112
+ tabs: dict[int, GhostTarget] = field(default_factory=dict)
113
+
114
+
115
+ class RelayServer:
116
+ """ws://127.0.0.1:19989 — extension talks to us here.
117
+
118
+ Lifecycle: `start()` binds; `wait_ready(timeout)` blocks until at least
119
+ one extension has sent `hello`; `stop()` closes everything cleanly.
120
+ """
121
+
122
+ def __init__(self, *, port: int = DEFAULT_RELAY_PORT,
123
+ host: str = "127.0.0.1"):
124
+ self._port = port
125
+ self._host = host
126
+ self._server: Any = None
127
+ self._extensions: dict[str, _ExtensionConn] = {}
128
+ self._next_cmd_id: int = 1
129
+ self._first_ready = asyncio.Event()
130
+ # Hook: every event-frame from any extension gets called back here so
131
+ # the daemon's CDP proxy can route it. Set by the listener.
132
+ self._on_event: Callable[[dict], Awaitable[None]] | None = None
133
+ # Task #tab-handle-model PR2: the Playwright facade needs to observe the
134
+ # SAME extension event stream as the agent path (so it can translate
135
+ # `Page.frameNavigated` etc. into per-Playwright-session frames), but the
136
+ # single `_on_event` slot is already claimed by the agent's
137
+ # ExtensionUpstream. We keep a fan-out set of ADDITIONAL listeners that
138
+ # the relay calls alongside `_on_event` — the facade registers/removes
139
+ # itself here per connection without disturbing the agent handler.
140
+ self._event_listeners: set[Callable[[dict], Awaitable[None]]] = set()
141
+ # Shared extension-session state. The daemon's primary
142
+ # ExtensionUpstream and every Playwright facade bridge are separate
143
+ # adapter instances over this one relay, so relay-scoped state is the
144
+ # in-process truth they can all see immediately.
145
+ self._session_groups: dict[str, int] = {}
146
+ self._session_announce_events: dict[str, asyncio.Event] = {}
147
+
148
+ # ---- lifecycle -------------------------------------------------------
149
+
150
+ async def start(self) -> int:
151
+ """Bind the relay. Returns the actual port (useful with port=0)."""
152
+ self._server = await serve(
153
+ self._handler,
154
+ self._host,
155
+ self._port,
156
+ process_request=self._process_request,
157
+ compression=None,
158
+ ping_interval=20,
159
+ ping_timeout=20,
160
+ max_size=None, # screenshots can exceed the 1 MiB default
161
+ )
162
+ # Discover the actually-bound port (for port=0 tests).
163
+ for sock in self._server.sockets:
164
+ sa = sock.getsockname()
165
+ if isinstance(sa, tuple) and len(sa) >= 2:
166
+ self._port = sa[1]
167
+ break
168
+ logger.info("extension relay listening on ws://%s:%d",
169
+ self._host, self._port)
170
+ return self._port
171
+
172
+ async def stop(self) -> None:
173
+ if self._server is None:
174
+ return
175
+ # Cancel every pending command future so callers see a clean error.
176
+ for ext in list(self._extensions.values()):
177
+ for fut in list(ext.pending.values()):
178
+ if not fut.done():
179
+ fut.set_exception(ConnectionError("relay shutting down"))
180
+ try:
181
+ await ext.conn.close(code=1001, reason="relay shutdown")
182
+ except Exception:
183
+ pass
184
+ self._extensions.clear()
185
+ self._server.close()
186
+ with contextlib.suppress(Exception):
187
+ await self._server.wait_closed()
188
+ self._server = None
189
+
190
+ async def wait_ready(self, timeout: float = 30.0) -> None:
191
+ """Block until at least one extension has sent its `hello`."""
192
+ await asyncio.wait_for(self._first_ready.wait(), timeout=timeout)
193
+
194
+ @property
195
+ def port(self) -> int:
196
+ return self._port
197
+
198
+ @property
199
+ def is_ready(self) -> bool:
200
+ return any(e.hello_received.is_set() for e in self._extensions.values())
201
+
202
+ def set_event_handler(
203
+ self, handler: Callable[[dict], Awaitable[None]] | None,
204
+ ) -> None:
205
+ """Register THE primary coroutine that receives every async event from
206
+ the extension (`Page.frameNavigated` etc). The daemon's router uses this
207
+ to translate extension events back into CDP frames for clients.
208
+
209
+ This is single-slot (the agent path). Secondary observers (the
210
+ Playwright facade) use `add_event_listener` / `remove_event_listener`.
211
+ """
212
+ self._on_event = handler
213
+
214
+ def add_event_listener(
215
+ self, handler: Callable[[dict], Awaitable[None]],
216
+ ) -> None:
217
+ """Register an ADDITIONAL fan-out observer of the extension event
218
+ stream (Task #tab-handle-model PR2). Called alongside the primary
219
+ `_on_event` handler — used by the Playwright facade so it sees the same
220
+ `attached`/`event` stream the agent path does without stealing the
221
+ single primary slot."""
222
+ self._event_listeners.add(handler)
223
+
224
+ def remove_event_listener(
225
+ self, handler: Callable[[dict], Awaitable[None]],
226
+ ) -> None:
227
+ """Drop a fan-out observer (facade disconnect/stop). Idempotent."""
228
+ self._event_listeners.discard(handler)
229
+
230
+ def bind_session_group(self, session_id: str, group_id: int) -> None:
231
+ if isinstance(group_id, int) and group_id >= 0:
232
+ self._session_groups[session_id] = group_id
233
+
234
+ def session_group(self, session_id: str | None) -> int | None:
235
+ if not session_id:
236
+ return None
237
+ return self._session_groups.get(session_id)
238
+
239
+ def reset_session_announce(self, session_id: str | None) -> None:
240
+ if not session_id:
241
+ return
242
+ self._session_announce_events.setdefault(session_id, asyncio.Event()).clear()
243
+
244
+ def set_session_announce(self, session_id: str | None) -> None:
245
+ if not session_id:
246
+ return
247
+ self._session_announce_events.setdefault(session_id, asyncio.Event()).set()
248
+
249
+ async def wait_session_announce(self, session_id: str,
250
+ timeout: float = 2.0) -> bool:
251
+ event = self._session_announce_events.setdefault(
252
+ session_id, asyncio.Event())
253
+ try:
254
+ await asyncio.wait_for(event.wait(), timeout=max(0.0, timeout))
255
+ return True
256
+ except asyncio.TimeoutError:
257
+ return False
258
+
259
+ # ---- public command API (used by extension upstream wrapper) ---------
260
+
261
+ def list_ghost_targets(self) -> list[GhostTarget]:
262
+ """All currently-attached tabs across every extension."""
263
+ out: list[GhostTarget] = []
264
+ for ext in self._extensions.values():
265
+ out.extend(ext.tabs.values())
266
+ return out
267
+
268
+ async def query_active_tab(self, *, timeout: float = 5.0) -> dict | None:
269
+ """Spec §8.4: `BrowserwrightDaemon.getActiveTab` accuracy=`exact` path.
270
+
271
+ Asks the first ready extension `chrome.tabs.query({active:true})`. If
272
+ no extension is connected, returns None — caller falls back to the
273
+ heuristic-recent-activate table.
274
+ """
275
+ ext = self._pick_active_extension()
276
+ if ext is None:
277
+ return None
278
+ return await self._request(ext, {"type": "queryActiveTab"},
279
+ timeout=timeout)
280
+
281
+ async def query_group_tabs(self, group_name: str | None = None, *,
282
+ group_id: int | None = None,
283
+ timeout: float = 5.0) -> dict | None:
284
+ """Live membership query: ask the extension for the tabs of the
285
+ session's tab group. ``group_id`` is the durable primary key (the
286
+ numeric Chrome groupId); ``group_name`` is accepted for older callers
287
+ but is not a lookup key because titles are not unique. Returns
288
+ ``{"groupId":int,"tabs":
289
+ [{tabId,url,title,active,lastAccessed}, ...]}`` — ``groupId == -1`` /
290
+ empty tabs when no group matches (the session's browser has no tabs).
291
+ Returns None when no extension is connected (mirrors
292
+ query_active_tab's caller-falls-back contract)."""
293
+ ext = self._pick_active_extension()
294
+ if ext is None:
295
+ return None
296
+ body: dict = {"type": "queryGroup"}
297
+ if group_name:
298
+ body["groupName"] = group_name
299
+ if isinstance(group_id, int) and group_id >= 0:
300
+ body["groupId"] = group_id
301
+ return await self._request(ext, body, timeout=timeout)
302
+
303
+ async def attach_active_tab(self, *,
304
+ group_name: str | None = None,
305
+ group_id: int | None = None,
306
+ timeout: float = 10.0) -> GhostTarget:
307
+ """Daemon-driven adopt (docs C1): ask the extension to MOVE Chrome's
308
+ currently-focused-window active tab into this session's tab group and
309
+ attach the debugger. ``group_id`` identifies the destination group;
310
+ ``group_name`` is only the title to apply if a new group is created.
311
+ The extension refuses (error) if the focused tab already belongs to a
312
+ DIFFERENT session's group.
313
+
314
+ The adopted tab is a regular group member — it closes with the group on
315
+ ``end_session`` (no separate borrowed/owned flag).
316
+
317
+ Retries on "already attached" the same way `attach_tab` does. Returns
318
+ the GhostTarget (with a ``group_id`` attribute) once the extension
319
+ confirms. The extension also emits `attached`, so the ghost ends up in
320
+ `ext.tabs` for the regular routing path.
321
+ """
322
+ ext = self._pick_active_extension()
323
+ if ext is None:
324
+ raise RuntimeError("no extension connected")
325
+ last_err: Exception | None = None
326
+ body: dict = {"type": "attachActive"}
327
+ if group_name:
328
+ body["groupName"] = group_name
329
+ if isinstance(group_id, int) and group_id >= 0:
330
+ body["groupId"] = group_id
331
+ for i in range(ATTACH_RETRY_LIMIT):
332
+ try:
333
+ result = await self._request(ext, body, timeout=timeout)
334
+ info = result or {}
335
+ tab_id_raw = info.get("tabId")
336
+ if not isinstance(tab_id_raw, int):
337
+ raise RuntimeError(
338
+ f"attachActive response missing tabId: {info!r}")
339
+ gt = GhostTarget(
340
+ target_id=f"ext-tab-{tab_id_raw}",
341
+ tab_id=tab_id_raw,
342
+ url=str(info.get("url", "")),
343
+ title=str(info.get("title", "")),
344
+ install_id=ext.install_id,
345
+ )
346
+ try:
347
+ gt.group_id = int(info.get("groupId", -1)) # type: ignore[attr-defined]
348
+ except (TypeError, ValueError):
349
+ gt.group_id = -1 # type: ignore[attr-defined]
350
+ ext.tabs[tab_id_raw] = gt
351
+ return gt
352
+ except _CommandError as e:
353
+ last_err = e
354
+ if "already attached" not in (e.message or "").lower():
355
+ raise
356
+ await asyncio.sleep(ATTACH_RETRY_BACKOFF[i])
357
+ raise last_err if last_err is not None else RuntimeError(
358
+ "attach active failed (no error captured)")
359
+
360
+ async def attach_tab(self, tab_id: int, *,
361
+ timeout: float = 5.0) -> GhostTarget:
362
+ """Tell the extension to `chrome.debugger.attach({tabId})`. Retries
363
+ up to ATTACH_RETRY_LIMIT on "already attached" errors.
364
+
365
+ Returns the GhostTarget once the extension confirms.
366
+ """
367
+ ext = self._pick_active_extension()
368
+ if ext is None:
369
+ raise RuntimeError("no extension connected")
370
+ # Idempotency: extension may already hold chrome.debugger.attach on
371
+ # this tab (popup click, prior daemon lifecycle — the SW survives
372
+ # daemon restarts and re-announces attached tabs on reconnect, so
373
+ # ext.tabs is authoritative). Skip the redundant attach call to
374
+ # avoid "Another debugger is already attached" from Chrome.
375
+ existing = ext.tabs.get(tab_id)
376
+ if existing is not None:
377
+ return existing
378
+ last_err: Exception | None = None
379
+ for i in range(ATTACH_RETRY_LIMIT):
380
+ try:
381
+ result = await self._request(
382
+ ext, {"type": "attach", "tabId": tab_id}, timeout=timeout)
383
+ # Result shape: {"targetInfo": {...}}
384
+ info = (result or {}).get("targetInfo") or {}
385
+ gt = GhostTarget(
386
+ target_id=f"ext-tab-{tab_id}",
387
+ tab_id=tab_id,
388
+ url=str(info.get("url", "")),
389
+ title=str(info.get("title", "")),
390
+ install_id=ext.install_id,
391
+ )
392
+ ext.tabs[tab_id] = gt
393
+ return gt
394
+ except _CommandError as e:
395
+ last_err = e
396
+ if "already attached" not in (e.message or "").lower():
397
+ raise
398
+ await asyncio.sleep(ATTACH_RETRY_BACKOFF[i])
399
+ # Exhausted retries.
400
+ raise last_err if last_err is not None else RuntimeError(
401
+ "attach failed (no error captured)")
402
+
403
+ async def detach_tab(self, tab_id: int, *,
404
+ timeout: float = 5.0) -> None:
405
+ ext = self._extension_for_tab(tab_id)
406
+ if ext is None:
407
+ return
408
+ try:
409
+ await self._request(
410
+ ext, {"type": "detach", "tabId": tab_id}, timeout=timeout)
411
+ except Exception as e:
412
+ logger.warning("detach(tab=%d) failed: %r", tab_id, e)
413
+ ext.tabs.pop(tab_id, None)
414
+
415
+ async def create_background_tab(
416
+ self,
417
+ url: str,
418
+ *,
419
+ group_name: str | None = "Agent",
420
+ group_id: int | None = None,
421
+ background: bool = True,
422
+ timeout: float = 10.0,
423
+ ) -> GhostTarget:
424
+ """Spec Phase B Feature 1: open a tab in the background (active=false)
425
+ in the session's tab group, attach ``chrome.debugger`` to it, and
426
+ return a GhostTarget bound to the new tab. The user's currently-active
427
+ tab keeps focus.
428
+
429
+ The session's group is identified by ``group_id`` (the durable numeric
430
+ Chrome groupId) when known; ``group_name`` (= session name) is only the
431
+ human-visible title to use when a new group must be created. The
432
+ extension resolves by id, or creates a new group when the id is absent
433
+ or invalid.
434
+
435
+ ``group_name=None`` and no ``group_id`` skips the grouping step; the
436
+ resulting GhostTarget carries the extension-reported ``group_id``
437
+ (which may be ``-1`` when no group was requested or grouping failed in
438
+ a recoverable way).
439
+ """
440
+ ext = self._pick_active_extension()
441
+ if ext is None:
442
+ raise RuntimeError("no extension connected")
443
+ body: dict = {"type": "createTab", "url": url}
444
+ if group_name:
445
+ body["groupName"] = group_name
446
+ if isinstance(group_id, int) and group_id >= 0:
447
+ body["groupId"] = group_id
448
+ # background=False opens the tab in the foreground (active:true);
449
+ # default True keeps the user's focus tab. Only sent when foreground
450
+ # is requested so existing extensions default to background.
451
+ if not background:
452
+ body["background"] = False
453
+ result = await self._request(ext, body, timeout=timeout) or {}
454
+ tab_id = int(result.get("tabId", -1))
455
+ if tab_id < 0:
456
+ raise RuntimeError(
457
+ f"extension createTab returned invalid tabId: {result!r}")
458
+ gt = GhostTarget(
459
+ target_id=f"ext-tab-{tab_id}",
460
+ tab_id=tab_id,
461
+ url=str(result.get("url", url)),
462
+ title=str(result.get("title", "")),
463
+ install_id=ext.install_id,
464
+ )
465
+ # Stash a group_id attribute on the dataclass instance for callers
466
+ # that want to expose it (we don't widen GhostTarget's dataclass
467
+ # shape — using object.__setattr__ keeps the schema-locked fields
468
+ # frozen for everyone else).
469
+ try:
470
+ gt.group_id = int(result.get("groupId", -1)) # type: ignore[attr-defined]
471
+ except (TypeError, ValueError):
472
+ gt.group_id = -1 # type: ignore[attr-defined]
473
+ ext.tabs[tab_id] = gt
474
+ return gt
475
+
476
+ async def close_tab(self, tab_id: int, *,
477
+ timeout: float = 5.0) -> None:
478
+ """Spec Phase B Feature 2: close a tab via chrome.tabs.remove (not a
479
+ debugger detach). Clears the ghost-target entry whether or not the
480
+ extension confirmed.
481
+
482
+ Raises if no extension is connected at all — silently returning
483
+ success here would lie to callers about a close that never went
484
+ over the wire. `_extension_for_tab` already falls back to any ready
485
+ extension if no ext owns the tab (race between popup attach and
486
+ ghost registration), so a None return means "no extension exists"
487
+ rather than "no extension owns this specific tab id".
488
+ """
489
+ ext = self._extension_for_tab(tab_id)
490
+ if ext is None:
491
+ raise RuntimeError(f"no extension knows tab {tab_id}")
492
+ try:
493
+ await self._request(
494
+ ext, {"type": "closeTab", "tabId": tab_id}, timeout=timeout)
495
+ except Exception as e:
496
+ logger.warning("close_tab(tab=%d) failed: %r", tab_id, e)
497
+ ext.tabs.pop(tab_id, None)
498
+
499
+ async def send_cdp(self, tab_id: int, method: str, params: dict,
500
+ *, timeout: float = 10.0) -> dict:
501
+ """Forward a CDP method+params through the extension's
502
+ `chrome.debugger.sendCommand(tabId, method, params)`.
503
+ """
504
+ ext = self._extension_for_tab(tab_id)
505
+ if ext is None:
506
+ raise RuntimeError(f"no extension owns tab {tab_id}")
507
+ return await self._request(ext, {
508
+ "type": "command",
509
+ "tabId": tab_id,
510
+ "method": method,
511
+ "params": params,
512
+ }, timeout=timeout) or {}
513
+
514
+ async def userscript_request(self, verb: str, payload: dict,
515
+ *, timeout: float = 5.0) -> dict | None:
516
+ """Forward a userscript control request to any ready extension.
517
+
518
+ Userscript operations are extension-global rather than tab-scoped, so
519
+ unlike ``send_cdp`` they only need a connected extension, not a tab
520
+ owner.
521
+ """
522
+ ext = self._pick_active_extension()
523
+ if ext is None:
524
+ raise RuntimeError("no extension connected")
525
+ return await self._request(
526
+ ext, {"type": f"userscript.{verb}", **payload}, timeout=timeout)
527
+
528
+ # ---- internals -------------------------------------------------------
529
+
530
+ def _pick_active_extension(self) -> _ExtensionConn | None:
531
+ for ext in self._extensions.values():
532
+ if ext.hello_received.is_set():
533
+ return ext
534
+ return None
535
+
536
+ def _extension_for_tab(self, tab_id: int) -> _ExtensionConn | None:
537
+ for ext in self._extensions.values():
538
+ if tab_id in ext.tabs:
539
+ return ext
540
+ # Fall back to any ready extension — for tabs the extension is about
541
+ # to attach to (race between popup click and ghost registration).
542
+ return self._pick_active_extension()
543
+
544
+ def _alloc_id(self) -> int:
545
+ v = self._next_cmd_id
546
+ self._next_cmd_id += 1
547
+ return v
548
+
549
+ async def _request(self, ext: _ExtensionConn, body: dict, *,
550
+ timeout: float) -> dict | None:
551
+ cmd_id = self._alloc_id()
552
+ body = {**body, "id": cmd_id}
553
+ loop = asyncio.get_running_loop()
554
+ fut: asyncio.Future = loop.create_future()
555
+ ext.pending[cmd_id] = fut
556
+ try:
557
+ await ext.conn.send(json.dumps(body))
558
+ return await asyncio.wait_for(fut, timeout=timeout)
559
+ finally:
560
+ ext.pending.pop(cmd_id, None)
561
+
562
+ # ---- ws handlers -----------------------------------------------------
563
+
564
+ def _process_request(self, conn: ServerConnection, request) -> Any:
565
+ """Intercept the HTTP handshake before upgrade.
566
+
567
+ - `GET /__status__` answered as JSON (doctor probe hook).
568
+ - Web-page `Origin` header → 403 (anti-CSRF, OpenCLI borrow).
569
+ - `Origin: chrome-extension://<id>` → allowed. NOTE: this admits ANY
570
+ extension installed in the user's Chrome profile, not just ours.
571
+ We rely on (a) the daemon binding to 127.0.0.1 (local-only) and
572
+ (b) the user-trusted extension install model. A malicious
573
+ extension on the same profile already has `chrome.debugger`
574
+ primitives strictly more powerful than what the relay exposes,
575
+ so admitting unknown-id extension Origins here doesn't widen the
576
+ attack surface beyond what the user already implicitly trusts.
577
+ - Missing Origin → allowed (curl, raw ws clients — not exploitable
578
+ via drive-by page since the browser would always set Origin).
579
+ """
580
+ path = request.path or "/"
581
+ if path.startswith("/__status__"):
582
+ extensions = [
583
+ {
584
+ "install_id": getattr(e, "install_id", ""),
585
+ "browser": getattr(e, "browser", ""),
586
+ "version": getattr(e, "version", ""),
587
+ "browserwright_version": getattr(e, "browserwright_version", ""),
588
+ "extension_protocol_version": getattr(
589
+ e, "extension_protocol_version", ""
590
+ ),
591
+ "compatible": (
592
+ getattr(e, "extension_protocol_version", "")
593
+ in ("", EXTENSION_PROTOCOL_VERSION)
594
+ ),
595
+ }
596
+ for e in self._extensions.values()
597
+ if e.hello_received.is_set()
598
+ ]
599
+ body = json.dumps({
600
+ "running": True,
601
+ "extensions": len(self._extensions),
602
+ "install_ids": [e["install_id"] for e in extensions],
603
+ "extension_protocol_version": EXTENSION_PROTOCOL_VERSION,
604
+ "extension_details": extensions,
605
+ "tab_count": sum(len(e.tabs) for e in self._extensions.values()),
606
+ })
607
+ resp = conn.respond(http.HTTPStatus.OK, body)
608
+ resp.headers["Content-Type"] = "application/json"
609
+ return resp
610
+
611
+ # Anti-CSRF: refuse web-page Origins. Allow Origin: chrome-extension://*
612
+ # — note this admits ANY extension installed in the user's profile, not
613
+ # just ours. We rely on the daemon binding to 127.0.0.1 + the user-
614
+ # trusted extension install model. A malicious extension on the same
615
+ # profile already has chrome.debugger primitives strictly more powerful
616
+ # than what the relay exposes. Chrome MV3 SW does emit Origin
617
+ # (chrome-extension://<id>) on ws upgrades from Chrome 144+ — earlier
618
+ # comments here claimed otherwise and 403'd legitimate extension
619
+ # connections. We allow that prefix and 403 anything else non-empty.
620
+ origin = request.headers.get("Origin", "") or request.headers.get("origin", "")
621
+ if origin and not origin.startswith("chrome-extension://"):
622
+ resp = conn.respond(
623
+ http.HTTPStatus.FORBIDDEN,
624
+ "extension relay refuses non-extension Origin (anti-CSRF)\n",
625
+ )
626
+ return resp
627
+ return None # allow upgrade
628
+
629
+ async def _handler(self, conn: ServerConnection) -> None:
630
+ ext = _ExtensionConn(conn=conn)
631
+ # Use the conn's id() as a temp key until hello arrives.
632
+ temp_key = f"_pending-{id(conn)}"
633
+ self._extensions[temp_key] = ext
634
+ try:
635
+ async for raw in conn:
636
+ if not isinstance(raw, (str, bytes)):
637
+ continue
638
+ text = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
639
+ try:
640
+ msg = json.loads(text)
641
+ except (ValueError, TypeError):
642
+ logger.warning("extension sent non-JSON: %s", text[:80])
643
+ continue
644
+ if not isinstance(msg, dict):
645
+ continue
646
+ await self._dispatch_from_extension(ext, temp_key, msg)
647
+ except websockets.exceptions.ConnectionClosed:
648
+ pass
649
+ except Exception as e:
650
+ logger.warning("extension handler crashed: %r", e)
651
+ finally:
652
+ key = ext.install_id or temp_key
653
+ self._extensions.pop(key, None)
654
+ self._extensions.pop(temp_key, None)
655
+ for fut in list(ext.pending.values()):
656
+ if not fut.done():
657
+ fut.set_exception(ConnectionError("extension disconnected"))
658
+
659
+ async def _dispatch_from_extension(self, ext: _ExtensionConn,
660
+ temp_key: str, msg: dict) -> None:
661
+ kind = msg.get("type")
662
+
663
+ if kind == "hello":
664
+ ext.install_id = str(msg.get("installId") or "")
665
+ ext.browser = str(msg.get("browser") or "")
666
+ ext.version = str(msg.get("version") or "")
667
+ ext.browserwright_version = str(msg.get("browserwrightVersion") or ext.version)
668
+ ext.extension_protocol_version = str(
669
+ msg.get("extensionProtocolVersion") or ""
670
+ )
671
+ # Re-key the extension by install_id (so multiple extensions don't
672
+ # collide on temp_key collisions).
673
+ self._extensions.pop(temp_key, None)
674
+ self._extensions[ext.install_id or temp_key] = ext
675
+ ext.hello_received.set()
676
+ self._first_ready.set()
677
+ if (
678
+ ext.extension_protocol_version
679
+ and ext.extension_protocol_version != EXTENSION_PROTOCOL_VERSION
680
+ ):
681
+ logger.warning(
682
+ "extension protocol mismatch: install_id=%s extension=%s daemon=%s",
683
+ ext.install_id,
684
+ ext.extension_protocol_version,
685
+ EXTENSION_PROTOCOL_VERSION,
686
+ )
687
+ logger.info(
688
+ "extension hello: install_id=%s browser=%s version=%s protocol=%s",
689
+ ext.install_id,
690
+ ext.browser,
691
+ ext.version,
692
+ ext.extension_protocol_version or "legacy",
693
+ )
694
+ return
695
+
696
+ if kind == "ping":
697
+ # MV3 SW lifetime keepalive. Chrome only extends the SW's 30s idle
698
+ # timer on application-level ws frames (the `onmessage` kind);
699
+ # the protocol PING the `websockets` lib sends is handled by the
700
+ # browser internally and never reaches the SW. So the extension
701
+ # drives this app-level heartbeat and we echo back — both an
702
+ # outgoing send (in the extension) and an incoming onmessage
703
+ # (when this pong lands) reset the reaper.
704
+ try:
705
+ await ext.conn.send(json.dumps({
706
+ "type": "pong", "ts": msg.get("ts"),
707
+ }))
708
+ except Exception:
709
+ pass
710
+ return
711
+
712
+ if kind == "attached":
713
+ tab_id = int(msg.get("tabId", -1))
714
+ if tab_id < 0:
715
+ return
716
+ info = msg.get("targetInfo") or {}
717
+ ext.tabs[tab_id] = GhostTarget(
718
+ target_id=f"ext-tab-{tab_id}",
719
+ tab_id=tab_id,
720
+ url=str(info.get("url", "")),
721
+ title=str(info.get("title", "")),
722
+ install_id=ext.install_id,
723
+ )
724
+ # PR2: notify fan-out observers (the Playwright facade) of new tab
725
+ # lifecycle so they can synthesize Target.targetCreated /
726
+ # attachedToTarget for a live `connect_over_cdp` client. The agent
727
+ # path ignores these (its `_on_event` only handles `event`).
728
+ await self._fanout_listeners(msg)
729
+ return
730
+
731
+ if kind == "detached":
732
+ tab_id = int(msg.get("tabId", -1))
733
+ if tab_id < 0:
734
+ return
735
+ ext.tabs.pop(tab_id, None)
736
+ await self._fanout_listeners(msg)
737
+ return
738
+
739
+ if kind == "response":
740
+ rid = msg.get("id")
741
+ if isinstance(rid, int) and rid in ext.pending:
742
+ fut = ext.pending.pop(rid)
743
+ if not fut.done():
744
+ if "error" in msg:
745
+ err = msg["error"] or {}
746
+ fut.set_exception(_CommandError(
747
+ code=int(err.get("code", -32000)),
748
+ message=str(err.get("message", "extension error")),
749
+ ))
750
+ else:
751
+ fut.set_result(msg.get("result") or {})
752
+ return
753
+
754
+ if kind == "event":
755
+ if self._on_event is not None:
756
+ try:
757
+ await self._on_event(msg)
758
+ except Exception as e:
759
+ logger.warning("relay event handler raised: %r", e)
760
+ await self._fanout_listeners(msg)
761
+ return
762
+
763
+ logger.debug("extension sent unknown type %r: %s", kind, str(msg)[:100])
764
+
765
+ async def _fanout_listeners(self, msg: dict) -> None:
766
+ """Call every additional fan-out observer with the raw extension
767
+ message (PR2). Isolated from the primary `_on_event` so one observer
768
+ raising can't drop the message for the others or the agent path."""
769
+ for listener in list(self._event_listeners):
770
+ try:
771
+ await listener(msg)
772
+ except Exception as e: # noqa: BLE001
773
+ logger.warning("relay fan-out listener raised: %r", e)
774
+
775
+
776
+ class _CommandError(Exception):
777
+ """Wrapped extension-side CDP error. Surfaced to the caller in
778
+ `send_cdp` / `attach_tab` so the daemon can map to CDP -32xxx codes."""
779
+
780
+ def __init__(self, *, code: int, message: str):
781
+ super().__init__(message)
782
+ self.code = code
783
+ self.message = message