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,677 @@
1
+ """Extension upstream adapter — makes a RelayServer look like an
2
+ `UpstreamConnection` so the listener / router don't need extension-specific
3
+ branches in their hot paths.
4
+
5
+ When `backend=extension` is active in Mode B, the daemon's "upstream" is no
6
+ longer a real Chrome CDP ws — it's the RelayServer plus the connected
7
+ extension's `chrome.debugger` calls. This wrapper translates the CDP frames
8
+ the router emits into relay operations, and vice versa.
9
+
10
+ CDP commands intercepted here (not forwarded as `chrome.debugger.sendCommand`):
11
+
12
+ - `Target.getTargets` → answered from `RelayServer.list_ghost_targets()`
13
+ - `Target.attachToTarget` → `RelayServer.attach_tab(tabId)` + fabricated
14
+ sessionId
15
+ - `Target.detachFromTarget` → `RelayServer.detach_tab(tabId)`
16
+ - `Target.setDiscoverTargets` / `Target.setAutoAttach` → silent ack
17
+ (we don't need Chrome's discover stream — ghost targets come from the
18
+ extension via "attached"/"detached" event types instead)
19
+ - `Browser.getVersion` → daemon-stamped result, used for heartbeat
20
+ - `Browser.crash`, `Browser.close` and other unsupported browser-level
21
+ methods → -32601 ("method not implemented in extension backend")
22
+
23
+ Session-scoped commands (have `sessionId`) → routed through
24
+ `RelayServer.send_cdp(tab_id, method, params)` where `tab_id` is recovered
25
+ from the session-id naming convention (`ext-sid-<tabId>-<random>`).
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import json
31
+ import logging
32
+ import secrets
33
+ import time
34
+ from typing import Any, Awaitable, Callable
35
+
36
+ from .. import __version__
37
+ from .relay import RelayServer, GhostTarget, _CommandError
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ # Browser-level methods that have no meaningful chrome.debugger analog.
43
+ # v0.4 returns -32601 per spec §8.4.
44
+ _UNSUPPORTED_BROWSER_METHODS = frozenset({
45
+ "Browser.crash",
46
+ "Browser.close",
47
+ "Browser.setDownloadBehavior",
48
+ "Browser.getWindowForTarget",
49
+ "Browser.getWindowBounds",
50
+ "Browser.setWindowBounds",
51
+ })
52
+
53
+
54
+ def _build_requires_session_error(method: str) -> str:
55
+ return (
56
+ f"{method!r} requires a sessionId in extension backend — "
57
+ "no tab attached. Attach one first via "
58
+ "BrowserwrightDaemon.attachActiveTab (focused tab) or "
59
+ "BrowserwrightDaemon.openBackgroundTab (background tab), then retry."
60
+ )
61
+
62
+
63
+ def _build_create_target_error() -> str:
64
+ """Target.createTarget can't be honored by the extension backend (it can't
65
+ issue browser-level CDP). The old code reported the misleading 'requires a
66
+ sessionId' error; instead point clients at the real tab-opening verbs."""
67
+ return (
68
+ "Target.createTarget is not supported by the extension backend — "
69
+ "it cannot open browser-level targets. Open a tab via the skill "
70
+ "primitive open_background(url, group=\"Agent\") (or "
71
+ "BrowserwrightDaemon.openBackgroundTab for a background tab) instead. "
72
+ "new_tab() works only on the rdp/env backend."
73
+ )
74
+
75
+
76
+ def _build_unknown_session_error(session_id: str) -> str:
77
+ return (
78
+ f"unknown sessionId {session_id!r} — likely from a transient ws "
79
+ "(e.g. CLI subprocess) which the daemon has since released. "
80
+ "Re-attach from the same ws that will send subsequent commands."
81
+ )
82
+
83
+
84
+ def _new_upstream_session_id(tab_id: int) -> str:
85
+ """Synthetic upstream sessionId. Format chosen so the upstream side
86
+ parser in `UpstreamSession.from_id` can recover the tabId without an
87
+ extra table."""
88
+ return f"ext-sid-{tab_id}-{secrets.token_hex(6).upper()}"
89
+
90
+
91
+ def _tab_id_from_session_id(session_id: str) -> int | None:
92
+ if not session_id.startswith("ext-sid-"):
93
+ return None
94
+ rest = session_id[len("ext-sid-"):]
95
+ head, _, _ = rest.partition("-")
96
+ try:
97
+ return int(head)
98
+ except ValueError:
99
+ return None
100
+
101
+
102
+ def _tab_id_from_target_id(target_id: str) -> int | None:
103
+ if not target_id.startswith("ext-tab-"):
104
+ return None
105
+ try:
106
+ return int(target_id[len("ext-tab-"):])
107
+ except ValueError:
108
+ return None
109
+
110
+
111
+ class ExtensionUpstream:
112
+ """Adapter that quacks like `UpstreamConnection` but talks to a
113
+ `RelayServer`.
114
+
115
+ The listener wires this in as `self.upstream` when backend=extension; the
116
+ router calls `send_text` on every client frame, and the adapter handles
117
+ interception + translation.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ relay: RelayServer,
123
+ on_frame: Callable[[str], Awaitable[None]],
124
+ on_close: Callable[[str], Awaitable[None]],
125
+ ):
126
+ self._relay = relay
127
+ self._on_frame = on_frame
128
+ self._on_close = on_close
129
+ self._open = False
130
+ # Map: upstream sessionId → tabId (for the rare path where commands
131
+ # specify sessionId without our naming convention).
132
+ self._sessions: dict[str, int] = {}
133
+ # The session IS a tab group (docs "extension browser = tab group").
134
+ # We bind to the durable numeric Chrome groupId and key all ops on it;
135
+ # the group's live membership (chrome.tabs.query({groupId})) is the
136
+ # SINGLE source of truth for what's in the session — there is no
137
+ # owned/borrowed bookkeeping. ``group_name`` (= session name) is only a
138
+ # human-visible title used when creating a new group.
139
+ self._groups: dict[str, int] = {} # bs session → tab-group id
140
+ self._tab_url: dict[int, str] = {} # tab_id → last-known url
141
+
142
+ def reset_session_announce(self, session_id: str | None) -> None:
143
+ self._relay.reset_session_announce(session_id)
144
+
145
+ async def wait_session_announce(self, session_id: str,
146
+ timeout: float = 2.0) -> bool:
147
+ return await self._relay.wait_session_announce(session_id, timeout)
148
+
149
+ # ---- per-session group binding helpers -------------------------------
150
+
151
+ def _bind_group(self, session_id: str, group_id: int) -> None:
152
+ """Record the session's durable groupId (the session's browser id).
153
+ A negative/invalid id is ignored — the group may have been auto-deleted
154
+ (empty) and will be recreated on the next open."""
155
+ if isinstance(group_id, int) and group_id >= 0:
156
+ self._groups[session_id] = group_id
157
+ self._relay.bind_session_group(session_id, group_id)
158
+
159
+ @staticmethod
160
+ def _group_required(*, group_name: str | None,
161
+ group_id: int | None,
162
+ session_id: str | None) -> bool:
163
+ """Whether this operation promised to land the tab in a session group."""
164
+ return bool(group_name) or bool(session_id) or (
165
+ isinstance(group_id, int) and group_id >= 0)
166
+
167
+ @staticmethod
168
+ def _require_group_result(group_id: int, *, op: str) -> None:
169
+ if group_id < 0:
170
+ raise RuntimeError(
171
+ f"{op} did not return a tab group id; the extension failed to "
172
+ "place the tab in the session tab group")
173
+
174
+ async def _group_member_tabs(self, session_id: str | None,
175
+ group_id: int | None = None) -> tuple[int, list[int]]:
176
+ """Resolve the session's live group membership = the source of truth.
177
+ Returns ``(group_id, [tab_id, ...])``. Keyed ONLY on the numeric Chrome
178
+ groupId — the session's in-memory bound id first, else the persisted id
179
+ passed in. The title is never a lookup key (names aren't unique;
180
+ decision 6). Empty list when the session has no live group (never opened
181
+ a tab, or its last tab closed and Chrome auto-deleted the group)."""
182
+ gid = self._groups.get(session_id) if session_id else None
183
+ if gid is None:
184
+ gid = self._relay.session_group(session_id)
185
+ if gid is None:
186
+ gid = group_id
187
+ info = await self._relay.query_group_tabs(group_id=gid)
188
+ if not info:
189
+ return (-1, [])
190
+ live_gid = int(info.get("groupId", -1))
191
+ if session_id and live_gid >= 0:
192
+ self._groups[session_id] = live_gid
193
+ tabs = sorted({
194
+ t.get("tabId") for t in (info.get("tabs") or [])
195
+ if isinstance(t.get("tabId"), int)
196
+ })
197
+ return (live_gid, list(tabs))
198
+
199
+ def session_info(self, session_id: str) -> dict:
200
+ """Live view of a session's browser: its bound group id, the number of
201
+ tabs we currently track for it (best-effort, in-memory), and a sample
202
+ url. Used to fill `whoami`'s live fields. Membership-as-truth means the
203
+ authoritative count comes from the live group query (``list_tabs``);
204
+ this synchronous view reports the in-memory tabs bound to the group's
205
+ recorded sessions."""
206
+ gid = self._groups.get(session_id, -1)
207
+ sample = next((u for u in (self._tab_url.get(t) for t in self._sessions.values())
208
+ if u), "")
209
+ return {
210
+ "session_id": session_id,
211
+ "group_id": gid,
212
+ "tab_count": sum(1 for _ in self._sessions),
213
+ "sample_url": sample,
214
+ }
215
+
216
+ async def end_session(self, session_id: str,
217
+ group_id: int | None = None) -> dict:
218
+ """Tear down a session's browser (DECIDED): close the WHOLE tab group —
219
+ every member tab — then the group disappears. Membership is resolved
220
+ from the live group by numeric groupId (bound id first, else the
221
+ persisted id passed in), NOT from any owned/borrowed set or title.
222
+ Returns ``{closed: [...], kept: []}`` (``kept`` is always empty now —
223
+ there is no borrowed distinction; drag a tab out of the group to spare
224
+ it)."""
225
+ group_id, members = await self._group_member_tabs(session_id, group_id)
226
+ self._groups.pop(session_id, None)
227
+ closed: list[int] = []
228
+ for tab_id in members:
229
+ try:
230
+ await self._relay.close_tab(tab_id)
231
+ closed.append(tab_id)
232
+ except Exception: # noqa: BLE001 — best-effort teardown
233
+ pass
234
+ # Evict any fabricated CDP sessions bound to a closed tab.
235
+ for sid in [s for s, t in self._sessions.items() if t == tab_id]:
236
+ self._sessions.pop(sid, None)
237
+ self._tab_url.pop(tab_id, None)
238
+ return {"closed": closed, "kept": []}
239
+
240
+ async def list_tabs(self, session_id: str | None = None,
241
+ group_id: int | None = None) -> dict:
242
+ """The session's tabs, resolved from LIVE group membership (the source
243
+ of truth) by numeric groupId — never an in-memory set or the title.
244
+ Returns ``{groupId, tabs:[{tabId, url, title, attached}, ...]}``."""
245
+ gid = self._groups.get(session_id) if session_id else None
246
+ if gid is None:
247
+ gid = group_id
248
+ info = await self._relay.query_group_tabs(group_id=gid)
249
+ if not info:
250
+ return {"groupId": -1, "tabs": []}
251
+ live_gid = int(info.get("groupId", -1))
252
+ if session_id and live_gid >= 0:
253
+ self._groups[session_id] = live_gid
254
+ attached_tabs = {t for t in self._sessions.values()}
255
+ tabs = [
256
+ {
257
+ "tabId": t.get("tabId"),
258
+ "url": t.get("url", ""),
259
+ "title": t.get("title", ""),
260
+ "attached": t.get("tabId") in attached_tabs,
261
+ }
262
+ for t in (info.get("tabs") or [])
263
+ if isinstance(t.get("tabId"), int)
264
+ ]
265
+ return {"groupId": live_gid, "tabs": tabs}
266
+
267
+ async def scoped_target_infos(self, session_id: str | None) -> list[dict]:
268
+ """CDP ``targetInfos`` for the session's browser = its tab group ONLY.
269
+
270
+ The source of truth is the live group membership (by the session's bound
271
+ groupId); we filter the global ghost list down to tabs that belong to
272
+ this session's group so two sessions sharing one Chrome stay mutually
273
+ invisible at enumeration. Shape matches the unscoped ``Target.getTargets``
274
+ interception."""
275
+ _gid, member_tabs = await self._group_member_tabs(session_id)
276
+ members = set(member_tabs)
277
+ out: list[dict] = []
278
+ for g in self._relay.list_ghost_targets():
279
+ tab_id = _tab_id_from_target_id(g.target_id)
280
+ if tab_id is None or tab_id not in members:
281
+ continue
282
+ out.append({
283
+ "targetId": g.target_id,
284
+ "type": g.type,
285
+ "url": g.url,
286
+ "title": g.title,
287
+ "attached": True,
288
+ "canAccessOpener": False,
289
+ "browserContextId": "",
290
+ })
291
+ return out
292
+
293
+ @property
294
+ def ws_url(self) -> str | None:
295
+ # Pseudo-URL for log / state.upstream_ws_url. The proxy never opens
296
+ # a ws to this; it's just informational.
297
+ return f"ws://127.0.0.1:{self._relay.port}/__extension_relay__"
298
+
299
+ @property
300
+ def is_open(self) -> bool:
301
+ return self._open
302
+
303
+ # ---- lifecycle -------------------------------------------------------
304
+
305
+ async def open(self, ws_url: str | None = None, *,
306
+ timeout: float = 30.0) -> None:
307
+ """Wait for the relay to have at least one extension connected.
308
+
309
+ `ws_url` is ignored — kept for signature compatibility with
310
+ UpstreamConnection.open. `timeout` matches the same arg shape.
311
+ """
312
+ await self._relay.wait_ready(timeout=timeout)
313
+ # Wire event fan-in so async events (Page.frameNavigated etc.) get
314
+ # surfaced into the daemon's normal event router.
315
+ self._relay.set_event_handler(self._handle_extension_event)
316
+ self._open = True
317
+
318
+ async def close(self, *, code: int = 1000, reason: str = "") -> None:
319
+ self._open = False
320
+ self._relay.set_event_handler(None)
321
+ # We don't stop the relay here — the listener may want to keep it
322
+ # alive across reconnects. The listener owns relay lifecycle.
323
+
324
+ async def userscript_request(self, verb: str, payload: dict, **kw):
325
+ return await self._relay.userscript_request(verb, payload, **kw)
326
+
327
+ async def send_text(self, frame: str) -> None:
328
+ """Client → 'upstream' CDP frame. We parse, intercept Target.* +
329
+ Browser.*, and route session-scoped commands via the relay.
330
+ """
331
+ try:
332
+ msg = json.loads(frame)
333
+ except (ValueError, TypeError):
334
+ logger.warning("extension upstream got non-JSON: %s", frame[:80])
335
+ return
336
+ if not isinstance(msg, dict):
337
+ return
338
+
339
+ method = msg.get("method")
340
+ req_id = msg.get("id") if isinstance(msg.get("id"), int) else None
341
+ params = msg.get("params") or {}
342
+ session_id = msg.get("sessionId") if isinstance(msg.get("sessionId"), str) else None
343
+
344
+ # --- intercepted browser-level methods ---
345
+ if method == "Target.setDiscoverTargets" or method == "Target.setAutoAttach":
346
+ # Silent ack — extension-driven discovery happens via push events.
347
+ await self._respond(req_id, {})
348
+ return
349
+
350
+ if method == "Target.getTargets":
351
+ ghosts = self._relay.list_ghost_targets()
352
+ await self._respond(req_id, {
353
+ "targetInfos": [
354
+ {
355
+ "targetId": g.target_id,
356
+ "type": g.type,
357
+ "url": g.url,
358
+ "title": g.title,
359
+ "attached": True,
360
+ "canAccessOpener": False,
361
+ "browserContextId": "",
362
+ } for g in ghosts
363
+ ],
364
+ })
365
+ return
366
+
367
+ if method == "Target.attachToTarget":
368
+ target_id = params.get("targetId")
369
+ tab_id = _tab_id_from_target_id(target_id) if isinstance(target_id, str) else None
370
+ if tab_id is None:
371
+ await self._error(req_id, -32602,
372
+ f"unknown extension target {target_id!r}")
373
+ return
374
+ try:
375
+ await self._relay.attach_tab(tab_id, timeout=10.0)
376
+ except _CommandError as e:
377
+ await self._error(req_id, e.code, e.message)
378
+ return
379
+ except Exception as e:
380
+ await self._error(req_id, -32603, f"attach failed: {e!r}")
381
+ return
382
+ sid = _new_upstream_session_id(tab_id)
383
+ self._sessions[sid] = tab_id
384
+ await self._respond(req_id, {"sessionId": sid})
385
+ return
386
+
387
+ if method == "Target.detachFromTarget":
388
+ sid = params.get("sessionId") or session_id
389
+ tab_id = self._sessions.pop(sid, None) if isinstance(sid, str) else None
390
+ if tab_id is None:
391
+ # CDP doesn't error on detach of unknown — return empty result.
392
+ await self._respond(req_id, {})
393
+ return
394
+ try:
395
+ await self._relay.detach_tab(tab_id)
396
+ except Exception as e:
397
+ logger.warning("relay detach failed: %r", e)
398
+ await self._respond(req_id, {})
399
+ return
400
+
401
+ if method == "Browser.getVersion":
402
+ # Heartbeat — daemon-internal. Return a stable shape so the
403
+ # proxy doesn't choke on the heartbeat loop in UpstreamConnection
404
+ # land (not used in extension backend, but symmetric).
405
+ await self._respond(req_id, {
406
+ "product": f"browserwright-daemon-extension/{__version__}",
407
+ "userAgent": "extension-relay",
408
+ "protocolVersion": "1.3",
409
+ "revision": "0",
410
+ "jsVersion": "0",
411
+ })
412
+ return
413
+
414
+ if isinstance(method, str) and method in _UNSUPPORTED_BROWSER_METHODS:
415
+ await self._error(req_id, -32601,
416
+ "method not implemented in extension backend")
417
+ return
418
+
419
+ # --- session-scoped commands → forward via relay ---
420
+ if session_id is None:
421
+ # Browser-level method we don't intercept (e.g., Target.activateTarget).
422
+ # Best effort: report -32601 since extensions can't issue
423
+ # browser-level CDP without a session.
424
+ if isinstance(method, str) and method.startswith("Target."):
425
+ # Target.createTarget: the extension can't open browser-level
426
+ # targets — fast-fail with a message naming the real verbs
427
+ # (new_page / openBackgroundTab) rather than the misleading
428
+ # "requires a sessionId".
429
+ if method == "Target.createTarget":
430
+ await self._error(req_id, -32601, _build_create_target_error())
431
+ return
432
+ # Target.activateTarget(targetId) → translate to chrome.tabs.update
433
+ if method == "Target.activateTarget":
434
+ target_id = params.get("targetId")
435
+ tab_id = (_tab_id_from_target_id(target_id)
436
+ if isinstance(target_id, str) else None)
437
+ if tab_id is not None:
438
+ # We don't have a relay verb for tab activate yet;
439
+ # punt as success (the popup-driven attach model
440
+ # means user-driven activation already happened).
441
+ await self._respond(req_id, {})
442
+ return
443
+ await self._error(req_id, -32601,
444
+ _build_requires_session_error(method or "<unknown>"))
445
+ return
446
+
447
+ tab_id = self._sessions.get(session_id) or _tab_id_from_session_id(session_id)
448
+ if tab_id is None:
449
+ await self._error(req_id, -32602, _build_unknown_session_error(session_id))
450
+ return
451
+
452
+ try:
453
+ result = await self._relay.send_cdp(tab_id, method or "", params)
454
+ await self._respond(req_id, result)
455
+ except _CommandError as e:
456
+ await self._error(req_id, e.code, e.message)
457
+ except Exception as e:
458
+ await self._error(req_id, -32603, f"relay send failed: {e!r}")
459
+
460
+ async def attach_active_tab(self, *, session_id: str | None = None,
461
+ group_name: str | None = None) -> dict:
462
+ """Daemon-driven ADOPT (docs C1): the relay asks the extension to move
463
+ the focused-window active tab INTO this session's tab group and attach
464
+ it. We fabricate a sessionId the same shape `Target.attachToTarget`
465
+ would. Returned dict: `{sessionId, targetId, tabId, url, title,
466
+ groupId}`.
467
+
468
+ The adopted tab becomes a regular group member — it closes with the
469
+ group on `end_session` (no separate borrowed flag). The extension
470
+ REFUSES (raises) if the focused tab already belongs to another
471
+ session's group; that error propagates to the caller.
472
+ """
473
+ gid = self._groups.get(session_id) if session_id else None
474
+ ghost = await self._relay.attach_active_tab(
475
+ group_name=group_name, group_id=gid, timeout=10.0)
476
+ group_id = getattr(ghost, "group_id", -1)
477
+ group_id = int(group_id) if isinstance(group_id, int) else -1
478
+ if self._group_required(
479
+ group_name=group_name, group_id=gid, session_id=session_id):
480
+ self._require_group_result(group_id, op="attachActive")
481
+ sid = _new_upstream_session_id(ghost.tab_id)
482
+ self._sessions[sid] = ghost.tab_id
483
+ if session_id is not None:
484
+ self._bind_group(session_id, group_id)
485
+ if ghost.url:
486
+ self._tab_url[ghost.tab_id] = ghost.url
487
+ return {
488
+ "sessionId": sid,
489
+ "targetId": ghost.target_id,
490
+ "tabId": ghost.tab_id,
491
+ "url": ghost.url,
492
+ "title": ghost.title,
493
+ "groupId": group_id,
494
+ }
495
+
496
+ async def open_background_tab(
497
+ self,
498
+ url: str,
499
+ *,
500
+ group_name: str | None = "Agent",
501
+ session_id: str | None = None,
502
+ background: bool = True,
503
+ ) -> dict:
504
+ """Open a background tab in the session's tab group via the relay,
505
+ fabricate a sessionId, and return
506
+ ``{sessionId, targetId, tabId, url, title, groupId}``.
507
+
508
+ The session's group is keyed on the bound groupId (durable). The group
509
+ name is only the human-visible title used when a new group must be
510
+ created. The returned groupId is (re)bound to the session — that's the
511
+ only per-session state we keep; membership comes from the live group."""
512
+ gid = self._groups.get(session_id) if session_id else None
513
+ if gid is None:
514
+ gid = self._relay.session_group(session_id)
515
+ self.reset_session_announce(session_id)
516
+ gt = await self._relay.create_background_tab(
517
+ url, group_name=group_name, group_id=gid, background=background)
518
+ group_id = getattr(gt, "group_id", -1)
519
+ group_id = int(group_id) if isinstance(group_id, int) else -1
520
+ if self._group_required(
521
+ group_name=group_name, group_id=gid, session_id=session_id):
522
+ self._require_group_result(group_id, op="createTab")
523
+ sid = _new_upstream_session_id(gt.tab_id)
524
+ self._sessions[sid] = gt.tab_id
525
+ if session_id is not None:
526
+ self._bind_group(session_id, group_id)
527
+ if gt.url:
528
+ self._tab_url[gt.tab_id] = gt.url
529
+ return {
530
+ "sessionId": sid,
531
+ "targetId": gt.target_id,
532
+ "tabId": gt.tab_id,
533
+ "url": gt.url,
534
+ "title": gt.title,
535
+ "groupId": group_id,
536
+ }
537
+
538
+ async def recover_session(self, session_id: str | None, *,
539
+ group_id: int) -> dict:
540
+ """Session-reconnect-recovery: after a daemon restart (Chrome still
541
+ running) the in-memory session→tab bindings are gone, but the Chrome
542
+ tab group survives. Query that group **by its persisted numeric
543
+ groupId** (NOT the title — names aren't unique), re-attach the debugger
544
+ to each of its tabs, rebuild ``_sessions`` / ``_groups``, and return a
545
+ representative target with the same shape as ``open_background_tab``.
546
+
547
+ The persisted groupId comes from the skill's ledger ``runtime.group_id``
548
+ (written on every open). If Chrome itself restarted the groupId is gone
549
+ and nothing is recovered — by design (a closed Chrome needs no
550
+ recovery).
551
+
552
+ Raises (proxy maps to a CDP error) when no group matches or it has no
553
+ tabs."""
554
+ info = await self._relay.query_group_tabs(group_id=group_id)
555
+ if not info or not info.get("tabs"):
556
+ raise RuntimeError(
557
+ f"no recoverable tabs for group id {group_id} "
558
+ "(group missing or empty)")
559
+ group_id = int(info.get("groupId", -1))
560
+ tabs = info["tabs"]
561
+ recovered: list[int] = []
562
+ # tab_id → (sid, url, title, lastAccessed) for picking a representative.
563
+ meta: dict[int, dict] = {}
564
+ for tab in tabs:
565
+ tab_id = tab.get("tabId")
566
+ if not isinstance(tab_id, int):
567
+ continue
568
+ # Idempotent: re-attaches the debugger (relay short-circuits if the
569
+ # ghost already exists from a popup attach / re-announce).
570
+ await self._relay.attach_tab(tab_id)
571
+ sid = _new_upstream_session_id(tab_id)
572
+ self._sessions[sid] = tab_id
573
+ url = str(tab.get("url", ""))
574
+ if session_id:
575
+ self._bind_group(session_id, group_id)
576
+ if url:
577
+ self._tab_url[tab_id] = url
578
+ recovered.append(tab_id)
579
+ meta[tab_id] = {
580
+ "sid": sid,
581
+ "url": url,
582
+ "title": str(tab.get("title", "")),
583
+ "lastAccessed": tab.get("lastAccessed", 0) or 0,
584
+ }
585
+ if not recovered:
586
+ raise RuntimeError(
587
+ f"group id {group_id} had tabs but none had a usable tabId")
588
+ # Representative tab: most-recently-accessed, else first.
589
+ rep_id = max(recovered, key=lambda t: meta[t]["lastAccessed"])
590
+ rep = meta[rep_id]
591
+ return {
592
+ "sessionId": rep["sid"],
593
+ "targetId": f"ext-tab-{rep_id}",
594
+ "tabId": rep_id,
595
+ "url": rep["url"],
596
+ "title": rep["title"],
597
+ "groupId": group_id,
598
+ "recovered": recovered,
599
+ }
600
+
601
+ async def close_tab(self, session_id: str) -> dict:
602
+ """Close the tab bound to ``session_id`` (UPSTREAM sessionId). Raises
603
+ ValueError if unknown — proxy translates to a CDP error."""
604
+ tab_id = self._sessions.pop(session_id, None)
605
+ if tab_id is None:
606
+ tab_id = _tab_id_from_session_id(session_id)
607
+ if tab_id is None:
608
+ raise ValueError(f"unknown sessionId {session_id!r}")
609
+ await self._relay.close_tab(tab_id)
610
+ return {"ok": True, "tabId": tab_id}
611
+
612
+ async def close_tab_by_target_id(self, target_id: str) -> dict:
613
+ """Close-tab path used when the daemon proxy can't resolve a session
614
+ binding (e.g. the original opener's transient ws disconnected and the
615
+ per-client attacher was reaped). Derives tabId from ``ext-tab-N`` and
616
+ calls the relay directly — no session lookup required. Also evicts
617
+ any matching tab from ``_sessions`` to keep state tidy."""
618
+ tab_id = _tab_id_from_target_id(target_id)
619
+ if tab_id is None:
620
+ raise ValueError(f"unknown targetId {target_id!r}")
621
+ # Drop any sessions that still reference this tab so the upstream
622
+ # doesn't hold stale entries.
623
+ for sid in [s for s, t in self._sessions.items() if t == tab_id]:
624
+ self._sessions.pop(sid, None)
625
+ await self._relay.close_tab(tab_id)
626
+ return {"ok": True, "tabId": tab_id}
627
+
628
+ async def send_command(self, method: str, params: dict | None = None,
629
+ session_id: str | None = None,
630
+ timeout: float = 10.0) -> dict:
631
+ """Daemon-internal command path (heartbeat, setDiscoverTargets).
632
+
633
+ For the extension backend these are no-ops or trivial — we don't
634
+ actually need them to hit Chrome. Return a synthesized success so
635
+ the listener's startup sequence doesn't fail.
636
+ """
637
+ if method == "Target.setDiscoverTargets":
638
+ return {}
639
+ if method == "Browser.getVersion":
640
+ return {
641
+ "product": f"browserwright-daemon-extension/{__version__}",
642
+ "userAgent": "extension-relay",
643
+ "protocolVersion": "1.3",
644
+ "revision": "0",
645
+ "jsVersion": "0",
646
+ }
647
+ return {}
648
+
649
+ # ---- helpers ---------------------------------------------------------
650
+
651
+ async def _respond(self, req_id: int | None, result: dict) -> None:
652
+ await self._on_frame(json.dumps({"id": req_id, "result": result}))
653
+
654
+ async def _error(self, req_id: int | None, code: int, msg: str) -> None:
655
+ await self._on_frame(json.dumps({
656
+ "id": req_id, "error": {"code": code, "message": msg},
657
+ }))
658
+
659
+ async def _handle_extension_event(self, ext_msg: dict) -> None:
660
+ """Translate an extension's `{"type":"event",...}` push into the
661
+ equivalent CDP event frame so the daemon's router can fan it out.
662
+ """
663
+ tab_id = ext_msg.get("tabId")
664
+ method = ext_msg.get("method")
665
+ params = ext_msg.get("params") or {}
666
+ if not isinstance(tab_id, int) or not isinstance(method, str):
667
+ return
668
+ # Find a sessionId we previously handed out for this tab.
669
+ sid = None
670
+ for s, t in self._sessions.items():
671
+ if t == tab_id:
672
+ sid = s
673
+ break
674
+ out: dict[str, Any] = {"method": method, "params": params}
675
+ if sid is not None:
676
+ out["sessionId"] = sid
677
+ await self._on_frame(json.dumps(out))