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