browserwright 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"""Mode B daemon client — long-lived socket connection (spec §10 v0.2, §D).
|
|
2
|
+
|
|
3
|
+
Mode B is the v0.2 happy path:
|
|
4
|
+
|
|
5
|
+
- Skill connects to a running ``browserwright-daemon serve`` instance via its
|
|
6
|
+
unix-socket (POSIX) or TCP+token (Windows) endpoint.
|
|
7
|
+
- Standard CDP commands are tunnelled through. ``BrowserwrightDaemon.*`` RPCs
|
|
8
|
+
(``getActiveTab``, ``disconnect``, ``subscribeFocus``, ``uiState``) are
|
|
9
|
+
answered by the daemon itself, not forwarded upstream.
|
|
10
|
+
- Events fan out to the client: ``upstreamClosed``, ``activeTabChanged``,
|
|
11
|
+
``upstreamReady`` etc.
|
|
12
|
+
|
|
13
|
+
The Skill side here is a single-threaded sync wrapper that ``Session`` holds
|
|
14
|
+
as its sole daemon client (Mode A — the one-shot subprocess resolver — was
|
|
15
|
+
removed; the skill always talks to a running daemon over its socket).
|
|
16
|
+
|
|
17
|
+
Discovery:
|
|
18
|
+
- Endpoint path comes from ``browserwright-daemon status --json`` (or directly
|
|
19
|
+
``${XDG_RUNTIME_DIR:-/tmp}/browserwright-daemon.sock``).
|
|
20
|
+
- On connect, the client appends ``?client=skill-repl&session=<id>`` to the URL.
|
|
21
|
+
|
|
22
|
+
The CDPSession transport connects to our Mode B unix endpoint (translated to
|
|
23
|
+
``ws+unix://``). :func:`client_for_session` builds the client from a resolved
|
|
24
|
+
ledger record; ``DaemonUnavailable`` surfaces lazily when no daemon socket is
|
|
25
|
+
reachable.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import socket
|
|
32
|
+
import subprocess
|
|
33
|
+
import threading
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Optional
|
|
36
|
+
|
|
37
|
+
from .errors import DaemonUnavailable
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default_socket_path() -> Path:
|
|
41
|
+
base = os.environ.get("XDG_RUNTIME_DIR") or "/tmp"
|
|
42
|
+
return Path(base) / "browserwright-daemon.sock"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _windows_port_file() -> Path:
|
|
46
|
+
return Path(os.environ.get("TEMP", "/tmp")) / "browserwright-daemon.port"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ModeBClient:
|
|
50
|
+
"""Mode B daemon endpoint. Use ``connect()`` to confirm reachability;
|
|
51
|
+
``ws_url()`` returns the CDP-compatible URL Skill's ``CDPSession`` can
|
|
52
|
+
open. Active-tab / disconnect / uiState are sent over the same socket.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._endpoint: Optional[str] = None
|
|
57
|
+
self._transport: Optional[str] = None # "unix" or "tcp"
|
|
58
|
+
self._token: Optional[str] = None
|
|
59
|
+
self._cached_ws: Optional[str] = None
|
|
60
|
+
# client label sent on the ws query string for daemon observability;
|
|
61
|
+
# session-bound clients override this with ``skill-s<id>``.
|
|
62
|
+
self._client_label: str = "skill-repl"
|
|
63
|
+
# The session id, emitted on the ws query as ``?session=<id>`` — this is
|
|
64
|
+
# the key the daemon's dispatcher routes on (rdp sessions reach their
|
|
65
|
+
# own UpstreamContext through it). None for the bare REPL client.
|
|
66
|
+
self._session_id: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
# ---- endpoint discovery ---------------------------------------------
|
|
69
|
+
|
|
70
|
+
def discover(self) -> dict:
|
|
71
|
+
"""Return ``{"transport": ..., "path": ..., "host": ..., "port": ...,
|
|
72
|
+
"token": ...}``. Probes the daemon's ``status --json`` first; falls
|
|
73
|
+
back to direct path inspection on POSIX so we still work when
|
|
74
|
+
daemon CLI is on a slow path."""
|
|
75
|
+
try:
|
|
76
|
+
proc = subprocess.run(
|
|
77
|
+
["browserwright-daemon", "status", "--json"],
|
|
78
|
+
capture_output=True, text=True, timeout=3,
|
|
79
|
+
)
|
|
80
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
81
|
+
info = json.loads(proc.stdout)
|
|
82
|
+
if info.get("alive"):
|
|
83
|
+
return self._normalize_endpoint_info(info)
|
|
84
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
# POSIX fallback: just look at the well-known socket path.
|
|
88
|
+
if os.name != "nt":
|
|
89
|
+
sock_path = _default_socket_path()
|
|
90
|
+
if sock_path.exists():
|
|
91
|
+
return {"transport": "unix", "path": str(sock_path)}
|
|
92
|
+
|
|
93
|
+
# Windows fallback: look at the port file.
|
|
94
|
+
port_file = _windows_port_file()
|
|
95
|
+
if port_file.exists():
|
|
96
|
+
try:
|
|
97
|
+
data = json.loads(port_file.read_text(encoding="utf-8"))
|
|
98
|
+
if "port" in data and "token" in data:
|
|
99
|
+
return {
|
|
100
|
+
"transport": "tcp",
|
|
101
|
+
"host": data.get("host", "127.0.0.1"),
|
|
102
|
+
"port": int(data["port"]),
|
|
103
|
+
"token": data["token"],
|
|
104
|
+
}
|
|
105
|
+
except (OSError, ValueError):
|
|
106
|
+
pass
|
|
107
|
+
raise DaemonUnavailable("no Mode B endpoint — the daemon is not running")
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _normalize_endpoint_info(info: dict) -> dict:
|
|
111
|
+
# `status --json` may nest the transport details or flatten them; be
|
|
112
|
+
# tolerant of both shapes daemon-implementer may ship.
|
|
113
|
+
out = dict(info)
|
|
114
|
+
if "endpoint" in info and isinstance(info["endpoint"], dict):
|
|
115
|
+
out.update(info["endpoint"])
|
|
116
|
+
out.pop("alive", None)
|
|
117
|
+
# Drop everything outside our known schema so callers don't pin on it.
|
|
118
|
+
return {k: out[k] for k in ("transport", "path", "host", "port", "token", "name")
|
|
119
|
+
if k in out}
|
|
120
|
+
|
|
121
|
+
# ---- connect probe + ws_url ----------------------------------------
|
|
122
|
+
|
|
123
|
+
def is_alive(self) -> bool:
|
|
124
|
+
"""Cheap reachability check. Returns True iff the daemon's socket
|
|
125
|
+
accepts a `ping`-style request."""
|
|
126
|
+
try:
|
|
127
|
+
ep = self.discover()
|
|
128
|
+
except DaemonUnavailable:
|
|
129
|
+
return False
|
|
130
|
+
try:
|
|
131
|
+
return self._ping(ep)
|
|
132
|
+
except OSError:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
def wait_until_alive(self, timeout: float = 8.0, interval: float = 0.2) -> bool:
|
|
136
|
+
"""Poll :meth:`is_alive` until the daemon answers or ``timeout`` passes.
|
|
137
|
+
Used after a respawn so the caller doesn't race the new daemon's bind.
|
|
138
|
+
Returns whether the daemon came up in time."""
|
|
139
|
+
import time
|
|
140
|
+
deadline = time.monotonic() + timeout
|
|
141
|
+
while time.monotonic() < deadline:
|
|
142
|
+
self.invalidate()
|
|
143
|
+
if self.is_alive():
|
|
144
|
+
return True
|
|
145
|
+
time.sleep(interval)
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def _ping(self, ep: dict) -> bool:
|
|
149
|
+
"""Open a short-lived raw socket to the daemon endpoint and verify
|
|
150
|
+
it's responsive. We avoid a CDP request because the upstream may
|
|
151
|
+
not be open yet — we just want to know the daemon's accept loop is
|
|
152
|
+
live."""
|
|
153
|
+
if ep["transport"] == "unix":
|
|
154
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
155
|
+
s.settimeout(1.5)
|
|
156
|
+
try:
|
|
157
|
+
s.connect(ep["path"])
|
|
158
|
+
except OSError:
|
|
159
|
+
s.close()
|
|
160
|
+
return False
|
|
161
|
+
s.close()
|
|
162
|
+
return True
|
|
163
|
+
# TCP / windows
|
|
164
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
165
|
+
s.settimeout(1.5)
|
|
166
|
+
try:
|
|
167
|
+
s.connect((ep.get("host", "127.0.0.1"), int(ep["port"])))
|
|
168
|
+
except OSError:
|
|
169
|
+
s.close()
|
|
170
|
+
return False
|
|
171
|
+
s.close()
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
def ws_url(self, *, client_label: Optional[str] = None) -> str:
|
|
175
|
+
"""Return a ``ws+unix://`` or ``ws://`` URL the ``CDPSession`` can open.
|
|
176
|
+
|
|
177
|
+
Caches the result; call ``invalidate()`` to force a re-resolve (e.g.
|
|
178
|
+
after a 1011 close).
|
|
179
|
+
"""
|
|
180
|
+
if client_label is None:
|
|
181
|
+
client_label = self._client_label
|
|
182
|
+
if self._cached_ws:
|
|
183
|
+
return self._cached_ws
|
|
184
|
+
ep = self.discover()
|
|
185
|
+
# Session-bound clients carry ``?session=<id>`` — the daemon dispatcher
|
|
186
|
+
# routes on this (not on the client label). Without it an rdp session
|
|
187
|
+
# resolves to None → the shared (extension) context.
|
|
188
|
+
session_q = f"&session={self._session_id}" if self._session_id else ""
|
|
189
|
+
if ep["transport"] == "unix":
|
|
190
|
+
# websockets.sync.client.connect doesn't support ws+unix:// natively;
|
|
191
|
+
# we hand it a pre-built socket via the `sock=` kwarg instead.
|
|
192
|
+
# Return a sentinel URL the CDPSession layer recognises.
|
|
193
|
+
url = f"ws+unix://{ep['path']}?client={client_label}{session_q}"
|
|
194
|
+
else:
|
|
195
|
+
tok = ep.get("token", "")
|
|
196
|
+
host = ep.get("host", "127.0.0.1")
|
|
197
|
+
port = ep["port"]
|
|
198
|
+
url = f"ws://{host}:{port}?token={tok}&client={client_label}{session_q}"
|
|
199
|
+
self._cached_ws = url
|
|
200
|
+
self._endpoint = ep.get("path") or f"{ep.get('host')}:{ep.get('port')}"
|
|
201
|
+
self._transport = ep["transport"]
|
|
202
|
+
self._token = ep.get("token")
|
|
203
|
+
return url
|
|
204
|
+
|
|
205
|
+
def invalidate(self) -> None:
|
|
206
|
+
self._cached_ws = None
|
|
207
|
+
|
|
208
|
+
# Mode A / Mode B protocol alias — Session._resolve_ws_url() picks this.
|
|
209
|
+
def resolve_ws_url(self) -> str:
|
|
210
|
+
return self.ws_url()
|
|
211
|
+
|
|
212
|
+
# ---- backend identity ----------------------------------------------
|
|
213
|
+
|
|
214
|
+
def get_backend_info(self) -> Optional[dict]:
|
|
215
|
+
"""Return the running daemon's reported backend, or ``None`` if the
|
|
216
|
+
daemon doesn't support the ``BrowserwrightDaemon.getBackendInfo`` RPC.
|
|
217
|
+
Surfaced via ``Session.backend_name`` so primitives can branch on
|
|
218
|
+
backend quirks (e.g. extension's explicit-attach model).
|
|
219
|
+
|
|
220
|
+
We use the CLI shim ``browserwright-daemon backend-info --name <X>
|
|
221
|
+
--json`` (zero-side-effect, mirrors doctor's contract) because that's
|
|
222
|
+
the easiest path that doesn't require us to open a ws first.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
cmd = ["browserwright-daemon", "backend-info", "--json"]
|
|
226
|
+
if self._session_id:
|
|
227
|
+
cmd += ["--session", self._session_id]
|
|
228
|
+
proc = subprocess.run(
|
|
229
|
+
cmd,
|
|
230
|
+
capture_output=True, text=True, timeout=5,
|
|
231
|
+
)
|
|
232
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
233
|
+
return None
|
|
234
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
235
|
+
return None
|
|
236
|
+
try:
|
|
237
|
+
return json.loads(proc.stdout)
|
|
238
|
+
except json.JSONDecodeError:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
# ---- minimal one-shot RPC (subprocess CLI) -------------------------
|
|
242
|
+
# These let a caller ask the *same* daemon for BrowserwrightDaemon.* answers
|
|
243
|
+
# via its CLI subcommands without opening a ws. The interesting ones
|
|
244
|
+
# (subscribeFocus, uiState) require a live ws and are handled inside
|
|
245
|
+
# CDPSession instead.
|
|
246
|
+
|
|
247
|
+
def active_tab(self) -> Optional[dict]:
|
|
248
|
+
"""Best-guess user-active tab via the ``browserwright-daemon active-tab``
|
|
249
|
+
CLI subcommand; the ws-based ``BrowserwrightDaemon.getActiveTab`` RPC is
|
|
250
|
+
wired into ``Session`` when a CDP connection is up."""
|
|
251
|
+
if not self._session_id:
|
|
252
|
+
return None
|
|
253
|
+
try:
|
|
254
|
+
proc = subprocess.run(
|
|
255
|
+
["browserwright-daemon", "active-tab", "--json",
|
|
256
|
+
"--session", self._session_id],
|
|
257
|
+
capture_output=True, text=True, timeout=8,
|
|
258
|
+
)
|
|
259
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
260
|
+
return None
|
|
261
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
data = json.loads(proc.stdout)
|
|
265
|
+
except json.JSONDecodeError:
|
|
266
|
+
return None
|
|
267
|
+
return {
|
|
268
|
+
"targetId": data.get("targetId"),
|
|
269
|
+
"url": data.get("url", ""),
|
|
270
|
+
"title": data.get("title", ""),
|
|
271
|
+
"accuracy": data.get("accuracy", "unknown"),
|
|
272
|
+
"since_seconds": data.get("since_seconds"),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
def attach_active(self) -> Optional[dict]:
|
|
276
|
+
"""v0.5.4: ask the daemon's extension backend to attach the
|
|
277
|
+
currently-focused-window active tab — bypasses the popup click.
|
|
278
|
+
|
|
279
|
+
Returns ``{sessionId, targetId, tabId, url, title}`` on success,
|
|
280
|
+
``None`` if the daemon errored or isn't reachable. The verb is unified:
|
|
281
|
+
on extension it adopts the focused tab into the session's group; on rdp
|
|
282
|
+
the daemon returns the session's current front tab. It never returns
|
|
283
|
+
-32601 to the agent.
|
|
284
|
+
"""
|
|
285
|
+
if not self._session_id:
|
|
286
|
+
return None
|
|
287
|
+
try:
|
|
288
|
+
proc = subprocess.run(
|
|
289
|
+
["browserwright-daemon", "attach-active", "--json",
|
|
290
|
+
"--session", self._session_id],
|
|
291
|
+
capture_output=True, text=True, timeout=20,
|
|
292
|
+
)
|
|
293
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
294
|
+
return None
|
|
295
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
296
|
+
return None
|
|
297
|
+
try:
|
|
298
|
+
return json.loads(proc.stdout)
|
|
299
|
+
except json.JSONDecodeError:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
def disconnect_upstream(self, reason: str = "skill_idle") -> bool:
|
|
303
|
+
"""Ask the daemon to close its upstream ws (banner disappears) but
|
|
304
|
+
keep our socket alive. Used by REPL idle policy."""
|
|
305
|
+
if not self._session_id:
|
|
306
|
+
return False
|
|
307
|
+
try:
|
|
308
|
+
proc = subprocess.run(
|
|
309
|
+
["browserwright-daemon", "disconnect",
|
|
310
|
+
"--reason", reason, "--session", self._session_id],
|
|
311
|
+
capture_output=True, text=True, timeout=5,
|
|
312
|
+
)
|
|
313
|
+
return proc.returncode == 0
|
|
314
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
315
|
+
return False
|
|
316
|
+
|
|
317
|
+
# ---- Phase B: open_background / close_tab CLI shims ---------------
|
|
318
|
+
|
|
319
|
+
def open_background(self, url: str, *, group: str = "Agent") -> Optional[dict]:
|
|
320
|
+
"""Phase B Feature 1 — invoke ``browserwright-daemon open-background``.
|
|
321
|
+
|
|
322
|
+
Returns the parsed JSON result (``{sessionId,targetId,tabId,url,
|
|
323
|
+
title,groupId}``) or ``None`` if the CLI was unavailable. On
|
|
324
|
+
failure the captured subprocess detail is stashed on
|
|
325
|
+
``self.last_cli_error`` so the caller can surface a meaningful
|
|
326
|
+
message instead of guessing. The daemon-side handler requires
|
|
327
|
+
backend=extension; on any other backend the call surfaces an
|
|
328
|
+
error (returncode != 0) which is recorded here verbatim.
|
|
329
|
+
"""
|
|
330
|
+
self.last_cli_error = None
|
|
331
|
+
cmd = ["browserwright-daemon", "open-background",
|
|
332
|
+
"--url", url,
|
|
333
|
+
"--group", group]
|
|
334
|
+
if self._session_id:
|
|
335
|
+
cmd += ["--session", self._session_id]
|
|
336
|
+
try:
|
|
337
|
+
proc = subprocess.run(
|
|
338
|
+
cmd, capture_output=True, text=True, timeout=15,
|
|
339
|
+
)
|
|
340
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
341
|
+
self.last_cli_error = f"subprocess failed: {e!r}"
|
|
342
|
+
return None
|
|
343
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
344
|
+
self.last_cli_error = (
|
|
345
|
+
f"`{' '.join(cmd)}` exit={proc.returncode}; "
|
|
346
|
+
f"stderr={proc.stderr.strip() or '<empty>'}; "
|
|
347
|
+
f"stdout={proc.stdout.strip() or '<empty>'}"
|
|
348
|
+
)
|
|
349
|
+
return None
|
|
350
|
+
try:
|
|
351
|
+
return json.loads(proc.stdout)
|
|
352
|
+
except json.JSONDecodeError:
|
|
353
|
+
self.last_cli_error = (
|
|
354
|
+
f"`{' '.join(cmd)}` returned non-JSON stdout: {proc.stdout!r}"
|
|
355
|
+
)
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
def close_tab(
|
|
359
|
+
self, session_id: str | None = None, *, target_id: str | None = None,
|
|
360
|
+
) -> Optional[dict]:
|
|
361
|
+
"""Phase B Feature 2 — invoke ``browserwright-daemon close-tab``.
|
|
362
|
+
|
|
363
|
+
Pass ``target_id`` (the ``ext-tab-N`` string returned by
|
|
364
|
+
``open_background``) when calling from a fresh subprocess context —
|
|
365
|
+
the CLI's transient ws can't see other clients' session bindings.
|
|
366
|
+
``session_id`` works only from a persistent ws (e.g. inside the
|
|
367
|
+
Skill REPL where the same client connection issued the open).
|
|
368
|
+
|
|
369
|
+
Returns ``{"ok":True,"tabId":N}`` on success or ``None`` when the
|
|
370
|
+
CLI is unreachable / the daemon errored.
|
|
371
|
+
"""
|
|
372
|
+
if not session_id and not target_id:
|
|
373
|
+
return None
|
|
374
|
+
self.last_cli_error = None
|
|
375
|
+
cmd = ["browserwright-daemon", "close-tab"]
|
|
376
|
+
if self._session_id:
|
|
377
|
+
cmd += ["--session", self._session_id]
|
|
378
|
+
if target_id:
|
|
379
|
+
cmd += ["--target-id", target_id]
|
|
380
|
+
if session_id:
|
|
381
|
+
cmd += ["--session-id", session_id]
|
|
382
|
+
try:
|
|
383
|
+
proc = subprocess.run(
|
|
384
|
+
cmd, capture_output=True, text=True, timeout=10,
|
|
385
|
+
)
|
|
386
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
387
|
+
self.last_cli_error = f"subprocess failed: {e!r}"
|
|
388
|
+
return None
|
|
389
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
390
|
+
self.last_cli_error = (
|
|
391
|
+
f"`{' '.join(cmd)}` exit={proc.returncode}; "
|
|
392
|
+
f"stderr={proc.stderr.strip() or '<empty>'}; "
|
|
393
|
+
f"stdout={proc.stdout.strip() or '<empty>'}"
|
|
394
|
+
)
|
|
395
|
+
return None
|
|
396
|
+
try:
|
|
397
|
+
return json.loads(proc.stdout)
|
|
398
|
+
except json.JSONDecodeError:
|
|
399
|
+
self.last_cli_error = (
|
|
400
|
+
f"`{' '.join(cmd)}` returned non-JSON stdout: {proc.stdout!r}"
|
|
401
|
+
)
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
def doctor(self) -> dict:
|
|
405
|
+
"""Forward ``browserwright-daemon doctor --json`` over a subprocess."""
|
|
406
|
+
try:
|
|
407
|
+
proc = subprocess.run(
|
|
408
|
+
["browserwright-daemon", "doctor", "--json"],
|
|
409
|
+
capture_output=True, text=True, timeout=10,
|
|
410
|
+
)
|
|
411
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
|
412
|
+
return {"schema_version": 1, "backends": [], "error": str(e),
|
|
413
|
+
"skill_synthetic": True}
|
|
414
|
+
if proc.returncode != 0:
|
|
415
|
+
return {"schema_version": 1, "backends": [],
|
|
416
|
+
"error": (proc.stderr or proc.stdout).strip(),
|
|
417
|
+
"skill_synthetic": True}
|
|
418
|
+
try:
|
|
419
|
+
return json.loads(proc.stdout)
|
|
420
|
+
except json.JSONDecodeError:
|
|
421
|
+
return {"schema_version": 1, "backends": [],
|
|
422
|
+
"error": "doctor output was not JSON",
|
|
423
|
+
"skill_synthetic": True}
|
|
424
|
+
|
|
425
|
+
# ---- S6 (A2-a): daemon ↔ code version coherence --------------------
|
|
426
|
+
#
|
|
427
|
+
# A daemon that's been running across a package upgrade speaks the OLD
|
|
428
|
+
# protocol — newer RPC methods come back as -32601 "unknown method", which
|
|
429
|
+
# looks like a mysterious failure (the session-1 pothole). We detect the
|
|
430
|
+
# version skew up front and restart the daemon so it picks up the new code.
|
|
431
|
+
|
|
432
|
+
def running_daemon_version(self) -> Optional[str]:
|
|
433
|
+
"""Version the *running* daemon advertises via ``status --json``.
|
|
434
|
+
|
|
435
|
+
Returns ``None`` when the daemon isn't reachable OR is too old to
|
|
436
|
+
advertise a version. A missing version is deliberately indistinguishable
|
|
437
|
+
from "no daemon" here; the coherence guard disambiguates via
|
|
438
|
+
:meth:`is_alive`."""
|
|
439
|
+
try:
|
|
440
|
+
proc = subprocess.run(
|
|
441
|
+
["browserwright-daemon", "status", "--json"],
|
|
442
|
+
capture_output=True, text=True, timeout=3,
|
|
443
|
+
)
|
|
444
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
445
|
+
return None
|
|
446
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
447
|
+
return None
|
|
448
|
+
try:
|
|
449
|
+
info = json.loads(proc.stdout)
|
|
450
|
+
except json.JSONDecodeError:
|
|
451
|
+
return None
|
|
452
|
+
v = info.get("version")
|
|
453
|
+
return v if isinstance(v, str) and v else None
|
|
454
|
+
|
|
455
|
+
def installed_daemon_version(self) -> Optional[str]:
|
|
456
|
+
"""Version of the ``browserwright-daemon`` package installed on disk, read
|
|
457
|
+
from ``browserwright-daemon version``. ``None`` when it can't be determined
|
|
458
|
+
(in which case the coherence guard declines to act — better than
|
|
459
|
+
thrash-restarting on a comparison we can't make)."""
|
|
460
|
+
try:
|
|
461
|
+
proc = subprocess.run(
|
|
462
|
+
["browserwright-daemon", "version"],
|
|
463
|
+
capture_output=True, text=True, timeout=3,
|
|
464
|
+
)
|
|
465
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
466
|
+
return None
|
|
467
|
+
if proc.returncode != 0:
|
|
468
|
+
return None
|
|
469
|
+
# Output shape: "browserwright-daemon X.Y.Z" — take the last whitespace token.
|
|
470
|
+
text = (proc.stdout or "").strip()
|
|
471
|
+
if not text:
|
|
472
|
+
return None
|
|
473
|
+
return text.split()[-1] or None
|
|
474
|
+
|
|
475
|
+
def _stop_daemon(self) -> None:
|
|
476
|
+
"""Stop the running daemon (mirrors the PID-guarded ``stop`` CLI)."""
|
|
477
|
+
try:
|
|
478
|
+
subprocess.run(
|
|
479
|
+
["browserwright-daemon", "stop"],
|
|
480
|
+
capture_output=True, text=True, timeout=10,
|
|
481
|
+
)
|
|
482
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
def _spawn_daemon(self, backend: Optional[str] = None) -> None:
|
|
486
|
+
"""Spawn a fresh daemon. Detached so it outlives this process, mirroring
|
|
487
|
+
how cold-start launches ``serve``.
|
|
488
|
+
|
|
489
|
+
``backend`` pins ``--backend`` on the respawn. The daemon refuses to
|
|
490
|
+
start under auto (it would silently fall back to rdp and leave the
|
|
491
|
+
extension relay un-bound), so a restart that drops the backend would
|
|
492
|
+
kill the daemon. Callers that know the backend the old daemon was
|
|
493
|
+
serving (see ``ensure_version_coherent``) pass it through so the
|
|
494
|
+
replacement keeps serving the same backend."""
|
|
495
|
+
cmd = ["browserwright-daemon", "serve"]
|
|
496
|
+
if backend:
|
|
497
|
+
cmd += ["--backend", backend]
|
|
498
|
+
try:
|
|
499
|
+
subprocess.Popen(
|
|
500
|
+
cmd,
|
|
501
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
502
|
+
stdin=subprocess.DEVNULL, start_new_session=True,
|
|
503
|
+
)
|
|
504
|
+
except FileNotFoundError:
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
def ensure_version_coherent(self) -> bool:
|
|
508
|
+
"""If the running daemon's version differs from the installed package
|
|
509
|
+
version (or it advertises none at all), stop + respawn it so the new
|
|
510
|
+
code takes effect. Returns ``True`` iff a restart was performed.
|
|
511
|
+
|
|
512
|
+
No-ops (returns ``False``) when:
|
|
513
|
+
- there's no daemon running at all (cold-start owns spawning), or
|
|
514
|
+
- the installed version can't be determined (can't compare safely).
|
|
515
|
+
|
|
516
|
+
Generic by construction: it never inspects RPC methods or specific
|
|
517
|
+
version strings — any future skew is handled the same way."""
|
|
518
|
+
installed = self.installed_daemon_version()
|
|
519
|
+
if installed is None:
|
|
520
|
+
return False
|
|
521
|
+
running = self.running_daemon_version()
|
|
522
|
+
if running is None:
|
|
523
|
+
# Distinguish "no daemon" (don't touch) from "daemon too old to
|
|
524
|
+
# report a version" (stale → restart).
|
|
525
|
+
if not self.is_alive():
|
|
526
|
+
return False
|
|
527
|
+
elif running == installed:
|
|
528
|
+
return False
|
|
529
|
+
# running is None-but-alive (legacy) OR running != installed → stale.
|
|
530
|
+
# Capture the backend the stale daemon is serving BEFORE we stop it, so
|
|
531
|
+
# the respawn pins the same backend. The daemon refuses to start under
|
|
532
|
+
# auto, so dropping the backend here would leave it dead. A daemon too
|
|
533
|
+
# old to answer backend-info yields None → respawn without a pin and let
|
|
534
|
+
# the daemon's own guard decide (BD_BACKEND/default_backend).
|
|
535
|
+
prior = self.get_backend_info() or {}
|
|
536
|
+
backend = prior.get("backend") or None
|
|
537
|
+
self._stop_daemon()
|
|
538
|
+
self._spawn_daemon(backend=backend)
|
|
539
|
+
self.invalidate()
|
|
540
|
+
return True
|
|
541
|
+
|
|
542
|
+
# ---- S6 (A2-b): rewrite -32601 "unknown method" --------------------
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def is_stale_method_error(error: Any) -> bool:
|
|
546
|
+
"""True iff a JSON-RPC error object is a ``-32601`` "method not found".
|
|
547
|
+
Generic — keys only on the standard code, never on a method name."""
|
|
548
|
+
return isinstance(error, dict) and error.get("code") == -32601
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def explain_rpc_error(method: str, error: Any) -> str:
|
|
552
|
+
"""Turn a JSON-RPC error object into a human-actionable message.
|
|
553
|
+
|
|
554
|
+
For ``-32601`` (unknown method) — the signature of a daemon running
|
|
555
|
+
older code than what's installed — we rewrite the raw envelope into a
|
|
556
|
+
clear "the daemon is stale, restart it" message that names the offending
|
|
557
|
+
method. Any other code is surfaced as its own message verbatim (those
|
|
558
|
+
are real protocol errors, not staleness)."""
|
|
559
|
+
if ModeBClient.is_stale_method_error(error):
|
|
560
|
+
return (
|
|
561
|
+
f"the running daemon doesn't have method {method!r} — it is "
|
|
562
|
+
f"likely stale (older than the installed code). Restart it with "
|
|
563
|
+
f"`browserwright-daemon stop && browserwright-daemon serve`."
|
|
564
|
+
)
|
|
565
|
+
if isinstance(error, dict) and error.get("message"):
|
|
566
|
+
return str(error["message"])
|
|
567
|
+
return f"RPC {method!r} failed: {error!r}"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ---- factory: build a client bound to a resolved session ------------
|
|
571
|
+
|
|
572
|
+
def client_for_session(record: dict) -> ModeBClient:
|
|
573
|
+
"""Build a Mode B client for the single global daemon (fixed socket).
|
|
574
|
+
|
|
575
|
+
The connection carries the session identity as its client label
|
|
576
|
+
(``skill-s<id>``) for daemon-side observability and per-session routing;
|
|
577
|
+
falls back to the default ``skill-repl`` when the record has no id.
|
|
578
|
+
|
|
579
|
+
Construction is lazy — ``DaemonUnavailable`` surfaces only when a primitive
|
|
580
|
+
first resolves the ws — but when the daemon *is* already up we restart it if
|
|
581
|
+
it's running stale code (S6 / A2-a), so we don't lean on newer RPCs against
|
|
582
|
+
an old protocol."""
|
|
583
|
+
client = ModeBClient()
|
|
584
|
+
sid = record.get("id")
|
|
585
|
+
if sid:
|
|
586
|
+
client._client_label = f"skill-s{sid}"
|
|
587
|
+
client._session_id = str(sid)
|
|
588
|
+
if client.is_alive() and client.ensure_version_coherent():
|
|
589
|
+
client.wait_until_alive()
|
|
590
|
+
return client
|