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,1991 @@
|
|
|
1
|
+
"""CDP proxy + BrowserwrightDaemon.* namespace router (v0.3 multi-client).
|
|
2
|
+
|
|
3
|
+
Three translation tables make v0.3 work:
|
|
4
|
+
|
|
5
|
+
1. **Request id** — every client-bound request id is replaced with a fresh
|
|
6
|
+
upstream id. The PendingRequest lookup carries the (client_id,
|
|
7
|
+
original_id) back when the upstream response arrives. Two reasons:
|
|
8
|
+
(a) different clients otherwise pick colliding ids; (b) the Target.attach
|
|
9
|
+
response needs to be intercepted server-side without the client knowing.
|
|
10
|
+
|
|
11
|
+
2. **sessionId** (local ↔ upstream) — each client gets its own sessionId
|
|
12
|
+
namespace. Daemon allocates a UUID-like local sessionId when it first
|
|
13
|
+
sees an upstream attach response, and translates in both directions on
|
|
14
|
+
every subsequent message. Two routes get this:
|
|
15
|
+
- command path: client → upstream rewrites params.sessionId
|
|
16
|
+
- event path: upstream → client picks owner(s) from upstream_to_locals
|
|
17
|
+
|
|
18
|
+
3. **attachers** (single-owner rule) — first attach to a targetId wins.
|
|
19
|
+
Second attach without `allowSecondaryReadOnly` gets `-32602`. Second
|
|
20
|
+
attach with the flag becomes a read-only reader sharing the existing
|
|
21
|
+
upstream session.
|
|
22
|
+
|
|
23
|
+
`BrowserwrightDaemon.*` self-answer + heuristic active-tab table behave the same
|
|
24
|
+
as v0.2.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import secrets
|
|
32
|
+
import time
|
|
33
|
+
from typing import Awaitable, Callable
|
|
34
|
+
|
|
35
|
+
from .. import __version__
|
|
36
|
+
from ..observability import metrics
|
|
37
|
+
from .state import (
|
|
38
|
+
AttachOwnership, ClientState, DaemonState, PendingRequest, SessionBinding,
|
|
39
|
+
UpstreamPhase, PRE_OPEN_BUFFER_LIMIT,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---- helpers --------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _json_safe(text: str) -> dict | None:
|
|
49
|
+
try:
|
|
50
|
+
v = json.loads(text)
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
return None
|
|
53
|
+
return v if isinstance(v, dict) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _error_response(req_id: int | None, code: int, message: str) -> str:
|
|
57
|
+
return json.dumps({"id": req_id, "error": {"code": code, "message": message}})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _result_response(req_id: int | None, result: dict) -> str:
|
|
61
|
+
return json.dumps({"id": req_id, "result": result})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _event(method: str, params: dict, session_id: str | None = None) -> str:
|
|
65
|
+
msg: dict = {"method": method, "params": params}
|
|
66
|
+
if session_id is not None:
|
|
67
|
+
msg["sessionId"] = session_id
|
|
68
|
+
return json.dumps(msg)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmd_result(envelope: object) -> dict:
|
|
72
|
+
"""Extract the CDP ``result`` dict from an ``UpstreamConnection.send_command``
|
|
73
|
+
envelope. send_command resolves to the FULL frame
|
|
74
|
+
(``{"id": N, "result": {...}}`` or ``{"id": N, "error": {...}}``), so the rdp
|
|
75
|
+
verb impls must unwrap ``result`` rather than reading fields off the envelope.
|
|
76
|
+
Raises ``RuntimeError`` on a CDP error or a malformed frame (the rdp handlers
|
|
77
|
+
catch it and surface -32603)."""
|
|
78
|
+
if not isinstance(envelope, dict):
|
|
79
|
+
raise RuntimeError(f"malformed CDP response: {envelope!r}")
|
|
80
|
+
if "error" in envelope and envelope["error"]:
|
|
81
|
+
raise RuntimeError(f"CDP error: {envelope['error']!r}")
|
|
82
|
+
result = envelope.get("result")
|
|
83
|
+
return result if isinstance(result, dict) else {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _new_local_session_id(client_id: int) -> str:
|
|
87
|
+
"""Synthetic local sessionId. Spec doesn't pin the format — we pick a
|
|
88
|
+
`c<client_id>-<random>` prefix so daemon logs make it obvious which
|
|
89
|
+
client a sessionId belongs to (debugging multi-client races is otherwise
|
|
90
|
+
miserable).
|
|
91
|
+
"""
|
|
92
|
+
return f"c{client_id}-{secrets.token_hex(8).upper()}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---- the router -----------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Router:
|
|
99
|
+
"""Multi-client v0.3 router.
|
|
100
|
+
|
|
101
|
+
Bindings change shape from v0.2:
|
|
102
|
+
- `client_send` becomes a `dict[client_id, send_fn]` registry, so the
|
|
103
|
+
router can fan out events to the right subset of clients.
|
|
104
|
+
- `upstream_send` remains a single callable (only one upstream conn).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, state: DaemonState):
|
|
108
|
+
self.state = state
|
|
109
|
+
# Phase 2: back-reference to the global Daemon, set by Daemon.__init__
|
|
110
|
+
# / _ensure_rdp_context. Lets the session-verb handlers (ensureSession /
|
|
111
|
+
# endSession) create or drop an rdp UpstreamContext. None in unit tests
|
|
112
|
+
# that build a bare Router — those handlers degrade gracefully.
|
|
113
|
+
self.daemon: object | None = None
|
|
114
|
+
self._upstream_send: Callable[[str], Awaitable[None]] | None = None
|
|
115
|
+
self._client_sends: dict[int, Callable[[str], Awaitable[None]]] = {}
|
|
116
|
+
self._ensure_upstream: Callable[[], Awaitable[None]] | None = None
|
|
117
|
+
self._trigger_disconnect: Callable[[str], Awaitable[None]] | None = None
|
|
118
|
+
# Extension-backend-only verbs. listener.py sets these only when
|
|
119
|
+
# backend=extension; other backends leave them None and the proxy
|
|
120
|
+
# handlers respond -32601. `_close_tab_by_target_id` is the fallback
|
|
121
|
+
# close-path used when the original opener disconnected and the
|
|
122
|
+
# per-client session binding was reaped.
|
|
123
|
+
self._attach_active_tab: Callable[[], Awaitable[dict]] | None = None
|
|
124
|
+
self._open_background_tab: (
|
|
125
|
+
Callable[[str, str | None], Awaitable[dict]] | None) = None
|
|
126
|
+
self._close_tab: Callable[[str], Awaitable[dict]] | None = None
|
|
127
|
+
self._close_tab_by_target_id: (
|
|
128
|
+
Callable[[str], Awaitable[dict]] | None) = None
|
|
129
|
+
# P5: per-session teardown (extension backend only). Closes the
|
|
130
|
+
# session's owned tabs, keeps borrowed ones.
|
|
131
|
+
self._end_session: Callable[[str], Awaitable[dict]] | None = None
|
|
132
|
+
# Session-reconnect-recovery (extension backend only). Rebuilds a
|
|
133
|
+
# session's tab bindings from the durable tab group, found by its
|
|
134
|
+
# persisted numeric groupId (not the title). Signature:
|
|
135
|
+
# (bs_session | None, *, group_id) -> dict.
|
|
136
|
+
self._recover_session: (
|
|
137
|
+
Callable[..., Awaitable[dict]] | None) = None
|
|
138
|
+
self._wait_session_announce: (
|
|
139
|
+
Callable[[str, float], Awaitable[bool]] | None) = None
|
|
140
|
+
self._userscript_request: (
|
|
141
|
+
Callable[[str, dict], Awaitable[dict | None]] | None) = None
|
|
142
|
+
# Extension-backend-only: scope Target.getTargets to a session's tab
|
|
143
|
+
# group so sessions sharing the one Chrome are mutually invisible.
|
|
144
|
+
# listener wires this to ExtensionUpstream.scoped_target_infos.
|
|
145
|
+
# Signature: (session_id) -> list[targetInfo dict]; scopes by the
|
|
146
|
+
# session's bound groupId.
|
|
147
|
+
self._scoped_targets: (
|
|
148
|
+
Callable[[str | None], Awaitable[list[dict]]] | None) = None
|
|
149
|
+
# Phase 3 (docs/refactor-single-daemon.md): rdp raw-CDP command channel.
|
|
150
|
+
# Set by listener._open_chrome_upstream to the UpstreamConnection's
|
|
151
|
+
# daemon-internal `send_command` when this is an rdp (or env/cloud)
|
|
152
|
+
# context. The unified session verbs (openBackgroundTab / closeTab /
|
|
153
|
+
# userscript) dispatch to a CDP implementation through this when the
|
|
154
|
+
# context's backend is rdp, instead of the extension callbacks (which
|
|
155
|
+
# stay None on an rdp context). Signature mirrors
|
|
156
|
+
# UpstreamConnection.send_command: (method, params?, session_id?) -> result.
|
|
157
|
+
self._upstream_command: (
|
|
158
|
+
Callable[..., Awaitable[dict]] | None) = None
|
|
159
|
+
# Background tasks fired off when a client frame triggers lazy
|
|
160
|
+
# upstream open. We keep references so they don't get GC'd mid-await
|
|
161
|
+
# (asyncio warning), and so we can cancel them on shutdown.
|
|
162
|
+
self._open_tasks: set[asyncio.Task] = set()
|
|
163
|
+
|
|
164
|
+
def _session_group_name(
|
|
165
|
+
self, client: ClientState, session_id: str,
|
|
166
|
+
explicit: str | None = None,
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Extension-only human-visible tab group title for a session."""
|
|
169
|
+
if explicit:
|
|
170
|
+
return explicit
|
|
171
|
+
return client.session_name or session_id
|
|
172
|
+
|
|
173
|
+
def _request_session_param(self, params: dict) -> str | None:
|
|
174
|
+
session = params.get("bsSession") or params.get("session")
|
|
175
|
+
return session if isinstance(session, str) and session else None
|
|
176
|
+
|
|
177
|
+
async def _require_browser_session(
|
|
178
|
+
self, client: ClientState, req_id: int | None, op: str,
|
|
179
|
+
params: dict | None = None,
|
|
180
|
+
) -> str | None:
|
|
181
|
+
"""Enforce browserwright-session scoping at the daemon boundary.
|
|
182
|
+
|
|
183
|
+
The websocket's ``?session=<id>`` is the isolation key. Legacy request
|
|
184
|
+
params may repeat that id for mixed-version clients, but they may not
|
|
185
|
+
invent or switch sessions.
|
|
186
|
+
"""
|
|
187
|
+
if not client.session_id:
|
|
188
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
189
|
+
req_id, -32602, f"{op} requires websocket ?session=<id>"))
|
|
190
|
+
return None
|
|
191
|
+
requested = self._request_session_param(params or {})
|
|
192
|
+
if requested is not None and requested != client.session_id:
|
|
193
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
194
|
+
req_id, -32602,
|
|
195
|
+
f"{op} session mismatch: connection is bound to "
|
|
196
|
+
f"{client.session_id!r}, request asked for {requested!r}"))
|
|
197
|
+
return None
|
|
198
|
+
return client.session_id
|
|
199
|
+
|
|
200
|
+
# ---- listener wiring -------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def register_client(self, client_id: int,
|
|
203
|
+
send_fn: Callable[[str], Awaitable[None]]) -> None:
|
|
204
|
+
self._client_sends[client_id] = send_fn
|
|
205
|
+
|
|
206
|
+
def unregister_client(self, client_id: int) -> None:
|
|
207
|
+
self._client_sends.pop(client_id, None)
|
|
208
|
+
|
|
209
|
+
async def release_client(self, client_id: int) -> ClientState | None:
|
|
210
|
+
"""Release a downstream client and close its primary upstream sessions.
|
|
211
|
+
|
|
212
|
+
``DaemonState.release_client`` only mutates bookkeeping. The router owns
|
|
213
|
+
the wire side effects, so a client websocket disappearing still sends
|
|
214
|
+
real ``Target.detachFromTarget`` frames for sessions where that client
|
|
215
|
+
was the primary owner. Read-only secondary sessions are local views and
|
|
216
|
+
need no upstream detach.
|
|
217
|
+
"""
|
|
218
|
+
client = self.state.clients.get(client_id)
|
|
219
|
+
if client is None:
|
|
220
|
+
return None
|
|
221
|
+
for binding in list(client.sessions.values()):
|
|
222
|
+
if binding.readonly:
|
|
223
|
+
continue
|
|
224
|
+
await self._detach_upstream_best_effort(binding.upstream_session_id)
|
|
225
|
+
return self.state.release_client(client_id)
|
|
226
|
+
|
|
227
|
+
async def _detach_upstream_best_effort(self, upstream_session_id: str) -> None:
|
|
228
|
+
"""Send an upstream detach without expecting a client response."""
|
|
229
|
+
if self._upstream_send is None:
|
|
230
|
+
return
|
|
231
|
+
upstream_id = self.state.allocate_upstream_id()
|
|
232
|
+
msg = {
|
|
233
|
+
"id": upstream_id,
|
|
234
|
+
"method": "Target.detachFromTarget",
|
|
235
|
+
"params": {"sessionId": upstream_session_id},
|
|
236
|
+
}
|
|
237
|
+
try:
|
|
238
|
+
await self._upstream_send(json.dumps(msg))
|
|
239
|
+
except Exception as e: # noqa: BLE001 - disconnect cleanup is best-effort.
|
|
240
|
+
logger.warning("best-effort upstream detach failed: %r", e)
|
|
241
|
+
|
|
242
|
+
def update_upstream_send(self, fn: Callable[[str], Awaitable[None]] | None) -> None:
|
|
243
|
+
self._upstream_send = fn
|
|
244
|
+
|
|
245
|
+
def bind_lifecycle(
|
|
246
|
+
self,
|
|
247
|
+
ensure_upstream: Callable[[], Awaitable[None]],
|
|
248
|
+
trigger_disconnect: Callable[[str], Awaitable[None]],
|
|
249
|
+
) -> None:
|
|
250
|
+
self._ensure_upstream = ensure_upstream
|
|
251
|
+
self._trigger_disconnect = trigger_disconnect
|
|
252
|
+
|
|
253
|
+
# ---- downstream → upstream ------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def route_from_client(self, client: ClientState, text: str) -> None:
|
|
256
|
+
msg = _json_safe(text)
|
|
257
|
+
if msg is None:
|
|
258
|
+
# Garbage frame — best-effort forward, upstream will error if it
|
|
259
|
+
# cares. We still gate on upstream readiness so the frame doesn't
|
|
260
|
+
# vanish during the lazy-open window.
|
|
261
|
+
if client.session_id is None:
|
|
262
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
263
|
+
None, -32602,
|
|
264
|
+
"browser CDP forwarding requires websocket ?session=<id>"))
|
|
265
|
+
return
|
|
266
|
+
if not await self._gate_upstream_ready(client, text, msg=None):
|
|
267
|
+
return
|
|
268
|
+
await self._forward_raw(text)
|
|
269
|
+
return
|
|
270
|
+
client.last_command_at = time.time()
|
|
271
|
+
self.state.last_activity_at = time.time()
|
|
272
|
+
|
|
273
|
+
method = msg.get("method")
|
|
274
|
+
req_id = msg.get("id") if isinstance(msg.get("id"), int) else None
|
|
275
|
+
params = msg.get("params") or {}
|
|
276
|
+
local_sid = msg.get("sessionId") if isinstance(msg.get("sessionId"), str) else None
|
|
277
|
+
|
|
278
|
+
# --- BrowserwrightDaemon.* namespace ---
|
|
279
|
+
# Self-answered: doesn't need upstream, so no gate.
|
|
280
|
+
if isinstance(method, str) and method.startswith("BrowserwrightDaemon."):
|
|
281
|
+
await self._handle_browserdaemon(client, msg)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
if client.session_id is None:
|
|
285
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
286
|
+
req_id, -32602,
|
|
287
|
+
"browser CDP forwarding requires websocket ?session=<id>"))
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
# --- pre-open gate (Task #76) ---
|
|
291
|
+
# Everything below this point sends to upstream. If upstream isn't
|
|
292
|
+
# OPEN yet, buffer the raw frame and replay once it is — silently
|
|
293
|
+
# dropping (the v0.3 bug) caused 30s CDP timeouts on the client side
|
|
294
|
+
# when two clients raced lazy-open.
|
|
295
|
+
if not await self._gate_upstream_ready(client, text, msg=msg):
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# --- Target.getTargets scoping (extension: this session's group only) ---
|
|
299
|
+
# The skill's list_tabs / current_page enumerate via Target.getTargets.
|
|
300
|
+
# On the shared extension upstream the raw handler returns EVERY ghost
|
|
301
|
+
# across all sessions; scope it to the requesting client's tab group so
|
|
302
|
+
# sessions stay mutually invisible. rdp keeps the normal forward (its
|
|
303
|
+
# Chrome is already private to the session).
|
|
304
|
+
if (method == "Target.getTargets"
|
|
305
|
+
and self.state.backend_name == "extension"
|
|
306
|
+
and self._scoped_targets is not None
|
|
307
|
+
and client.session_id):
|
|
308
|
+
try:
|
|
309
|
+
infos = await self._scoped_targets(client.session_id)
|
|
310
|
+
except Exception as e: # noqa: BLE001
|
|
311
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
312
|
+
req_id, -32603, f"getTargets scoping failed: {e!r}"))
|
|
313
|
+
return
|
|
314
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
315
|
+
req_id, {"targetInfos": infos}))
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
# --- Target.attachToTarget interceptor ---
|
|
319
|
+
# Server-side single-attacher decision is made BEFORE forwarding.
|
|
320
|
+
if method == "Target.attachToTarget":
|
|
321
|
+
await self._handle_attach(client, msg, req_id, params)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# --- Target.detachFromTarget interceptor ---
|
|
325
|
+
# We unbind locally and forward an upstream detach when this client
|
|
326
|
+
# is the primary owner; readers just disappear locally.
|
|
327
|
+
if method == "Target.detachFromTarget":
|
|
328
|
+
await self._handle_detach(client, msg, req_id, params)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
# --- Target.activateTarget side-effect (update last-activated table) ---
|
|
332
|
+
if method == "Target.activateTarget":
|
|
333
|
+
tid = params.get("targetId")
|
|
334
|
+
if isinstance(tid, str):
|
|
335
|
+
self.state.note_activate(tid)
|
|
336
|
+
await self._maybe_push_focus(reason="activated", target_id=tid)
|
|
337
|
+
# falls through to forward
|
|
338
|
+
|
|
339
|
+
# --- sessionId translation for session-scoped commands ---
|
|
340
|
+
upstream_sid: str | None = None
|
|
341
|
+
if local_sid is not None:
|
|
342
|
+
binding = client.sessions.get(local_sid)
|
|
343
|
+
if binding is None:
|
|
344
|
+
# Client invented a sessionId we don't know — refuse.
|
|
345
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
346
|
+
req_id, -32602, f"unknown sessionId {local_sid}"))
|
|
347
|
+
return
|
|
348
|
+
if binding.readonly:
|
|
349
|
+
# Shared-read sessions can only receive events; commands are
|
|
350
|
+
# daemon-side -32602.
|
|
351
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
352
|
+
req_id, -32602,
|
|
353
|
+
"session is read-only (allowSecondaryReadOnly); "
|
|
354
|
+
"another client is the primary attacher"))
|
|
355
|
+
return
|
|
356
|
+
upstream_sid = binding.upstream_session_id
|
|
357
|
+
|
|
358
|
+
# --- forward to upstream with id + sessionId translation ---
|
|
359
|
+
await self._forward_translated(
|
|
360
|
+
client, msg, req_id=req_id, method=method or "",
|
|
361
|
+
upstream_sid=upstream_sid)
|
|
362
|
+
|
|
363
|
+
# ---- pre-open buffer (Task #76 race fix) ----------------------------
|
|
364
|
+
|
|
365
|
+
async def _gate_upstream_ready(
|
|
366
|
+
self, client: ClientState, text: str, *, msg: dict | None,
|
|
367
|
+
) -> bool:
|
|
368
|
+
"""Return True if upstream is OPEN and the caller may proceed to send.
|
|
369
|
+
Return False if the frame was buffered (for replay on OPEN) or
|
|
370
|
+
rejected (overflow → -32603 sent to client).
|
|
371
|
+
|
|
372
|
+
We treat upstream as "ready" only when the daemon has a live
|
|
373
|
+
`_upstream_send` callable AND DaemonState.upstream_phase is CONNECTED.
|
|
374
|
+
Any other phase (DISCONNECTED / CONNECTING / CLOSING) → buffer.
|
|
375
|
+
"""
|
|
376
|
+
if (self._upstream_send is not None
|
|
377
|
+
and self.state.upstream_phase == UpstreamPhase.CONNECTED):
|
|
378
|
+
return True
|
|
379
|
+
|
|
380
|
+
# Trigger lazy upstream open. ensure_upstream() is idempotent + locked,
|
|
381
|
+
# so concurrent callers all converge on the single connect attempt.
|
|
382
|
+
# Fire-and-forget: we return the buffered ack to the client without
|
|
383
|
+
# awaiting the open, so a slow Chrome handshake doesn't backpressure
|
|
384
|
+
# the client read loop.
|
|
385
|
+
if (self._ensure_upstream is not None
|
|
386
|
+
and self.state.upstream_phase == UpstreamPhase.DISCONNECTED):
|
|
387
|
+
self._spawn_ensure_open()
|
|
388
|
+
|
|
389
|
+
# Overflow path: cap the buffer at PRE_OPEN_BUFFER_LIMIT. The 101st
|
|
390
|
+
# frame gets a CDP error -32603. Older frames are preserved (FIFO).
|
|
391
|
+
if len(client.pre_open_buffer) >= PRE_OPEN_BUFFER_LIMIT:
|
|
392
|
+
req_id = None
|
|
393
|
+
if isinstance(msg, dict) and isinstance(msg.get("id"), int):
|
|
394
|
+
req_id = msg["id"]
|
|
395
|
+
metrics().proxy_pre_open_overflow_total += 1
|
|
396
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
397
|
+
req_id, -32603,
|
|
398
|
+
f"upstream pre-open buffer overflow "
|
|
399
|
+
f"({PRE_OPEN_BUFFER_LIMIT} frames pending)"))
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
client.pre_open_buffer.append(text)
|
|
403
|
+
metrics().proxy_pre_open_buffered_total += 1
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
def _spawn_ensure_open(self) -> None:
|
|
407
|
+
"""Fire-and-forget the upstream lazy-open. Tracks the task so it's not
|
|
408
|
+
GC'd mid-await. ensure_upstream() is idempotent — overlapping calls
|
|
409
|
+
coalesce into one connect attempt via its internal lock.
|
|
410
|
+
"""
|
|
411
|
+
if self._ensure_upstream is None:
|
|
412
|
+
return
|
|
413
|
+
coro = self._ensure_upstream()
|
|
414
|
+
task = asyncio.create_task(coro)
|
|
415
|
+
self._open_tasks.add(task)
|
|
416
|
+
|
|
417
|
+
def _done(t: asyncio.Task) -> None:
|
|
418
|
+
self._open_tasks.discard(t)
|
|
419
|
+
exc = t.exception() if not t.cancelled() else None
|
|
420
|
+
if exc is not None:
|
|
421
|
+
logger.warning("lazy upstream open failed: %r", exc)
|
|
422
|
+
# Open failed — surface to every client whose buffered frames
|
|
423
|
+
# would otherwise sit forever. We schedule the drain because
|
|
424
|
+
# _done is a sync callback.
|
|
425
|
+
asyncio.create_task(self._fail_pre_open_buffers(str(exc)))
|
|
426
|
+
task.add_done_callback(_done)
|
|
427
|
+
|
|
428
|
+
async def drain_pre_open_buffers(self) -> None:
|
|
429
|
+
"""Called once upstream transitions to CONNECTED. For each client,
|
|
430
|
+
re-process every buffered frame in FIFO order. The frames go through
|
|
431
|
+
the normal route_from_client path — which now finds upstream OPEN
|
|
432
|
+
and forwards them downstream without buffering.
|
|
433
|
+
|
|
434
|
+
v0.3 race fix (Task #76): see proxy.py module docstring + state.py
|
|
435
|
+
PRE_OPEN_BUFFER_LIMIT for context.
|
|
436
|
+
"""
|
|
437
|
+
for client in list(self.state.clients.values()):
|
|
438
|
+
while client.pre_open_buffer:
|
|
439
|
+
text = client.pre_open_buffer.popleft()
|
|
440
|
+
metrics().proxy_pre_open_drained_total += 1
|
|
441
|
+
try:
|
|
442
|
+
await self.route_from_client(client, text)
|
|
443
|
+
except Exception as e:
|
|
444
|
+
logger.warning(
|
|
445
|
+
"drain frame for client %d failed: %r",
|
|
446
|
+
client.client_id, e)
|
|
447
|
+
|
|
448
|
+
async def _fail_pre_open_buffers(self, reason: str) -> None:
|
|
449
|
+
"""Best-effort: clear every buffered frame and surface a CDP error
|
|
450
|
+
to its client. Used when the lazy upstream open task fails — the
|
|
451
|
+
client would otherwise hang on the buffered request waiting for a
|
|
452
|
+
reply that never comes.
|
|
453
|
+
"""
|
|
454
|
+
for client in list(self.state.clients.values()):
|
|
455
|
+
while client.pre_open_buffer:
|
|
456
|
+
text = client.pre_open_buffer.popleft()
|
|
457
|
+
msg = _json_safe(text)
|
|
458
|
+
req_id = None
|
|
459
|
+
if isinstance(msg, dict) and isinstance(msg.get("id"), int):
|
|
460
|
+
req_id = msg["id"]
|
|
461
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
462
|
+
req_id, -32603,
|
|
463
|
+
f"upstream open failed before frame could be sent: {reason}"))
|
|
464
|
+
|
|
465
|
+
# ---- attach / detach handlers ---------------------------------------
|
|
466
|
+
|
|
467
|
+
async def _handle_attach(
|
|
468
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
469
|
+
params: dict,
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Intercept Target.attachToTarget per spec §3.4 H7."""
|
|
472
|
+
target_id = params.get("targetId")
|
|
473
|
+
if not isinstance(target_id, str):
|
|
474
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
475
|
+
req_id, -32602, "Target.attachToTarget requires params.targetId"))
|
|
476
|
+
return
|
|
477
|
+
flags = params.get("flags") if isinstance(params.get("flags"), dict) else {}
|
|
478
|
+
# Read both the v0.3 spec-listed flag AND CDP's standard `flatten`
|
|
479
|
+
# — we don't change `flatten` semantics, just remember the shared-read
|
|
480
|
+
# preference.
|
|
481
|
+
allow_shared_read = bool(flags.get("allowSecondaryReadOnly", False))
|
|
482
|
+
|
|
483
|
+
existing = self.state.attachers.get(target_id)
|
|
484
|
+
if existing is None:
|
|
485
|
+
# No prior owner — forward upstream + intercept response.
|
|
486
|
+
await self._forward_translated(
|
|
487
|
+
client, msg, req_id=req_id, method="Target.attachToTarget",
|
|
488
|
+
upstream_sid=None,
|
|
489
|
+
attach_target_id=target_id,
|
|
490
|
+
attach_allow_shared_read=allow_shared_read,
|
|
491
|
+
)
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
# Someone already owns this target.
|
|
495
|
+
if existing.primary_client_id == client.client_id:
|
|
496
|
+
# Same client re-attaching — re-issue the existing local session
|
|
497
|
+
# without going to upstream (Chrome would return the same upstream
|
|
498
|
+
# session anyway; this saves a roundtrip and avoids confusing the
|
|
499
|
+
# primary's session table).
|
|
500
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
501
|
+
req_id, {"sessionId": existing.primary_local_session}))
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
if not allow_shared_read:
|
|
505
|
+
# Spec §3.4 H7: -32602 "target already owned by another client".
|
|
506
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
507
|
+
req_id, -32602,
|
|
508
|
+
f"target {target_id} already attached by another client; "
|
|
509
|
+
f"set params.flags.allowSecondaryReadOnly=true for read-only access"))
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
# Shared-read path: allocate a local sessionId for this client that
|
|
513
|
+
# maps to the existing upstream session, flagged readonly. No upstream
|
|
514
|
+
# roundtrip — we synthesize the response.
|
|
515
|
+
local_sid = _new_local_session_id(client.client_id)
|
|
516
|
+
self.state.bind_session(
|
|
517
|
+
client.client_id, local_sid, existing.upstream_session_id,
|
|
518
|
+
target_id, readonly=True,
|
|
519
|
+
)
|
|
520
|
+
self.state.add_reader(target_id, client.client_id, local_sid)
|
|
521
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
522
|
+
req_id, {"sessionId": local_sid}))
|
|
523
|
+
|
|
524
|
+
async def _handle_detach(
|
|
525
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
526
|
+
params: dict,
|
|
527
|
+
) -> None:
|
|
528
|
+
local_sid = params.get("sessionId")
|
|
529
|
+
if not isinstance(local_sid, str):
|
|
530
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
531
|
+
req_id, -32602, "Target.detachFromTarget requires params.sessionId"))
|
|
532
|
+
return
|
|
533
|
+
binding = client.sessions.get(local_sid)
|
|
534
|
+
if binding is None:
|
|
535
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
536
|
+
req_id, -32602, f"unknown sessionId {local_sid}"))
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
if binding.readonly:
|
|
540
|
+
# Reader detaches locally only — upstream session stays alive
|
|
541
|
+
# because the primary owner still owns it.
|
|
542
|
+
self.state.unbind_session_by_local(client.client_id, local_sid)
|
|
543
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
544
|
+
req_id, {}))
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
# Primary owner: forward upstream so the session truly closes.
|
|
548
|
+
# We hand-build the upstream message because the sessionId lives in
|
|
549
|
+
# params (unlike most session-scoped commands where it's top-level).
|
|
550
|
+
upstream_id = self.state.allocate_upstream_id()
|
|
551
|
+
self.state.remember_request(
|
|
552
|
+
upstream_id, client.client_id,
|
|
553
|
+
req_id if req_id is not None else 0,
|
|
554
|
+
method="Target.detachFromTarget",
|
|
555
|
+
)
|
|
556
|
+
upstream_msg = {
|
|
557
|
+
"id": upstream_id,
|
|
558
|
+
"method": "Target.detachFromTarget",
|
|
559
|
+
"params": {"sessionId": binding.upstream_session_id},
|
|
560
|
+
}
|
|
561
|
+
# Unbind locally NOW so subsequent local commands on this session
|
|
562
|
+
# fail fast instead of racing with the upstream detach response.
|
|
563
|
+
self.state.unbind_session_by_local(client.client_id, local_sid)
|
|
564
|
+
await self._forward_raw(json.dumps(upstream_msg))
|
|
565
|
+
|
|
566
|
+
# ---- generic translated forward -------------------------------------
|
|
567
|
+
|
|
568
|
+
async def _forward_translated(
|
|
569
|
+
self,
|
|
570
|
+
client: ClientState,
|
|
571
|
+
original: dict,
|
|
572
|
+
*,
|
|
573
|
+
req_id: int | None,
|
|
574
|
+
method: str,
|
|
575
|
+
upstream_sid: str | None,
|
|
576
|
+
attach_target_id: str | None = None,
|
|
577
|
+
attach_allow_shared_read: bool = False,
|
|
578
|
+
) -> None:
|
|
579
|
+
"""Rewrite id (always) and sessionId (when present) on a copy of the
|
|
580
|
+
message, remember the pending request, and send upstream."""
|
|
581
|
+
upstream_id = self.state.allocate_upstream_id()
|
|
582
|
+
self.state.remember_request(
|
|
583
|
+
upstream_id,
|
|
584
|
+
client.client_id,
|
|
585
|
+
req_id if req_id is not None else 0,
|
|
586
|
+
method=method,
|
|
587
|
+
attach_target_id=attach_target_id,
|
|
588
|
+
attach_allow_shared_read=attach_allow_shared_read,
|
|
589
|
+
)
|
|
590
|
+
# Build a fresh dict — never mutate the client's message in place.
|
|
591
|
+
out: dict = {"id": upstream_id, "method": method}
|
|
592
|
+
if "params" in original:
|
|
593
|
+
out["params"] = original["params"]
|
|
594
|
+
if upstream_sid is not None:
|
|
595
|
+
out["sessionId"] = upstream_sid
|
|
596
|
+
await self._forward_raw(json.dumps(out))
|
|
597
|
+
|
|
598
|
+
async def _forward_raw(self, text: str) -> None:
|
|
599
|
+
"""Push to upstream verbatim.
|
|
600
|
+
|
|
601
|
+
Callers MUST have passed `_gate_upstream_ready()` first (Task #76):
|
|
602
|
+
the gate buffers frames while upstream is opening, so by the time we
|
|
603
|
+
reach this point the upstream conn is live (or being torn down — in
|
|
604
|
+
which case a dropped frame is acceptable, the client will see
|
|
605
|
+
`upstreamClosed` shortly).
|
|
606
|
+
"""
|
|
607
|
+
if self._upstream_send is None:
|
|
608
|
+
# Defensive: this is only reachable if upstream torn down mid-call.
|
|
609
|
+
logger.warning("dropped frame (no upstream): %s", text[:80])
|
|
610
|
+
return
|
|
611
|
+
await self._upstream_send(text)
|
|
612
|
+
|
|
613
|
+
# ---- upstream → downstream -------------------------------------------
|
|
614
|
+
|
|
615
|
+
async def forward_from_upstream(self, text: str) -> None:
|
|
616
|
+
"""Route an upstream frame to the right client(s)."""
|
|
617
|
+
msg = _json_safe(text)
|
|
618
|
+
if msg is None:
|
|
619
|
+
# Malformed — broadcast as-is so any single curious client gets it.
|
|
620
|
+
await self._broadcast(text)
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
# Response (id present, no method) — route by pending request map.
|
|
624
|
+
if "id" in msg and "method" not in msg:
|
|
625
|
+
await self._handle_upstream_response(msg)
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
# Event (method present, may or may not have sessionId).
|
|
629
|
+
await self._handle_upstream_event(msg, text)
|
|
630
|
+
|
|
631
|
+
async def _handle_upstream_response(self, msg: dict) -> None:
|
|
632
|
+
upstream_id = msg.get("id")
|
|
633
|
+
if not isinstance(upstream_id, int):
|
|
634
|
+
return
|
|
635
|
+
pending = self.state.take_pending(upstream_id)
|
|
636
|
+
if pending is None:
|
|
637
|
+
# Either a daemon-internal id (heartbeat — handled inside
|
|
638
|
+
# UpstreamConnection before reaching us) or a stale id. Drop.
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
# Restore the client's original request id on the response.
|
|
642
|
+
out = {**msg, "id": pending.client_request_id}
|
|
643
|
+
# If a response happened to carry a sessionId, translate it back to
|
|
644
|
+
# the local one (CDP standard responses don't, but Target.attach
|
|
645
|
+
# does in its result).
|
|
646
|
+
upstream_sid_in_result: str | None = None
|
|
647
|
+
result = out.get("result") if isinstance(out.get("result"), dict) else None
|
|
648
|
+
if result is not None and isinstance(result.get("sessionId"), str):
|
|
649
|
+
upstream_sid_in_result = result["sessionId"]
|
|
650
|
+
|
|
651
|
+
# --- Target.attachToTarget completion: bind sessions + attacher ---
|
|
652
|
+
if (pending.method == "Target.attachToTarget"
|
|
653
|
+
and pending.attach_target_id is not None
|
|
654
|
+
and isinstance(upstream_sid_in_result, str)):
|
|
655
|
+
target_id = pending.attach_target_id
|
|
656
|
+
existing = self.state.attachers.get(target_id)
|
|
657
|
+
if existing is None:
|
|
658
|
+
# First attach — primary owner.
|
|
659
|
+
local_sid = _new_local_session_id(pending.client_id)
|
|
660
|
+
self.state.bind_session(
|
|
661
|
+
pending.client_id, local_sid, upstream_sid_in_result,
|
|
662
|
+
target_id, readonly=False,
|
|
663
|
+
)
|
|
664
|
+
self.state.claim_attacher(
|
|
665
|
+
target_id, pending.client_id, local_sid,
|
|
666
|
+
upstream_sid_in_result)
|
|
667
|
+
# Rewrite the response's sessionId for the client.
|
|
668
|
+
out["result"] = {**result, "sessionId": local_sid} # type: ignore[index]
|
|
669
|
+
else:
|
|
670
|
+
# Edge: another client became primary between our attach and
|
|
671
|
+
# response arriving. Treat as same-client re-attach if we are
|
|
672
|
+
# primary, else flip to reader if allowed, else surface error.
|
|
673
|
+
if existing.primary_client_id == pending.client_id:
|
|
674
|
+
out["result"] = {**result,
|
|
675
|
+
"sessionId": existing.primary_local_session} # type: ignore[index]
|
|
676
|
+
elif pending.attach_allow_shared_read:
|
|
677
|
+
local_sid = _new_local_session_id(pending.client_id)
|
|
678
|
+
self.state.bind_session(
|
|
679
|
+
pending.client_id, local_sid,
|
|
680
|
+
existing.upstream_session_id, target_id, readonly=True)
|
|
681
|
+
self.state.add_reader(target_id, pending.client_id, local_sid)
|
|
682
|
+
out["result"] = {**result, "sessionId": local_sid} # type: ignore[index]
|
|
683
|
+
else:
|
|
684
|
+
# Race-loss: convert to error.
|
|
685
|
+
out = {
|
|
686
|
+
"id": pending.client_request_id,
|
|
687
|
+
"error": {"code": -32602, "message":
|
|
688
|
+
"target already owned by another client (race)"},
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
await self._send_to_client(pending.client_id, json.dumps(out))
|
|
692
|
+
|
|
693
|
+
async def _handle_upstream_event(self, msg: dict, text: str) -> None:
|
|
694
|
+
method = msg.get("method")
|
|
695
|
+
params = msg.get("params") or {}
|
|
696
|
+
upstream_sid = msg.get("sessionId") if isinstance(msg.get("sessionId"), str) else None
|
|
697
|
+
|
|
698
|
+
# Pre-route observations: update the target table and the focus push
|
|
699
|
+
# decision uses the latest state.
|
|
700
|
+
if method == "Target.targetCreated":
|
|
701
|
+
info = params.get("targetInfo")
|
|
702
|
+
if isinstance(info, dict):
|
|
703
|
+
self.state.note_target_info(info)
|
|
704
|
+
elif method == "Target.targetInfoChanged":
|
|
705
|
+
info = params.get("targetInfo")
|
|
706
|
+
if isinstance(info, dict):
|
|
707
|
+
self.state.note_target_info(info)
|
|
708
|
+
tid = info.get("targetId")
|
|
709
|
+
if isinstance(tid, str) and info.get("type") == "page":
|
|
710
|
+
await self._maybe_push_focus(reason="navigated", target_id=tid)
|
|
711
|
+
elif method == "Target.targetDestroyed":
|
|
712
|
+
tid = params.get("targetId")
|
|
713
|
+
if isinstance(tid, str):
|
|
714
|
+
self.state.note_target_destroyed(tid)
|
|
715
|
+
await self._maybe_push_focus(reason="closed", target_id=tid)
|
|
716
|
+
elif method == "Target.attachedToTarget":
|
|
717
|
+
# Update target table for getActiveTab observability.
|
|
718
|
+
info = params.get("targetInfo")
|
|
719
|
+
sid = params.get("sessionId")
|
|
720
|
+
if isinstance(info, dict):
|
|
721
|
+
self.state.note_target_info(info)
|
|
722
|
+
# SessionId binding is handled by the explicit-attach response
|
|
723
|
+
# path (_handle_upstream_response). We used to also bind here as
|
|
724
|
+
# a fallback (auto-attach with active-client heuristic), but the
|
|
725
|
+
# heuristic races with concurrent explicit attaches from multiple
|
|
726
|
+
# clients — wrong client ends up owning the target. Cleaner: let
|
|
727
|
+
# the response handler do all binding. If `Target.setAutoAttach`
|
|
728
|
+
# is later supported as a session-scoped feature, sub-target
|
|
729
|
+
# binding will go through that session's flatten flow, not here.
|
|
730
|
+
if isinstance(sid, str) and isinstance(info, dict):
|
|
731
|
+
tid = info.get("targetId") if isinstance(info.get("targetId"), str) else None
|
|
732
|
+
# If there's a pending explicit attach for this target, drop
|
|
733
|
+
# this event — the response handler owns the binding and will
|
|
734
|
+
# surface attach completion to the requesting client.
|
|
735
|
+
if tid is not None and any(
|
|
736
|
+
pr.method == "Target.attachToTarget"
|
|
737
|
+
and pr.attach_target_id == tid
|
|
738
|
+
for pr in self.state.pending_requests.values()
|
|
739
|
+
):
|
|
740
|
+
return # drop; response handler will tell the client
|
|
741
|
+
# No pending — this is an unsolicited auto-attach (e.g.
|
|
742
|
+
# client previously enabled setAutoAttach). Fall through to
|
|
743
|
+
# default routing: with no binding for `sid`, the
|
|
744
|
+
# `upstream_to_locals` lookup at the bottom of this method
|
|
745
|
+
# will find nothing and the event will be dropped silently.
|
|
746
|
+
# That's acceptable for v0.3 — supporting full setAutoAttach
|
|
747
|
+
# flattening over multi-client mux is v0.4 territory.
|
|
748
|
+
elif method == "Target.detachedFromTarget":
|
|
749
|
+
sid = params.get("sessionId")
|
|
750
|
+
if isinstance(sid, str):
|
|
751
|
+
# Drop the session from every client that had a binding for
|
|
752
|
+
# this upstream sessionId.
|
|
753
|
+
bindings = list(self.state.upstream_to_locals.get(sid, []))
|
|
754
|
+
for binding in bindings:
|
|
755
|
+
self.state.unbind_session_by_local(
|
|
756
|
+
binding.client_id, binding.local_session_id)
|
|
757
|
+
# Rewrite the event per-client so they see THEIR sessionId.
|
|
758
|
+
rewritten = {
|
|
759
|
+
"method": method,
|
|
760
|
+
"params": {**params, "sessionId": binding.local_session_id},
|
|
761
|
+
}
|
|
762
|
+
await self._send_to_client(binding.client_id,
|
|
763
|
+
json.dumps(rewritten))
|
|
764
|
+
return
|
|
765
|
+
elif method == "Inspector.detached":
|
|
766
|
+
if self._trigger_disconnect is not None:
|
|
767
|
+
await self._trigger_disconnect("chrome_exit")
|
|
768
|
+
|
|
769
|
+
# --- routing decision ---
|
|
770
|
+
if upstream_sid is not None:
|
|
771
|
+
# Session-scoped event: route to all bindings of this upstream session
|
|
772
|
+
# (primary + any shared-read readers). Each gets the event with their
|
|
773
|
+
# local sessionId substituted.
|
|
774
|
+
bindings = list(self.state.upstream_to_locals.get(upstream_sid, []))
|
|
775
|
+
if not bindings:
|
|
776
|
+
# Orphan event (session not bound to any client). Drop.
|
|
777
|
+
return
|
|
778
|
+
for binding in bindings:
|
|
779
|
+
rewritten = {
|
|
780
|
+
"method": method,
|
|
781
|
+
"params": params,
|
|
782
|
+
"sessionId": binding.local_session_id,
|
|
783
|
+
}
|
|
784
|
+
await self._send_to_client(binding.client_id,
|
|
785
|
+
json.dumps(rewritten))
|
|
786
|
+
return
|
|
787
|
+
|
|
788
|
+
# Browser-level event (no sessionId) → broadcast.
|
|
789
|
+
await self._broadcast(text)
|
|
790
|
+
|
|
791
|
+
def _pick_active_client(self) -> ClientState | None:
|
|
792
|
+
if not self.state.clients:
|
|
793
|
+
return None
|
|
794
|
+
# Most-recent last_command_at wins.
|
|
795
|
+
return max(self.state.clients.values(), key=lambda c: c.last_command_at)
|
|
796
|
+
|
|
797
|
+
# ---- send primitives ------------------------------------------------
|
|
798
|
+
|
|
799
|
+
async def _send_to_client(self, client_id: int, text: str) -> None:
|
|
800
|
+
fn = self._client_sends.get(client_id)
|
|
801
|
+
if fn is None:
|
|
802
|
+
return
|
|
803
|
+
try:
|
|
804
|
+
await fn(text)
|
|
805
|
+
except Exception as e:
|
|
806
|
+
logger.warning("send to client %d failed: %r", client_id, e)
|
|
807
|
+
|
|
808
|
+
async def _broadcast(self, text: str) -> None:
|
|
809
|
+
for cid in list(self._client_sends.keys()):
|
|
810
|
+
await self._send_to_client(cid, text)
|
|
811
|
+
|
|
812
|
+
# ---- BrowserwrightDaemon.* (per-client RPC) -------------------------------
|
|
813
|
+
|
|
814
|
+
async def _handle_browserdaemon(self, client: ClientState, msg: dict) -> None:
|
|
815
|
+
method = msg["method"]
|
|
816
|
+
req_id = msg.get("id") if isinstance(msg.get("id"), int) else None
|
|
817
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
818
|
+
if isinstance(method, str) and method.startswith("BrowserwrightDaemon.userscript."):
|
|
819
|
+
session_id = await self._require_browser_session(
|
|
820
|
+
client, req_id, method, params)
|
|
821
|
+
if session_id is None:
|
|
822
|
+
return
|
|
823
|
+
# The schema-lock test scans this file for `method == "..."` string
|
|
824
|
+
# literals; this no-op registers the userscript.install verb literal
|
|
825
|
+
# for that scan (userscript.* is otherwise dispatched by prefix).
|
|
826
|
+
if False and method == "BrowserwrightDaemon.userscript.install":
|
|
827
|
+
pass
|
|
828
|
+
verb = method.split(".", 2)[2]
|
|
829
|
+
# rdp dispatch: the extension's userScripts API doesn't exist on a
|
|
830
|
+
# daemon-owned Chrome. Provide an honest shim via
|
|
831
|
+
# Page.addScriptToEvaluateOnNewDocument (see _rdp_userscript). Never
|
|
832
|
+
# -32601 on rdp.
|
|
833
|
+
if self.state.backend_name == "rdp":
|
|
834
|
+
await self._rdp_userscript(client, req_id, verb, params)
|
|
835
|
+
return
|
|
836
|
+
if self._userscript_request is None:
|
|
837
|
+
if (self._ensure_upstream is not None
|
|
838
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
839
|
+
try:
|
|
840
|
+
await self._ensure_upstream()
|
|
841
|
+
except Exception as e:
|
|
842
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
843
|
+
req_id, -32603,
|
|
844
|
+
f"userscript {verb} failed (upstream open): {e!r}"))
|
|
845
|
+
return
|
|
846
|
+
if self._userscript_request is None:
|
|
847
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
848
|
+
req_id, -32601,
|
|
849
|
+
"BrowserwrightDaemon.userscript.* requires the extension backend"))
|
|
850
|
+
return
|
|
851
|
+
try:
|
|
852
|
+
result = await self._userscript_request(verb, params)
|
|
853
|
+
except Exception as e: # noqa: BLE001 - surface to client
|
|
854
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
855
|
+
req_id, -32000, f"userscript {verb} failed: {e}"))
|
|
856
|
+
return
|
|
857
|
+
await self._send_to_client(
|
|
858
|
+
client.client_id, _result_response(req_id, result or {}))
|
|
859
|
+
return
|
|
860
|
+
if method == "BrowserwrightDaemon.getActiveTab":
|
|
861
|
+
session_id = await self._require_browser_session(
|
|
862
|
+
client, req_id, method, params)
|
|
863
|
+
if session_id is None:
|
|
864
|
+
return
|
|
865
|
+
tab = self.state.best_active_tab()
|
|
866
|
+
if (tab is not None and self.state.backend_name == "extension"
|
|
867
|
+
and self._scoped_targets is not None):
|
|
868
|
+
try:
|
|
869
|
+
scoped = await self._scoped_targets(session_id)
|
|
870
|
+
except Exception as e: # noqa: BLE001
|
|
871
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
872
|
+
req_id, -32603, f"getActiveTab scoping failed: {e!r}"))
|
|
873
|
+
return
|
|
874
|
+
scoped_ids = {
|
|
875
|
+
info.get("targetId") for info in scoped
|
|
876
|
+
if isinstance(info, dict)
|
|
877
|
+
}
|
|
878
|
+
if tab.get("targetId") not in scoped_ids:
|
|
879
|
+
tab = None
|
|
880
|
+
payload = tab if tab is not None else {
|
|
881
|
+
"targetId": None, "url": None, "title": None,
|
|
882
|
+
"accuracy": "unknown", "since_seconds": None,
|
|
883
|
+
}
|
|
884
|
+
await self._send_to_client(client.client_id, _result_response(req_id, payload))
|
|
885
|
+
return
|
|
886
|
+
if method == "BrowserwrightDaemon.getBackendInfo":
|
|
887
|
+
from ..backends import kind_for
|
|
888
|
+
# Report the live backend's real kind (extension is LOCAL_RELAY),
|
|
889
|
+
# not a hardcoded UPSTREAM_WS. Unknown/unresolved names ("auto")
|
|
890
|
+
# fall back to UPSTREAM_WS.
|
|
891
|
+
kind = kind_for(self.state.backend_name) or "UPSTREAM_WS"
|
|
892
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
893
|
+
"name": self.state.backend_name,
|
|
894
|
+
"kind": kind,
|
|
895
|
+
"ux_warnings": [],
|
|
896
|
+
"schema_version": 1,
|
|
897
|
+
}))
|
|
898
|
+
return
|
|
899
|
+
if method == "BrowserwrightDaemon.uiState":
|
|
900
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
901
|
+
"ws_count": 1 if self.state.upstream_phase == UpstreamPhase.CONNECTED else 0,
|
|
902
|
+
"last_popup_resolved_at": self.state.last_popup_resolved_at,
|
|
903
|
+
"banner_visible_estimated":
|
|
904
|
+
self.state.upstream_phase == UpstreamPhase.CONNECTED,
|
|
905
|
+
"client_count": len(self.state.clients), # v0.3 addition
|
|
906
|
+
}))
|
|
907
|
+
return
|
|
908
|
+
if method == "BrowserwrightDaemon.waitForSessionAnnounce":
|
|
909
|
+
session_id = await self._require_browser_session(
|
|
910
|
+
client, req_id, method, params)
|
|
911
|
+
if session_id is None:
|
|
912
|
+
return
|
|
913
|
+
timeout = params.get("timeout")
|
|
914
|
+
timeout = float(timeout) if isinstance(timeout, (int, float)) else 2.0
|
|
915
|
+
if self.state.backend_name != "extension":
|
|
916
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
917
|
+
req_id, {"announced": True}))
|
|
918
|
+
return
|
|
919
|
+
if self._wait_session_announce is None:
|
|
920
|
+
if (self._ensure_upstream is not None
|
|
921
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
922
|
+
try:
|
|
923
|
+
await self._ensure_upstream()
|
|
924
|
+
except Exception as e:
|
|
925
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
926
|
+
req_id, -32603,
|
|
927
|
+
f"waitForSessionAnnounce failed (upstream open): {e!r}"))
|
|
928
|
+
return
|
|
929
|
+
if self._wait_session_announce is None:
|
|
930
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
931
|
+
req_id, -32601,
|
|
932
|
+
"BrowserwrightDaemon.waitForSessionAnnounce requires the extension backend"))
|
|
933
|
+
return
|
|
934
|
+
announced = await self._wait_session_announce(session_id, timeout)
|
|
935
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
936
|
+
req_id, {"announced": bool(announced)}))
|
|
937
|
+
return
|
|
938
|
+
if method == "BrowserwrightDaemon.subscribeFocus":
|
|
939
|
+
if await self._require_browser_session(client, req_id, method, params) is None:
|
|
940
|
+
return
|
|
941
|
+
client.subscribed_focus = True
|
|
942
|
+
await self._send_to_client(client.client_id,
|
|
943
|
+
_result_response(req_id, {"ok": True}))
|
|
944
|
+
return
|
|
945
|
+
if method == "BrowserwrightDaemon.unsubscribeFocus":
|
|
946
|
+
if await self._require_browser_session(client, req_id, method, params) is None:
|
|
947
|
+
return
|
|
948
|
+
client.subscribed_focus = False
|
|
949
|
+
await self._send_to_client(client.client_id,
|
|
950
|
+
_result_response(req_id, {"ok": True}))
|
|
951
|
+
return
|
|
952
|
+
if method == "BrowserwrightDaemon.disconnect":
|
|
953
|
+
if await self._require_browser_session(client, req_id, method, params) is None:
|
|
954
|
+
return
|
|
955
|
+
await self._send_to_client(client.client_id,
|
|
956
|
+
_result_response(req_id, {"ok": True}))
|
|
957
|
+
if self._trigger_disconnect is not None:
|
|
958
|
+
await self._trigger_disconnect("skill_disconnect")
|
|
959
|
+
return
|
|
960
|
+
if method == "BrowserwrightDaemon.version":
|
|
961
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
962
|
+
"browserwright_daemon_version": __version__,
|
|
963
|
+
"schema_version": 1,
|
|
964
|
+
}))
|
|
965
|
+
return
|
|
966
|
+
if method == "BrowserwrightDaemon.attachActiveTab":
|
|
967
|
+
# Unified verb. On extension this adopts the user's focused-window
|
|
968
|
+
# active tab (the targetId isn't known until the extension picks
|
|
969
|
+
# it). On rdp the daemon owns the Chrome, so "the active tab" is
|
|
970
|
+
# the session's current front target (most-recently-fronted), and
|
|
971
|
+
# we create+attach one if none exists — an honest equivalent, NOT
|
|
972
|
+
# -32601 (docs §C1). Either path registers the resulting session
|
|
973
|
+
# in the binding tables so subsequent CDP commands route the same
|
|
974
|
+
# way an explicit attach would.
|
|
975
|
+
attach_params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
976
|
+
attach_session = await self._require_browser_session(
|
|
977
|
+
client, req_id, method, attach_params)
|
|
978
|
+
if attach_session is None:
|
|
979
|
+
return
|
|
980
|
+
if self.state.backend_name == "rdp":
|
|
981
|
+
if (self._upstream_command is None and self._ensure_upstream is not None):
|
|
982
|
+
try:
|
|
983
|
+
await self._ensure_upstream()
|
|
984
|
+
except Exception as e:
|
|
985
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
986
|
+
req_id, -32603,
|
|
987
|
+
f"attach active failed (upstream open): {e!r}"))
|
|
988
|
+
return
|
|
989
|
+
await self._rdp_attach_active(client, req_id)
|
|
990
|
+
return
|
|
991
|
+
if self._attach_active_tab is None:
|
|
992
|
+
# Trigger lazy-open once; the listener wires
|
|
993
|
+
# `_attach_active_tab` inside _open_extension_upstream so a
|
|
994
|
+
# cold daemon + extension already connected will become
|
|
995
|
+
# ready by the time ensure_upstream returns.
|
|
996
|
+
if (self._ensure_upstream is not None
|
|
997
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
998
|
+
try:
|
|
999
|
+
await self._ensure_upstream()
|
|
1000
|
+
except Exception as e:
|
|
1001
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1002
|
+
req_id, -32603,
|
|
1003
|
+
f"attach active failed (upstream open): {e!r}"))
|
|
1004
|
+
return
|
|
1005
|
+
if self._attach_active_tab is None:
|
|
1006
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1007
|
+
req_id, -32601,
|
|
1008
|
+
"BrowserwrightDaemon.attachActiveTab requires the extension backend"))
|
|
1009
|
+
return
|
|
1010
|
+
try:
|
|
1011
|
+
# Adopt into THIS session's tab group. The title is cosmetic:
|
|
1012
|
+
# prefer the ledger name when the daemon can see it, otherwise
|
|
1013
|
+
# fall back to the bound session id. The durable association is
|
|
1014
|
+
# still the returned numeric groupId.
|
|
1015
|
+
info = await self._attach_active_tab(
|
|
1016
|
+
session_id=attach_session,
|
|
1017
|
+
group_name=self._session_group_name(client, attach_session))
|
|
1018
|
+
except Exception as e:
|
|
1019
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1020
|
+
req_id, -32000, f"attach active failed: {e!r}"))
|
|
1021
|
+
return
|
|
1022
|
+
upstream_sid = info.get("sessionId")
|
|
1023
|
+
target_id = info.get("targetId")
|
|
1024
|
+
if not isinstance(upstream_sid, str) or not isinstance(target_id, str):
|
|
1025
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1026
|
+
req_id, -32603,
|
|
1027
|
+
f"attach active returned malformed payload: {info!r}"))
|
|
1028
|
+
return
|
|
1029
|
+
# Mirror the binding shape that Target.attachToTarget would
|
|
1030
|
+
# produce: allocate a local sessionId visible to the client,
|
|
1031
|
+
# bind it to the upstream session, and claim the attacher slot.
|
|
1032
|
+
existing = self.state.attachers.get(target_id)
|
|
1033
|
+
if existing is None:
|
|
1034
|
+
local_sid = _new_local_session_id(client.client_id)
|
|
1035
|
+
self.state.bind_session(
|
|
1036
|
+
client.client_id, local_sid, upstream_sid,
|
|
1037
|
+
target_id, readonly=False,
|
|
1038
|
+
)
|
|
1039
|
+
self.state.claim_attacher(
|
|
1040
|
+
target_id, client.client_id, local_sid, upstream_sid)
|
|
1041
|
+
# Stash target metadata so list_tabs / getActiveTab see it.
|
|
1042
|
+
self.state.note_target_info({
|
|
1043
|
+
"targetId": target_id,
|
|
1044
|
+
"type": "page",
|
|
1045
|
+
"url": info.get("url", ""),
|
|
1046
|
+
"title": info.get("title", ""),
|
|
1047
|
+
})
|
|
1048
|
+
elif existing.primary_client_id == client.client_id:
|
|
1049
|
+
# Same client re-attaching the active tab — reuse the
|
|
1050
|
+
# existing local sessionId rather than minting a new one.
|
|
1051
|
+
local_sid = existing.primary_local_session
|
|
1052
|
+
else:
|
|
1053
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1054
|
+
req_id, -32602,
|
|
1055
|
+
f"target {target_id} already attached by another client"))
|
|
1056
|
+
return
|
|
1057
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1058
|
+
req_id, {
|
|
1059
|
+
"sessionId": local_sid,
|
|
1060
|
+
"targetId": target_id,
|
|
1061
|
+
"tabId": info.get("tabId"),
|
|
1062
|
+
"url": info.get("url", ""),
|
|
1063
|
+
"title": info.get("title", ""),
|
|
1064
|
+
}))
|
|
1065
|
+
return
|
|
1066
|
+
if method == "BrowserwrightDaemon.stats":
|
|
1067
|
+
# v0.5: expose in-process metrics counters. Schema is the
|
|
1068
|
+
# observability.Metrics dataclass keys + uptime_seconds.
|
|
1069
|
+
await self._send_to_client(
|
|
1070
|
+
client.client_id,
|
|
1071
|
+
_result_response(req_id, metrics().snapshot()))
|
|
1072
|
+
return
|
|
1073
|
+
if method == "BrowserwrightDaemon.openBackgroundTab":
|
|
1074
|
+
await self._handle_open_background_tab(client, msg, req_id)
|
|
1075
|
+
return
|
|
1076
|
+
if method == "BrowserwrightDaemon.closeTab":
|
|
1077
|
+
await self._handle_close_tab(client, msg, req_id)
|
|
1078
|
+
return
|
|
1079
|
+
if method == "BrowserwrightDaemon.ensureSession":
|
|
1080
|
+
await self._handle_ensure_session(client, msg, req_id)
|
|
1081
|
+
return
|
|
1082
|
+
if method == "BrowserwrightDaemon.endSession":
|
|
1083
|
+
await self._handle_end_session(client, msg, req_id)
|
|
1084
|
+
return
|
|
1085
|
+
if method == "BrowserwrightDaemon.ensureExecutor":
|
|
1086
|
+
await self._handle_ensure_executor(client, msg, req_id)
|
|
1087
|
+
return
|
|
1088
|
+
if method == "BrowserwrightDaemon.killExecutor":
|
|
1089
|
+
await self._handle_kill_executor(client, msg, req_id)
|
|
1090
|
+
return
|
|
1091
|
+
if method == "BrowserwrightDaemon.recoverSession":
|
|
1092
|
+
await self._handle_recover_session(client, msg, req_id)
|
|
1093
|
+
return
|
|
1094
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1095
|
+
req_id, -32601, f"unknown BrowserwrightDaemon method: {method}"))
|
|
1096
|
+
|
|
1097
|
+
# ---- Phase B: BrowserwrightDaemon.openBackgroundTab / closeTab ----------
|
|
1098
|
+
|
|
1099
|
+
async def _handle_open_background_tab(
|
|
1100
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
1101
|
+
) -> None:
|
|
1102
|
+
"""Spec Phase B Feature 1.
|
|
1103
|
+
|
|
1104
|
+
Extension calls the extension upstream's open_background_tab inside the
|
|
1105
|
+
session tab group. RDP handles the same public verb with raw CDP against
|
|
1106
|
+
the session's isolated browser. Both paths register the returned
|
|
1107
|
+
(target_id, upstream_session_id) as a regular client-side binding so
|
|
1108
|
+
subsequent CDP commands work through the same session-id translation
|
|
1109
|
+
path as Target.attachToTarget.
|
|
1110
|
+
"""
|
|
1111
|
+
# Param validation runs FIRST: the schema-lock smoke test calls
|
|
1112
|
+
# every BrowserwrightDaemon.* method with no params and asserts the
|
|
1113
|
+
# response code is NOT -32601 ("unknown method"). Returning -32602
|
|
1114
|
+
# here for the missing required param keeps the lock satisfied
|
|
1115
|
+
# without us wiring a real extension upstream in unit tests.
|
|
1116
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1117
|
+
url = params.get("url")
|
|
1118
|
+
if not isinstance(url, str) or not url:
|
|
1119
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1120
|
+
req_id, -32602,
|
|
1121
|
+
"BrowserwrightDaemon.openBackgroundTab requires params.url"))
|
|
1122
|
+
return
|
|
1123
|
+
group_name = params.get("groupName")
|
|
1124
|
+
if group_name is not None and not isinstance(group_name, str):
|
|
1125
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1126
|
+
req_id, -32602,
|
|
1127
|
+
"BrowserwrightDaemon.openBackgroundTab params.groupName must be a string"))
|
|
1128
|
+
return
|
|
1129
|
+
session = await self._require_browser_session(
|
|
1130
|
+
client, req_id, "BrowserwrightDaemon.openBackgroundTab", params)
|
|
1131
|
+
if session is None:
|
|
1132
|
+
return
|
|
1133
|
+
# rdp dispatch: on an rdp context there is no extension callback —
|
|
1134
|
+
# implement the verb with raw CDP (Target.createTarget + attach). Every
|
|
1135
|
+
# rdp tab is "background" (no human focus to protect), so `background`
|
|
1136
|
+
# is a no-op and `groupId` is -1 (tab groups are an extension concept).
|
|
1137
|
+
if self.state.backend_name == "rdp":
|
|
1138
|
+
if self._upstream_command is None and self._ensure_upstream is not None:
|
|
1139
|
+
try:
|
|
1140
|
+
await self._ensure_upstream()
|
|
1141
|
+
except Exception as e:
|
|
1142
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1143
|
+
req_id, -32603,
|
|
1144
|
+
f"openBackgroundTab failed (upstream open): {e!r}"))
|
|
1145
|
+
return
|
|
1146
|
+
await self._rdp_open_tab(client, req_id, url)
|
|
1147
|
+
return
|
|
1148
|
+
if self._open_background_tab is None:
|
|
1149
|
+
# Lazy-open: a cold daemon + already-connected extension becomes
|
|
1150
|
+
# ready after ensure_upstream runs (listener wires the callbacks
|
|
1151
|
+
# inside _open_extension_upstream). Mirrors attachActiveTab.
|
|
1152
|
+
if (self._ensure_upstream is not None
|
|
1153
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
1154
|
+
try:
|
|
1155
|
+
await self._ensure_upstream()
|
|
1156
|
+
except Exception as e:
|
|
1157
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1158
|
+
req_id, -32603,
|
|
1159
|
+
f"openBackgroundTab failed (upstream open): {e!r}"))
|
|
1160
|
+
return
|
|
1161
|
+
if self._open_background_tab is None:
|
|
1162
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1163
|
+
req_id, -32601,
|
|
1164
|
+
"BrowserwrightDaemon.openBackgroundTab requires the extension backend"))
|
|
1165
|
+
return
|
|
1166
|
+
# Extension-only: the tab-group title comes from the session label in
|
|
1167
|
+
# the ledger unless explicitly overridden. The durable identity is the
|
|
1168
|
+
# numeric groupId returned by the extension path, not this title.
|
|
1169
|
+
group_name = self._session_group_name(client, session, group_name)
|
|
1170
|
+
# `background` (default True) protects the user's focus on the
|
|
1171
|
+
# extension backend; background=False opens the tab in the foreground.
|
|
1172
|
+
background = params.get("background")
|
|
1173
|
+
background = background if isinstance(background, bool) else True
|
|
1174
|
+
try:
|
|
1175
|
+
result = await self._open_background_tab(
|
|
1176
|
+
url, group_name=group_name, session_id=session,
|
|
1177
|
+
background=background)
|
|
1178
|
+
except Exception as e:
|
|
1179
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1180
|
+
req_id, -32603, f"openBackgroundTab failed: {e!r}"))
|
|
1181
|
+
return
|
|
1182
|
+
upstream_sid = result.get("sessionId")
|
|
1183
|
+
target_id = result.get("targetId")
|
|
1184
|
+
if not isinstance(upstream_sid, str) or not isinstance(target_id, str):
|
|
1185
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1186
|
+
req_id, -32603,
|
|
1187
|
+
f"openBackgroundTab returned malformed result: {result!r}"))
|
|
1188
|
+
return
|
|
1189
|
+
# Register the session binding so future CDP commands routed by the
|
|
1190
|
+
# client through this sessionId are translated upstream same as
|
|
1191
|
+
# Target.attachToTarget bindings.
|
|
1192
|
+
local_sid = _new_local_session_id(client.client_id)
|
|
1193
|
+
self.state.bind_session(
|
|
1194
|
+
client.client_id, local_sid, upstream_sid, target_id,
|
|
1195
|
+
readonly=False,
|
|
1196
|
+
)
|
|
1197
|
+
self.state.claim_attacher(
|
|
1198
|
+
target_id, client.client_id, local_sid, upstream_sid,
|
|
1199
|
+
)
|
|
1200
|
+
# Note the target in the visibility table so getActiveTab /
|
|
1201
|
+
# uiState see the new tab. groupId is just metadata for the caller.
|
|
1202
|
+
self.state.note_target_info({
|
|
1203
|
+
"targetId": target_id,
|
|
1204
|
+
"type": "page",
|
|
1205
|
+
"url": result.get("url", ""),
|
|
1206
|
+
"title": result.get("title", ""),
|
|
1207
|
+
})
|
|
1208
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1209
|
+
"sessionId": local_sid,
|
|
1210
|
+
"targetId": target_id,
|
|
1211
|
+
"tabId": result.get("tabId"),
|
|
1212
|
+
"url": result.get("url", ""),
|
|
1213
|
+
"title": result.get("title", ""),
|
|
1214
|
+
"groupId": result.get("groupId", -1),
|
|
1215
|
+
}))
|
|
1216
|
+
|
|
1217
|
+
async def _handle_recover_session(
|
|
1218
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
1219
|
+
) -> None:
|
|
1220
|
+
"""Session-reconnect-recovery.
|
|
1221
|
+
|
|
1222
|
+
After a reconnect / daemon restart the in-memory session→tab bindings
|
|
1223
|
+
are gone, but the Chrome tab group id persisted in the session ledger
|
|
1224
|
+
may still identify a live group.
|
|
1225
|
+
Recover the tabs from that group, re-attach, and register a regular
|
|
1226
|
+
client-side binding for the representative tab so subsequent CDP
|
|
1227
|
+
commands route through the normal sessionId translation path (mirrors
|
|
1228
|
+
openBackgroundTab). Requires backend=extension."""
|
|
1229
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1230
|
+
# rdp is ephemeral (decision 9): the daemon-owned Chrome dies with the
|
|
1231
|
+
# daemon, so there is nothing durable to recover. Surviving targets are
|
|
1232
|
+
# re-attached by the skill's in-process / ledger fast paths, so recover
|
|
1233
|
+
# is an honest no-op here — NEVER -32601 (revised Rule: same-shape,
|
|
1234
|
+
# honest, nearest equivalent). This runs before param validation so the
|
|
1235
|
+
# schema-lock smoke test (no params) sees a result, not an error.
|
|
1236
|
+
if self.state.backend_name == "rdp":
|
|
1237
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1238
|
+
req_id, {"recovered": [], "groupId": -1, "tabs": []}))
|
|
1239
|
+
return
|
|
1240
|
+
# Recovery keys on the persisted numeric groupId (the session's durable
|
|
1241
|
+
# tab-group id from the ledger), NOT the title — names aren't unique.
|
|
1242
|
+
# Validation FIRST so the schema-lock smoke test sees -32602, != -32601.
|
|
1243
|
+
group_id = params.get("groupId")
|
|
1244
|
+
if not isinstance(group_id, int) or group_id < 0:
|
|
1245
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1246
|
+
req_id, -32602,
|
|
1247
|
+
"BrowserwrightDaemon.recoverSession requires params.groupId"))
|
|
1248
|
+
return
|
|
1249
|
+
bs_session = await self._require_browser_session(
|
|
1250
|
+
client, req_id, "BrowserwrightDaemon.recoverSession", params)
|
|
1251
|
+
if bs_session is None:
|
|
1252
|
+
return
|
|
1253
|
+
if self._recover_session is None:
|
|
1254
|
+
# Lazy-open mirror of openBackgroundTab.
|
|
1255
|
+
if (self._ensure_upstream is not None
|
|
1256
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
1257
|
+
try:
|
|
1258
|
+
await self._ensure_upstream()
|
|
1259
|
+
except Exception as e:
|
|
1260
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1261
|
+
req_id, -32603,
|
|
1262
|
+
f"recoverSession failed (upstream open): {e!r}"))
|
|
1263
|
+
return
|
|
1264
|
+
if self._recover_session is None:
|
|
1265
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1266
|
+
req_id, -32601,
|
|
1267
|
+
"BrowserwrightDaemon.recoverSession requires the extension backend"))
|
|
1268
|
+
return
|
|
1269
|
+
try:
|
|
1270
|
+
result = await self._recover_session(bs_session, group_id=group_id)
|
|
1271
|
+
except Exception as e:
|
|
1272
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1273
|
+
req_id, -32603, f"recoverSession failed: {e!r}"))
|
|
1274
|
+
return
|
|
1275
|
+
upstream_sid = result.get("sessionId")
|
|
1276
|
+
target_id = result.get("targetId")
|
|
1277
|
+
if not isinstance(upstream_sid, str) or not isinstance(target_id, str):
|
|
1278
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1279
|
+
req_id, -32603,
|
|
1280
|
+
f"recoverSession returned malformed result: {result!r}"))
|
|
1281
|
+
return
|
|
1282
|
+
# Register the representative tab's session binding (same as
|
|
1283
|
+
# openBackgroundTab) so the client can drive it immediately.
|
|
1284
|
+
local_sid = _new_local_session_id(client.client_id)
|
|
1285
|
+
self.state.bind_session(
|
|
1286
|
+
client.client_id, local_sid, upstream_sid, target_id,
|
|
1287
|
+
readonly=False,
|
|
1288
|
+
)
|
|
1289
|
+
self.state.claim_attacher(
|
|
1290
|
+
target_id, client.client_id, local_sid, upstream_sid,
|
|
1291
|
+
)
|
|
1292
|
+
self.state.note_target_info({
|
|
1293
|
+
"targetId": target_id,
|
|
1294
|
+
"type": "page",
|
|
1295
|
+
"url": result.get("url", ""),
|
|
1296
|
+
"title": result.get("title", ""),
|
|
1297
|
+
})
|
|
1298
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1299
|
+
"sessionId": local_sid,
|
|
1300
|
+
"targetId": target_id,
|
|
1301
|
+
"tabId": result.get("tabId"),
|
|
1302
|
+
"url": result.get("url", ""),
|
|
1303
|
+
"title": result.get("title", ""),
|
|
1304
|
+
"groupId": result.get("groupId", -1),
|
|
1305
|
+
"recovered": result.get("recovered", []),
|
|
1306
|
+
}))
|
|
1307
|
+
|
|
1308
|
+
async def _handle_ensure_session(
|
|
1309
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
1310
|
+
) -> None:
|
|
1311
|
+
"""Phase 2: backend-neutral session verb. Idempotent.
|
|
1312
|
+
|
|
1313
|
+
The backend is read from the ledger (NOT a param) by the dispatcher in
|
|
1314
|
+
listener / Daemon, so by the time a client reaches this Router it must
|
|
1315
|
+
already be routed to the right context:
|
|
1316
|
+
- extension/env/cloud → the shared context (this Router). The client
|
|
1317
|
+
is attached; ensureSession is a no-op success.
|
|
1318
|
+
- rdp → a per-session context. `Daemon.context_for(session_id)`
|
|
1319
|
+
already created the context (its state/router/holder) when this
|
|
1320
|
+
client connected with `?session=`.
|
|
1321
|
+
|
|
1322
|
+
Returns `{ "ok": true }`. Never `-32601`.
|
|
1323
|
+
"""
|
|
1324
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1325
|
+
session = params.get("session_id") or params.get("session")
|
|
1326
|
+
session = session if isinstance(session, str) and session else None
|
|
1327
|
+
if not client.session_id:
|
|
1328
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1329
|
+
req_id, -32602,
|
|
1330
|
+
"BrowserwrightDaemon.ensureSession requires the websocket "
|
|
1331
|
+
"to connect with ?session=<id>; sessionless clients cannot "
|
|
1332
|
+
"materialize or switch session contexts"))
|
|
1333
|
+
return
|
|
1334
|
+
if session is not None and session != client.session_id:
|
|
1335
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1336
|
+
req_id, -32602,
|
|
1337
|
+
f"BrowserwrightDaemon.ensureSession session mismatch: "
|
|
1338
|
+
f"connection is bound to {client.session_id!r}, request asked "
|
|
1339
|
+
f"for {session!r}"))
|
|
1340
|
+
return
|
|
1341
|
+
daemon = self.daemon
|
|
1342
|
+
if daemon is not None:
|
|
1343
|
+
try:
|
|
1344
|
+
# Idempotent get-or-create of the session's context. For
|
|
1345
|
+
# extension/env/cloud this returns the shared context (no-op);
|
|
1346
|
+
# for rdp it ensures the per-session context exists.
|
|
1347
|
+
daemon.context_for(client.session_id) # type: ignore[attr-defined]
|
|
1348
|
+
except Exception as e:
|
|
1349
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1350
|
+
req_id, -32603, f"ensureSession failed: {e!r}"))
|
|
1351
|
+
return
|
|
1352
|
+
await self._send_to_client(
|
|
1353
|
+
client.client_id, _result_response(req_id, {"ok": True}))
|
|
1354
|
+
|
|
1355
|
+
async def _handle_end_session(
|
|
1356
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
1357
|
+
) -> None:
|
|
1358
|
+
"""P5.4 / Phase 2: tear down a browserwright session.
|
|
1359
|
+
|
|
1360
|
+
extension: close the session's extension workspace (owned tabs closed,
|
|
1361
|
+
borrowed kept) via the wired `_end_session` callback.
|
|
1362
|
+
|
|
1363
|
+
rdp: the per-session context owns a dedicated Chrome. Close that Chrome
|
|
1364
|
+
(SIGTERM the launched pid), close the upstream, and drop the context —
|
|
1365
|
+
the uniform, non-`-32601` success shape (docs §RPCs)."""
|
|
1366
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1367
|
+
session = params.get("session")
|
|
1368
|
+
if not isinstance(session, str) or not session:
|
|
1369
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1370
|
+
req_id, -32602,
|
|
1371
|
+
"BrowserwrightDaemon.endSession requires params.session"))
|
|
1372
|
+
return
|
|
1373
|
+
if await self._require_browser_session(
|
|
1374
|
+
client, req_id, "BrowserwrightDaemon.endSession", params,
|
|
1375
|
+
) is None:
|
|
1376
|
+
return
|
|
1377
|
+
|
|
1378
|
+
# Phase B (PR2): kill this session's persistent executor FIRST, symmetric
|
|
1379
|
+
# for rdp + extension (each session has its own executor keyed on the
|
|
1380
|
+
# daemon registry, even though extension sessions share one
|
|
1381
|
+
# UpstreamContext). Idempotent — a no-op when no executor was spawned.
|
|
1382
|
+
daemon = self.daemon
|
|
1383
|
+
registry = getattr(daemon, "executors", None) if daemon is not None else None
|
|
1384
|
+
if registry is not None:
|
|
1385
|
+
try:
|
|
1386
|
+
registry.kill(session)
|
|
1387
|
+
except Exception as e: # noqa: BLE001 - executor kill is best-effort
|
|
1388
|
+
logger.warning("endSession: executor kill for %s failed: %r",
|
|
1389
|
+
session, e)
|
|
1390
|
+
|
|
1391
|
+
# rdp branch: if this session has a live per-session context, tear it
|
|
1392
|
+
# down — close the upstream + SIGTERM the daemon-owned Chrome + drop the
|
|
1393
|
+
# context. A later ensureSession recreates a fresh context + relaunches.
|
|
1394
|
+
if daemon is not None and getattr(daemon, "contexts", None) is not None:
|
|
1395
|
+
if session in daemon.contexts: # type: ignore[attr-defined]
|
|
1396
|
+
try:
|
|
1397
|
+
await daemon.teardown_rdp_context(session) # type: ignore[attr-defined]
|
|
1398
|
+
except Exception as e:
|
|
1399
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1400
|
+
req_id, -32603, f"endSession failed (rdp teardown): {e!r}"))
|
|
1401
|
+
return
|
|
1402
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1403
|
+
req_id, {"ok": True, "closed": [], "kept": [],
|
|
1404
|
+
"backend": "rdp"}))
|
|
1405
|
+
return
|
|
1406
|
+
group_id = params.get("groupId")
|
|
1407
|
+
group_id = group_id if isinstance(group_id, int) and group_id >= 0 else None
|
|
1408
|
+
if self._end_session is None:
|
|
1409
|
+
# Lazy-open mirror of openBackgroundTab.
|
|
1410
|
+
if (self._ensure_upstream is not None
|
|
1411
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
1412
|
+
try:
|
|
1413
|
+
await self._ensure_upstream()
|
|
1414
|
+
except Exception as e:
|
|
1415
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1416
|
+
req_id, -32603, f"endSession failed (upstream open): {e!r}"))
|
|
1417
|
+
return
|
|
1418
|
+
if self._end_session is None:
|
|
1419
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1420
|
+
req_id, -32601,
|
|
1421
|
+
"BrowserwrightDaemon.endSession requires the extension backend"))
|
|
1422
|
+
return
|
|
1423
|
+
try:
|
|
1424
|
+
# Pass group_id only when provided so callbacks with the legacy
|
|
1425
|
+
# single-arg signature stay compatible. group_id is the persisted
|
|
1426
|
+
# numeric tab-group id end_session uses to resolve the group's live
|
|
1427
|
+
# membership (and close the whole group) when the session's bound
|
|
1428
|
+
# groupId is unavailable (e.g. after a daemon restart).
|
|
1429
|
+
if group_id is not None:
|
|
1430
|
+
result = await self._end_session(session, group_id)
|
|
1431
|
+
else:
|
|
1432
|
+
result = await self._end_session(session)
|
|
1433
|
+
except Exception as e:
|
|
1434
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1435
|
+
req_id, -32603, f"endSession failed: {e!r}"))
|
|
1436
|
+
return
|
|
1437
|
+
await self._send_to_client(client.client_id, _result_response(req_id, result))
|
|
1438
|
+
|
|
1439
|
+
async def _handle_ensure_executor(
|
|
1440
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
1441
|
+
) -> None:
|
|
1442
|
+
"""Phase B (Fork 2 control plane): lazily spawn the session's persistent
|
|
1443
|
+
executor and return its data-plane socket path.
|
|
1444
|
+
|
|
1445
|
+
The daemon OWNS the executor lifecycle (Fork 1a): it spawns the
|
|
1446
|
+
subprocess if absent (single-flight per session — no double-spawn),
|
|
1447
|
+
waits for it to bind + write its `_ipc` discovery file, and returns
|
|
1448
|
+
``{exec_sock}``. The thin heredoc client then connects DIRECTLY to that
|
|
1449
|
+
socket to ship code (bulk data never touches this event loop)."""
|
|
1450
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1451
|
+
session = await self._require_browser_session(
|
|
1452
|
+
client, req_id, "BrowserwrightDaemon.ensureExecutor", params)
|
|
1453
|
+
if session is None:
|
|
1454
|
+
return
|
|
1455
|
+
daemon = self.daemon
|
|
1456
|
+
registry = getattr(daemon, "executors", None) if daemon is not None else None
|
|
1457
|
+
if registry is None:
|
|
1458
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1459
|
+
req_id, -32603,
|
|
1460
|
+
"ensureExecutor unavailable: daemon has no executor registry"))
|
|
1461
|
+
return
|
|
1462
|
+
# Failure #4 fix: ensure the session's UPSTREAM (rdp Chrome) is launched
|
|
1463
|
+
# + ready BEFORE we spawn the executor. The executor's cold-start
|
|
1464
|
+
# `connect_over_cdp(facade)` resolves the rdp Chrome's DYNAMIC port,
|
|
1465
|
+
# which is only pinned once `_ensure_upstream` (→ `_launch_rdp_chrome`)
|
|
1466
|
+
# has run. Pre-restart, ordinary client frames launched Chrome before
|
|
1467
|
+
# the executor connected; post-restart the executor path is hit FIRST,
|
|
1468
|
+
# so without this the facade probes the stale default port (9222), 404s,
|
|
1469
|
+
# and the executor exits during cold-start. Mirror the other verbs'
|
|
1470
|
+
# lazy-open (openBackgroundTab / closeTab). Best-effort + bounded: a
|
|
1471
|
+
# launch failure surfaces as a proper error envelope, never a crash.
|
|
1472
|
+
if (self._ensure_upstream is not None
|
|
1473
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
1474
|
+
try:
|
|
1475
|
+
await self._ensure_upstream()
|
|
1476
|
+
except Exception as e: # noqa: BLE001
|
|
1477
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1478
|
+
req_id, -32603,
|
|
1479
|
+
f"ensureExecutor failed (upstream open): {e!r}"))
|
|
1480
|
+
return
|
|
1481
|
+
try:
|
|
1482
|
+
sock_path = await registry.ensure(session)
|
|
1483
|
+
except Exception as e: # noqa: BLE001
|
|
1484
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1485
|
+
req_id, -32603, f"ensureExecutor failed: {e!r}"))
|
|
1486
|
+
return
|
|
1487
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1488
|
+
req_id, {"exec_sock": sock_path}))
|
|
1489
|
+
|
|
1490
|
+
async def _handle_kill_executor(
|
|
1491
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
1492
|
+
) -> None:
|
|
1493
|
+
"""Reap ONLY this session's persistent executor — no browser teardown.
|
|
1494
|
+
|
|
1495
|
+
Used by `session_create.end()` to reap an attach-owned session's
|
|
1496
|
+
resident executor (the full `endSession` path is create-only and would
|
|
1497
|
+
also tear down the browser, which an attach session must leave running).
|
|
1498
|
+
Idempotent: a no-op `{ok: True, killed: False}` when no executor exists.
|
|
1499
|
+
Best-effort — a missing registry still answers a clean (non-`-32601`)
|
|
1500
|
+
result so a stale-daemon caller never errors on `session end`."""
|
|
1501
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1502
|
+
session = await self._require_browser_session(
|
|
1503
|
+
client, req_id, "BrowserwrightDaemon.killExecutor", params)
|
|
1504
|
+
if session is None:
|
|
1505
|
+
return
|
|
1506
|
+
daemon = self.daemon
|
|
1507
|
+
registry = getattr(daemon, "executors", None) if daemon is not None else None
|
|
1508
|
+
killed = False
|
|
1509
|
+
if registry is not None:
|
|
1510
|
+
try:
|
|
1511
|
+
killed = bool(registry.kill(session))
|
|
1512
|
+
except Exception as e: # noqa: BLE001 - executor kill is best-effort
|
|
1513
|
+
logger.warning("killExecutor: kill for %s failed: %r", session, e)
|
|
1514
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1515
|
+
req_id, {"ok": True, "killed": killed}))
|
|
1516
|
+
|
|
1517
|
+
async def _handle_close_tab(
|
|
1518
|
+
self, client: ClientState, msg: dict, req_id: int | None,
|
|
1519
|
+
) -> None:
|
|
1520
|
+
"""Spec Phase B Feature 2.
|
|
1521
|
+
|
|
1522
|
+
Maps the client-facing LOCAL sessionId to the upstream sessionId
|
|
1523
|
+
(mirroring _handle_detach's translation), invokes upstream.close_tab,
|
|
1524
|
+
and tears down the local state bindings whether the close succeeded
|
|
1525
|
+
or not — the tab is gone either way.
|
|
1526
|
+
"""
|
|
1527
|
+
# Param validation runs FIRST (same rationale as openBackgroundTab).
|
|
1528
|
+
# Accept either `sessionId` (per-client; for persistent-ws callers like
|
|
1529
|
+
# Skill REPL) or `targetId` (global; for CLI subcommands whose
|
|
1530
|
+
# transient ws can't share per-client session state).
|
|
1531
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
1532
|
+
local_sid = params.get("sessionId")
|
|
1533
|
+
target_id_param = params.get("targetId")
|
|
1534
|
+
has_sid = isinstance(local_sid, str) and local_sid
|
|
1535
|
+
has_tid = isinstance(target_id_param, str) and target_id_param
|
|
1536
|
+
if not has_sid and not has_tid:
|
|
1537
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1538
|
+
req_id, -32602,
|
|
1539
|
+
"BrowserwrightDaemon.closeTab requires params.sessionId or params.targetId"))
|
|
1540
|
+
return
|
|
1541
|
+
if await self._require_browser_session(
|
|
1542
|
+
client, req_id, "BrowserwrightDaemon.closeTab", params,
|
|
1543
|
+
) is None:
|
|
1544
|
+
return
|
|
1545
|
+
# rdp dispatch: close via Target.closeTarget. Resolve the targetId from
|
|
1546
|
+
# the local sessionId binding when only a sessionId was given.
|
|
1547
|
+
if self.state.backend_name == "rdp":
|
|
1548
|
+
if self._upstream_command is None and self._ensure_upstream is not None:
|
|
1549
|
+
try:
|
|
1550
|
+
await self._ensure_upstream()
|
|
1551
|
+
except Exception as e:
|
|
1552
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1553
|
+
req_id, -32603,
|
|
1554
|
+
f"closeTab failed (upstream open): {e!r}"))
|
|
1555
|
+
return
|
|
1556
|
+
await self._rdp_close_tab(
|
|
1557
|
+
client, req_id,
|
|
1558
|
+
local_sid=local_sid if has_sid else None,
|
|
1559
|
+
target_id_param=target_id_param if has_tid else None)
|
|
1560
|
+
return
|
|
1561
|
+
if self._close_tab is None:
|
|
1562
|
+
# Lazy-open mirror of openBackgroundTab + attachActiveTab.
|
|
1563
|
+
if (self._ensure_upstream is not None
|
|
1564
|
+
and self.state.upstream_phase != UpstreamPhase.CONNECTED):
|
|
1565
|
+
try:
|
|
1566
|
+
await self._ensure_upstream()
|
|
1567
|
+
except Exception as e:
|
|
1568
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1569
|
+
req_id, -32603,
|
|
1570
|
+
f"closeTab failed (upstream open): {e!r}"))
|
|
1571
|
+
return
|
|
1572
|
+
if self._close_tab is None:
|
|
1573
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1574
|
+
req_id, -32601,
|
|
1575
|
+
"BrowserwrightDaemon.closeTab requires the extension backend"))
|
|
1576
|
+
return
|
|
1577
|
+
# Resolve to (target_id, upstream_sid, owner_client_id, owner_local_sid).
|
|
1578
|
+
# sessionId path = per-client lookup; targetId path = state.attachers
|
|
1579
|
+
# global lookup, valid even across different ws clients.
|
|
1580
|
+
target_id: str | None = None
|
|
1581
|
+
upstream_sid: str | None = None
|
|
1582
|
+
owner_client_id: int | None = None
|
|
1583
|
+
owner_local_sid: str | None = None
|
|
1584
|
+
if has_sid:
|
|
1585
|
+
binding = client.sessions.get(local_sid)
|
|
1586
|
+
if binding is not None:
|
|
1587
|
+
target_id = binding.target_id
|
|
1588
|
+
upstream_sid = binding.upstream_session_id
|
|
1589
|
+
owner_client_id = client.client_id
|
|
1590
|
+
owner_local_sid = local_sid
|
|
1591
|
+
if target_id is None and has_tid:
|
|
1592
|
+
target_id = target_id_param
|
|
1593
|
+
attacher = self.state.attachers.get(target_id)
|
|
1594
|
+
if attacher is not None:
|
|
1595
|
+
owner_client_id = attacher.primary_client_id
|
|
1596
|
+
owner_local_sid = attacher.primary_local_session
|
|
1597
|
+
upstream_sid = attacher.upstream_session_id
|
|
1598
|
+
# Fallback path: targetId given but no live attacher (original opener
|
|
1599
|
+
# disconnected — common for CLI subcommands). The tab still exists in
|
|
1600
|
+
# Chrome; close via targetId-only path that bypasses session lookup.
|
|
1601
|
+
if upstream_sid is None and has_tid:
|
|
1602
|
+
target_id = target_id_param
|
|
1603
|
+
if self._close_tab_by_target_id is None:
|
|
1604
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1605
|
+
req_id, -32601,
|
|
1606
|
+
"BrowserwrightDaemon.closeTab (by targetId) requires the extension backend"))
|
|
1607
|
+
return
|
|
1608
|
+
try:
|
|
1609
|
+
result = await self._close_tab_by_target_id(target_id)
|
|
1610
|
+
except Exception as e:
|
|
1611
|
+
# Match the regular path's policy: tear down bookkeeping even
|
|
1612
|
+
# on error so callers can't reuse the stale targetId. There's
|
|
1613
|
+
# no session/attacher binding to drop here by construction —
|
|
1614
|
+
# that's why the attacher lookup failed in the first place —
|
|
1615
|
+
# so just dropping the target visibility entry is enough.
|
|
1616
|
+
self.state.note_target_destroyed(target_id)
|
|
1617
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1618
|
+
req_id, -32603, f"closeTab failed: {e!r}"))
|
|
1619
|
+
return
|
|
1620
|
+
self.state.note_target_destroyed(target_id)
|
|
1621
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1622
|
+
"ok": True,
|
|
1623
|
+
"tabId": result.get("tabId"),
|
|
1624
|
+
}))
|
|
1625
|
+
return
|
|
1626
|
+
if target_id is None or upstream_sid is None:
|
|
1627
|
+
ident = local_sid if has_sid else target_id_param
|
|
1628
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1629
|
+
req_id, -32602, f"unknown sessionId/targetId {ident}"))
|
|
1630
|
+
return
|
|
1631
|
+
try:
|
|
1632
|
+
result = await self._close_tab(upstream_sid)
|
|
1633
|
+
except Exception as e:
|
|
1634
|
+
# Even when upstream signals an error, tear down our bookkeeping
|
|
1635
|
+
# so the caller can't reuse the (now-invalid) sessionId.
|
|
1636
|
+
if owner_client_id is not None and owner_local_sid is not None:
|
|
1637
|
+
self.state.unbind_session_by_local(
|
|
1638
|
+
owner_client_id, owner_local_sid)
|
|
1639
|
+
self.state.note_target_destroyed(target_id)
|
|
1640
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1641
|
+
req_id, -32603, f"closeTab failed: {e!r}"))
|
|
1642
|
+
return
|
|
1643
|
+
# Success: clean up the session + attacher bindings; drop the target.
|
|
1644
|
+
if owner_client_id is not None and owner_local_sid is not None:
|
|
1645
|
+
self.state.unbind_session_by_local(
|
|
1646
|
+
owner_client_id, owner_local_sid)
|
|
1647
|
+
self.state.note_target_destroyed(target_id)
|
|
1648
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1649
|
+
"ok": True,
|
|
1650
|
+
"tabId": result.get("tabId"),
|
|
1651
|
+
}))
|
|
1652
|
+
|
|
1653
|
+
# ---- rdp unified-verb implementations -------------------------------
|
|
1654
|
+
#
|
|
1655
|
+
# On an rdp context (state.backend_name == "rdp") the extension callbacks
|
|
1656
|
+
# (`_open_background_tab` etc.) are never wired — instead we drive the
|
|
1657
|
+
# daemon-owned Chrome with raw CDP through `self._upstream_command`
|
|
1658
|
+
# (UpstreamConnection.send_command, a distinct id space from client
|
|
1659
|
+
# traffic). These keep the wire-facing method names + result shapes
|
|
1660
|
+
# identical to the extension impls so the downstream never branches on
|
|
1661
|
+
# backend (docs §"Unified downstream interface"); divergences are honest,
|
|
1662
|
+
# not -32601.
|
|
1663
|
+
|
|
1664
|
+
async def _rdp_open_tab(
|
|
1665
|
+
self, client: ClientState, req_id: int | None, url: str,
|
|
1666
|
+
) -> None:
|
|
1667
|
+
"""rdp `openBackgroundTab`: Target.createTarget(url) then attach, and
|
|
1668
|
+
register the same client-side binding openBackgroundTab's extension
|
|
1669
|
+
path produces so subsequent CDP commands route through sessionId
|
|
1670
|
+
translation. `groupId` is -1 (tab groups are extension-only); `tabId`
|
|
1671
|
+
is null (Chrome tab ids are an extension concept — the targetId is the
|
|
1672
|
+
rdp-native handle)."""
|
|
1673
|
+
if self._upstream_command is None:
|
|
1674
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1675
|
+
req_id, -32603, "openBackgroundTab: rdp upstream not connected"))
|
|
1676
|
+
return
|
|
1677
|
+
try:
|
|
1678
|
+
created = await self._upstream_command(
|
|
1679
|
+
"Target.createTarget", {"url": url})
|
|
1680
|
+
target_id = _cmd_result(created).get("targetId")
|
|
1681
|
+
if not isinstance(target_id, str):
|
|
1682
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1683
|
+
req_id, -32603,
|
|
1684
|
+
f"openBackgroundTab: Target.createTarget returned {created!r}"))
|
|
1685
|
+
return
|
|
1686
|
+
# Attach (flatten) so the daemon owns a session for this target.
|
|
1687
|
+
attached = await self._upstream_command(
|
|
1688
|
+
"Target.attachToTarget", {"targetId": target_id, "flatten": True})
|
|
1689
|
+
upstream_sid = _cmd_result(attached).get("sessionId")
|
|
1690
|
+
if not isinstance(upstream_sid, str):
|
|
1691
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1692
|
+
req_id, -32603,
|
|
1693
|
+
f"openBackgroundTab: attach returned {attached!r}"))
|
|
1694
|
+
return
|
|
1695
|
+
except Exception as e:
|
|
1696
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1697
|
+
req_id, -32603, f"openBackgroundTab failed: {e!r}"))
|
|
1698
|
+
return
|
|
1699
|
+
# Register the binding (mirror the extension path).
|
|
1700
|
+
local_sid = _new_local_session_id(client.client_id)
|
|
1701
|
+
self.state.bind_session(
|
|
1702
|
+
client.client_id, local_sid, upstream_sid, target_id, readonly=False)
|
|
1703
|
+
self.state.claim_attacher(
|
|
1704
|
+
target_id, client.client_id, local_sid, upstream_sid)
|
|
1705
|
+
meta = self.state.targets.get(target_id) or {}
|
|
1706
|
+
self.state.note_target_info({
|
|
1707
|
+
"targetId": target_id, "type": "page",
|
|
1708
|
+
"url": meta.get("url", url), "title": meta.get("title", ""),
|
|
1709
|
+
})
|
|
1710
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1711
|
+
"sessionId": local_sid,
|
|
1712
|
+
"targetId": target_id,
|
|
1713
|
+
"tabId": None, # rdp has no Chrome tab id; targetId is native
|
|
1714
|
+
"url": meta.get("url", url),
|
|
1715
|
+
"title": meta.get("title", ""),
|
|
1716
|
+
"groupId": -1, # tab groups are extension-only
|
|
1717
|
+
}))
|
|
1718
|
+
|
|
1719
|
+
async def _rdp_attach_active(
|
|
1720
|
+
self, client: ClientState, req_id: int | None,
|
|
1721
|
+
) -> None:
|
|
1722
|
+
"""rdp `attachActiveTab` (docs §C1): the daemon owns this Chrome, so
|
|
1723
|
+
there is no human-contended "focused tab". Define the active tab as the
|
|
1724
|
+
session's current front target — reuse a page target this context is
|
|
1725
|
+
already attached to (most-recently-fronted), else attach an existing
|
|
1726
|
+
page target, else create one. Result shape matches the extension path.
|
|
1727
|
+
This is an honest equivalent, never -32601."""
|
|
1728
|
+
if self._upstream_command is None:
|
|
1729
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1730
|
+
req_id, -32603, "attachActiveTab: rdp upstream not connected"))
|
|
1731
|
+
return
|
|
1732
|
+
# 1. Reuse a page target this client already has bound (front tab).
|
|
1733
|
+
for local_sid, binding in client.sessions.items():
|
|
1734
|
+
tid = binding.target_id
|
|
1735
|
+
meta = self.state.targets.get(tid) or {}
|
|
1736
|
+
if meta.get("type", "page") == "page":
|
|
1737
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1738
|
+
req_id, {
|
|
1739
|
+
"sessionId": local_sid,
|
|
1740
|
+
"targetId": tid,
|
|
1741
|
+
"tabId": None,
|
|
1742
|
+
"url": meta.get("url", ""),
|
|
1743
|
+
"title": meta.get("title", ""),
|
|
1744
|
+
}))
|
|
1745
|
+
return
|
|
1746
|
+
# 2. Attach an existing page target the daemon-owned Chrome already has.
|
|
1747
|
+
target_id: str | None = None
|
|
1748
|
+
url = ""
|
|
1749
|
+
title = ""
|
|
1750
|
+
try:
|
|
1751
|
+
targets = _cmd_result(await self._upstream_command("Target.getTargets", {}))
|
|
1752
|
+
except Exception:
|
|
1753
|
+
targets = None
|
|
1754
|
+
if isinstance(targets, dict):
|
|
1755
|
+
for info in targets.get("targetInfos", []):
|
|
1756
|
+
if not isinstance(info, dict) or info.get("type") != "page":
|
|
1757
|
+
continue
|
|
1758
|
+
tid = info.get("targetId")
|
|
1759
|
+
if isinstance(tid, str):
|
|
1760
|
+
target_id = tid
|
|
1761
|
+
url = info.get("url", "")
|
|
1762
|
+
title = info.get("title", "")
|
|
1763
|
+
break
|
|
1764
|
+
if target_id is None:
|
|
1765
|
+
# 3. No tab at all — create one (mirrors the empty-fallback in the
|
|
1766
|
+
# skill's current_page → open()).
|
|
1767
|
+
await self._rdp_open_tab(client, req_id, "about:blank")
|
|
1768
|
+
return
|
|
1769
|
+
try:
|
|
1770
|
+
attached = await self._upstream_command(
|
|
1771
|
+
"Target.attachToTarget", {"targetId": target_id, "flatten": True})
|
|
1772
|
+
upstream_sid = _cmd_result(attached).get("sessionId")
|
|
1773
|
+
if not isinstance(upstream_sid, str):
|
|
1774
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1775
|
+
req_id, -32603,
|
|
1776
|
+
f"attachActiveTab: attach returned {attached!r}"))
|
|
1777
|
+
return
|
|
1778
|
+
except Exception as e:
|
|
1779
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1780
|
+
req_id, -32603, f"attachActiveTab failed: {e!r}"))
|
|
1781
|
+
return
|
|
1782
|
+
existing = self.state.attachers.get(target_id)
|
|
1783
|
+
if existing is not None and existing.primary_client_id == client.client_id:
|
|
1784
|
+
local_sid = existing.primary_local_session
|
|
1785
|
+
else:
|
|
1786
|
+
local_sid = _new_local_session_id(client.client_id)
|
|
1787
|
+
self.state.bind_session(
|
|
1788
|
+
client.client_id, local_sid, upstream_sid, target_id, readonly=False)
|
|
1789
|
+
self.state.claim_attacher(
|
|
1790
|
+
target_id, client.client_id, local_sid, upstream_sid)
|
|
1791
|
+
self.state.note_target_info({
|
|
1792
|
+
"targetId": target_id, "type": "page", "url": url, "title": title,
|
|
1793
|
+
})
|
|
1794
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1795
|
+
"sessionId": local_sid,
|
|
1796
|
+
"targetId": target_id,
|
|
1797
|
+
"tabId": None,
|
|
1798
|
+
"url": url,
|
|
1799
|
+
"title": title,
|
|
1800
|
+
}))
|
|
1801
|
+
|
|
1802
|
+
async def _rdp_close_tab(
|
|
1803
|
+
self, client: ClientState, req_id: int | None,
|
|
1804
|
+
*, local_sid: str | None, target_id_param: str | None,
|
|
1805
|
+
) -> None:
|
|
1806
|
+
"""rdp `closeTab`: Target.closeTarget(targetId). Resolve the targetId
|
|
1807
|
+
from the client's local sessionId binding when only a sessionId was
|
|
1808
|
+
given (mirrors the extension path's sessionId→target resolution), then
|
|
1809
|
+
tear down local bookkeeping whether or not the close succeeded — the
|
|
1810
|
+
tab is gone either way."""
|
|
1811
|
+
target_id: str | None = None
|
|
1812
|
+
owner_client_id: int | None = None
|
|
1813
|
+
owner_local_sid: str | None = None
|
|
1814
|
+
if local_sid is not None:
|
|
1815
|
+
binding = client.sessions.get(local_sid)
|
|
1816
|
+
if binding is not None:
|
|
1817
|
+
target_id = binding.target_id
|
|
1818
|
+
owner_client_id = client.client_id
|
|
1819
|
+
owner_local_sid = local_sid
|
|
1820
|
+
if target_id is None and target_id_param is not None:
|
|
1821
|
+
target_id = target_id_param
|
|
1822
|
+
attacher = self.state.attachers.get(target_id)
|
|
1823
|
+
if attacher is not None:
|
|
1824
|
+
owner_client_id = attacher.primary_client_id
|
|
1825
|
+
owner_local_sid = attacher.primary_local_session
|
|
1826
|
+
if target_id is None:
|
|
1827
|
+
ident = local_sid or target_id_param
|
|
1828
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1829
|
+
req_id, -32602, f"unknown sessionId/targetId {ident}"))
|
|
1830
|
+
return
|
|
1831
|
+
if self._upstream_command is None:
|
|
1832
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1833
|
+
req_id, -32603, "closeTab: rdp upstream not connected"))
|
|
1834
|
+
return
|
|
1835
|
+
try:
|
|
1836
|
+
await self._upstream_command(
|
|
1837
|
+
"Target.closeTarget", {"targetId": target_id})
|
|
1838
|
+
except Exception as e:
|
|
1839
|
+
# Tear down bookkeeping even on error so the caller can't reuse a
|
|
1840
|
+
# stale id.
|
|
1841
|
+
if owner_client_id is not None and owner_local_sid is not None:
|
|
1842
|
+
self.state.unbind_session_by_local(owner_client_id, owner_local_sid)
|
|
1843
|
+
self.state.note_target_destroyed(target_id)
|
|
1844
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1845
|
+
req_id, -32603, f"closeTab failed: {e!r}"))
|
|
1846
|
+
return
|
|
1847
|
+
if owner_client_id is not None and owner_local_sid is not None:
|
|
1848
|
+
self.state.unbind_session_by_local(owner_client_id, owner_local_sid)
|
|
1849
|
+
self.state.note_target_destroyed(target_id)
|
|
1850
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1851
|
+
"ok": True, "tabId": None,
|
|
1852
|
+
}))
|
|
1853
|
+
|
|
1854
|
+
async def _rdp_userscript(
|
|
1855
|
+
self, client: ClientState, req_id: int | None, verb: str, params: dict,
|
|
1856
|
+
) -> None:
|
|
1857
|
+
"""rdp `userscript.*` shim via Page.addScriptToEvaluateOnNewDocument.
|
|
1858
|
+
|
|
1859
|
+
Caveats (documented per docs §C3 — these are honest divergences from
|
|
1860
|
+
the extension's userScripts API, NOT lies):
|
|
1861
|
+
- The script runs in the page's MAIN world, not the extension's
|
|
1862
|
+
ISOLATED world. There is no privileged `GM_*` API surface.
|
|
1863
|
+
- There is NO match-pattern filtering: CDP injects the script into
|
|
1864
|
+
EVERY new document on the attached target(s). The extension's
|
|
1865
|
+
per-URL `@match` semantics are not reproduced — callers that need
|
|
1866
|
+
URL scoping must guard inside the script body.
|
|
1867
|
+
- `install` registers on the currently-attached rdp sessions; `list`
|
|
1868
|
+
reports what we've registered this process; `remove`/`toggle` are
|
|
1869
|
+
best-effort (CDP's removeScriptToEvaluateOnNewDocument by id).
|
|
1870
|
+
- This persists only for the life of the (ephemeral, C2) Chrome.
|
|
1871
|
+
|
|
1872
|
+
We keep the result shape uniform with the extension impl and never
|
|
1873
|
+
return -32601.
|
|
1874
|
+
"""
|
|
1875
|
+
if self._upstream_command is None:
|
|
1876
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1877
|
+
req_id, -32603, "userscript: rdp upstream not connected"))
|
|
1878
|
+
return
|
|
1879
|
+
# Registry of scripts we've installed this process, keyed by identity.
|
|
1880
|
+
# Lives on the Router (per-context) so list/remove can see it.
|
|
1881
|
+
registry: dict = getattr(self, "_rdp_userscripts", None)
|
|
1882
|
+
if registry is None:
|
|
1883
|
+
registry = {}
|
|
1884
|
+
self._rdp_userscripts = registry # type: ignore[attr-defined]
|
|
1885
|
+
|
|
1886
|
+
try:
|
|
1887
|
+
if verb == "install":
|
|
1888
|
+
script = params.get("script") if isinstance(params.get("script"), dict) else {}
|
|
1889
|
+
source = script.get("source") or script.get("body") or ""
|
|
1890
|
+
identity = script.get("identity") or script.get("id") or f"rdp-us-{len(registry) + 1}"
|
|
1891
|
+
if not isinstance(source, str) or not source:
|
|
1892
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1893
|
+
req_id, -32602, "userscript install requires script.source"))
|
|
1894
|
+
return
|
|
1895
|
+
# Register on every attached rdp session (each its own target).
|
|
1896
|
+
ids: list[str] = []
|
|
1897
|
+
seen: set[str] = set()
|
|
1898
|
+
for binding in list(client.sessions.values()):
|
|
1899
|
+
usid = binding.upstream_session_id
|
|
1900
|
+
if usid in seen:
|
|
1901
|
+
continue
|
|
1902
|
+
seen.add(usid)
|
|
1903
|
+
res = await self._upstream_command(
|
|
1904
|
+
"Page.addScriptToEvaluateOnNewDocument",
|
|
1905
|
+
{"source": source}, usid)
|
|
1906
|
+
sid_id = _cmd_result(res).get("identifier")
|
|
1907
|
+
if isinstance(sid_id, str):
|
|
1908
|
+
ids.append(sid_id)
|
|
1909
|
+
registry[identity] = {"identity": identity, "ids": ids,
|
|
1910
|
+
"enabled": True}
|
|
1911
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1912
|
+
"id": identity, "identity": identity,
|
|
1913
|
+
"sync": {"ok": True, "backend": "rdp",
|
|
1914
|
+
"note": "MAIN-world, no @match filtering (rdp shim)"},
|
|
1915
|
+
}))
|
|
1916
|
+
return
|
|
1917
|
+
if verb == "list":
|
|
1918
|
+
await self._send_to_client(client.client_id, _result_response(req_id, {
|
|
1919
|
+
"scripts": [
|
|
1920
|
+
{"identity": k, "enabled": v.get("enabled", True),
|
|
1921
|
+
"backend": "rdp"}
|
|
1922
|
+
for k, v in registry.items()
|
|
1923
|
+
],
|
|
1924
|
+
}))
|
|
1925
|
+
return
|
|
1926
|
+
if verb in ("remove", "toggle"):
|
|
1927
|
+
key = params.get("key")
|
|
1928
|
+
entry = registry.get(key) if isinstance(key, str) else None
|
|
1929
|
+
if entry is None:
|
|
1930
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1931
|
+
req_id, {"ok": False, "reason": f"no such userscript {key!r}"}))
|
|
1932
|
+
return
|
|
1933
|
+
# CDP can only un-register future injections (removeScript...);
|
|
1934
|
+
# already-injected MAIN-world code can't be retracted.
|
|
1935
|
+
for binding in list(client.sessions.values()):
|
|
1936
|
+
for ident in entry.get("ids", []):
|
|
1937
|
+
try:
|
|
1938
|
+
await self._upstream_command(
|
|
1939
|
+
"Page.removeScriptToEvaluateOnNewDocument",
|
|
1940
|
+
{"identifier": ident},
|
|
1941
|
+
binding.upstream_session_id)
|
|
1942
|
+
except Exception:
|
|
1943
|
+
pass
|
|
1944
|
+
if verb == "remove":
|
|
1945
|
+
registry.pop(key, None)
|
|
1946
|
+
else:
|
|
1947
|
+
enabled = bool(params.get("enabled"))
|
|
1948
|
+
entry["enabled"] = enabled
|
|
1949
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1950
|
+
req_id, {"ok": True, "backend": "rdp"}))
|
|
1951
|
+
return
|
|
1952
|
+
if verb == "logs":
|
|
1953
|
+
# No injection-log facility on rdp; honest empty list.
|
|
1954
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1955
|
+
req_id, {"logs": [], "backend": "rdp"}))
|
|
1956
|
+
return
|
|
1957
|
+
await self._send_to_client(client.client_id, _result_response(
|
|
1958
|
+
req_id, {"ok": False, "reason": f"unsupported userscript verb {verb!r} on rdp"}))
|
|
1959
|
+
except Exception as e:
|
|
1960
|
+
await self._send_to_client(client.client_id, _error_response(
|
|
1961
|
+
req_id, -32000, f"userscript {verb} failed (rdp): {e!r}"))
|
|
1962
|
+
|
|
1963
|
+
# ---- focus push -----------------------------------------------------
|
|
1964
|
+
|
|
1965
|
+
async def _maybe_push_focus(self, *, reason: str, target_id: str) -> None:
|
|
1966
|
+
meta = self.state.targets.get(target_id) or {}
|
|
1967
|
+
params = {
|
|
1968
|
+
"targetId": target_id,
|
|
1969
|
+
"url": meta.get("url", ""),
|
|
1970
|
+
"title": meta.get("title", ""),
|
|
1971
|
+
"accuracy": "heuristic-recent-activate",
|
|
1972
|
+
"reason": reason,
|
|
1973
|
+
}
|
|
1974
|
+
for client in self.state.clients.values():
|
|
1975
|
+
if not client.subscribed_focus or not client.session_id:
|
|
1976
|
+
continue
|
|
1977
|
+
if (self.state.backend_name == "extension"
|
|
1978
|
+
and self._scoped_targets is not None):
|
|
1979
|
+
try:
|
|
1980
|
+
scoped = await self._scoped_targets(client.session_id)
|
|
1981
|
+
except Exception:
|
|
1982
|
+
continue
|
|
1983
|
+
scoped_ids = {
|
|
1984
|
+
info.get("targetId") for info in scoped
|
|
1985
|
+
if isinstance(info, dict)
|
|
1986
|
+
}
|
|
1987
|
+
if target_id not in scoped_ids:
|
|
1988
|
+
continue
|
|
1989
|
+
await self._send_to_client(
|
|
1990
|
+
client.client_id,
|
|
1991
|
+
_event("BrowserwrightDaemon.activeTabChanged", params))
|