browserwright 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. browserwright-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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))