browserwright 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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))
|