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,8 @@
|
|
|
1
|
+
"""browserwright-daemon: resolve a browser-level CDP WebSocket URL from any local Chrome.
|
|
2
|
+
|
|
3
|
+
v0.1 is Mode A only — a one-shot CLI resolver. Mode B (socket proxy) lands in v0.2.
|
|
4
|
+
The package surface is the `browserwright-daemon` console script; importing this module
|
|
5
|
+
directly is not part of the public contract (Skill talks via subprocess only).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from browserwright.version import __version__ # noqa: F401
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""IPC plumbing for Mode B (§6.7).
|
|
2
|
+
|
|
3
|
+
Ported from browser-harness `_ipc.py` — the file-naming / ping patterns are
|
|
4
|
+
field-tested. Two changes from the source:
|
|
5
|
+
1. Prefix is `browserwright-daemon` instead of `bu-` (separate product).
|
|
6
|
+
2. Ping is HTTP (`GET /__ping__`) over the local socket *before* a ws upgrade
|
|
7
|
+
ever happens — this lets stale-detection work without negotiating a CDP
|
|
8
|
+
session. Spec §6.7 says the ping should be CDP `Browser.getVersion`; we
|
|
9
|
+
defer that to the ws layer once a daemon is live, but the cold-start
|
|
10
|
+
stale-check before bind needs cheaper plumbing.
|
|
11
|
+
|
|
12
|
+
There is exactly ONE daemon, so the endpoint is a fixed path (no per-instance
|
|
13
|
+
name — the `BD_NAME` concept was removed; see docs/refactor-single-daemon.md):
|
|
14
|
+
|
|
15
|
+
POSIX:
|
|
16
|
+
sock_path = {XDG_RUNTIME_DIR | /tmp}/browserwright-daemon.sock
|
|
17
|
+
log_path = {TMPDIR | /tmp}/browserwright-daemon.log
|
|
18
|
+
pid_path = {XDG_RUNTIME_DIR | /tmp}/browserwright-daemon.pid
|
|
19
|
+
|
|
20
|
+
Windows:
|
|
21
|
+
port_path = %TEMP%/browserwright-daemon.port (atomic-written JSON)
|
|
22
|
+
log_path = %TEMP%/browserwright-daemon.log
|
|
23
|
+
pid_path = %TEMP%/browserwright-daemon.pid
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import secrets
|
|
31
|
+
import socket
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
38
|
+
_PREFIX = "browserwright-daemon"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---- file paths ------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _runtime_dir() -> Path:
|
|
45
|
+
"""Where sock + pid + (Windows) port file live.
|
|
46
|
+
|
|
47
|
+
AF_UNIX sun_path has a hard 104-byte budget on macOS. `tempfile.gettempdir()`
|
|
48
|
+
on macOS returns `/var/folders/...` which would blow that budget — so we use
|
|
49
|
+
`/tmp` on POSIX explicitly. On Windows we use `%TEMP%`, no path-length issue.
|
|
50
|
+
"""
|
|
51
|
+
if (xdg := os.environ.get("XDG_RUNTIME_DIR")):
|
|
52
|
+
return Path(xdg)
|
|
53
|
+
if IS_WINDOWS:
|
|
54
|
+
return Path(tempfile.gettempdir())
|
|
55
|
+
return Path("/tmp")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tmp_dir() -> Path:
|
|
59
|
+
"""Where the log lives (long paths OK)."""
|
|
60
|
+
if (t := os.environ.get("TMPDIR")):
|
|
61
|
+
return Path(t)
|
|
62
|
+
if IS_WINDOWS:
|
|
63
|
+
return Path(tempfile.gettempdir())
|
|
64
|
+
return Path("/tmp")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sock_path() -> Path:
|
|
68
|
+
return _runtime_dir() / f"{_PREFIX}.sock"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def port_path() -> Path:
|
|
72
|
+
"""Windows token file. Holds JSON {port, token}."""
|
|
73
|
+
return _runtime_dir() / f"{_PREFIX}.port"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def log_path() -> Path:
|
|
77
|
+
return _tmp_dir() / f"{_PREFIX}.log"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pid_path() -> Path:
|
|
81
|
+
return _runtime_dir() / f"{_PREFIX}.pid"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def facade_path() -> Path:
|
|
85
|
+
"""Discovery file for the Playwright CDP facade.
|
|
86
|
+
|
|
87
|
+
Holds JSON ``{"ws": "ws://host:port/cdp", "port": N}`` written by the daemon
|
|
88
|
+
when the facade binds (Phase C). The skill layer reads this to
|
|
89
|
+
``connect_over_cdp`` without parsing daemon logs or guessing the port. Lives
|
|
90
|
+
beside the socket/pid under XDG_RUNTIME_DIR so e2e isolation (a throwaway
|
|
91
|
+
XDG_RUNTIME_DIR) gives each test daemon its own discovery file."""
|
|
92
|
+
return _runtime_dir() / f"{_PREFIX}.facade"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_facade_file(ws_url: str, port: int) -> None:
|
|
96
|
+
"""Atomic write of the facade discovery file (mirrors write_port_file)."""
|
|
97
|
+
fp = facade_path()
|
|
98
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
tmp = fp.with_name(fp.name + ".tmp")
|
|
100
|
+
tmp.write_text(json.dumps({"ws": ws_url, "port": port}))
|
|
101
|
+
os.replace(tmp, fp)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_facade_file() -> tuple[str | None, int | None]:
|
|
105
|
+
"""Return ``(ws_url, port)`` of the running daemon's facade, or
|
|
106
|
+
``(None, None)`` when the file is absent/unreadable (no daemon / facade off).
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
d = json.loads(facade_path().read_text())
|
|
110
|
+
return str(d["ws"]), int(d["port"])
|
|
111
|
+
except (FileNotFoundError, ValueError, KeyError, TypeError, OSError):
|
|
112
|
+
return None, None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---- Phase B: per-session executor discovery -------------------------------
|
|
116
|
+
#
|
|
117
|
+
# The persistent per-session executor (`browserwright._executor`) binds its OWN
|
|
118
|
+
# unix socket (the data plane — Fork 2) and writes a discovery file the thin
|
|
119
|
+
# heredoc client reads after the daemon `ensureExecutor` verb spawns it. The
|
|
120
|
+
# socket NAME must be short: AF_UNIX `sun_path` has a hard 104-byte budget on
|
|
121
|
+
# macOS (see `_runtime_dir`), and `_runtime_dir()` is already `/tmp` for that
|
|
122
|
+
# reason — so we key the per-session socket on a SHORT id digest, not the raw
|
|
123
|
+
# session id (which can be long, e.g. `e2e-phasec-<uuid4hex>`).
|
|
124
|
+
#
|
|
125
|
+
# TODO(Windows): there is no AF_UNIX on Windows; the executor socket will need
|
|
126
|
+
# the same TCP+token fallback the mode_b path uses (`make_tcp_socket` +
|
|
127
|
+
# port-file). Not built here — POSIX unix-socket happy path only for PR1.
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _exec_shortid(session_id: str) -> str:
|
|
131
|
+
"""A short, filesystem-safe digest of a session id for the socket name.
|
|
132
|
+
|
|
133
|
+
Keeps the AF_UNIX path within the 104-byte budget regardless of how long
|
|
134
|
+
the raw session id is. 12 hex chars of SHA-256 is collision-safe enough for
|
|
135
|
+
a per-machine, per-user runtime dir."""
|
|
136
|
+
import hashlib
|
|
137
|
+
|
|
138
|
+
return hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:12]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def executor_sock_path(session_id: str) -> Path:
|
|
142
|
+
"""Unix socket the per-session executor binds (`bw-exec-<shortid>.sock`)."""
|
|
143
|
+
return _runtime_dir() / f"bw-exec-{_exec_shortid(session_id)}.sock"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def executor_file_path(session_id: str) -> Path:
|
|
147
|
+
"""Discovery file the executor writes when its socket is bound + ready.
|
|
148
|
+
|
|
149
|
+
Holds JSON ``{"sock": "<path>", "pid": N, "session": "<id>"}``."""
|
|
150
|
+
return _runtime_dir() / f"bw-exec-{_exec_shortid(session_id)}.json"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_executor_file(session_id: str, sock: str, pid: int) -> None:
|
|
154
|
+
"""Atomic write of the executor discovery file (mirrors write_facade_file).
|
|
155
|
+
|
|
156
|
+
Written by the executor once its socket is bound and the worker is ready,
|
|
157
|
+
so a reader that sees the file can immediately connect."""
|
|
158
|
+
fp = executor_file_path(session_id)
|
|
159
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
tmp = fp.with_name(fp.name + ".tmp")
|
|
161
|
+
tmp.write_text(json.dumps({"sock": sock, "pid": pid, "session": session_id}))
|
|
162
|
+
os.replace(tmp, fp)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def read_executor_file(session_id: str) -> tuple[str | None, int | None]:
|
|
166
|
+
"""Return ``(sock_path, pid)`` of the session's executor, or
|
|
167
|
+
``(None, None)`` when the discovery file is absent/unreadable."""
|
|
168
|
+
try:
|
|
169
|
+
d = json.loads(executor_file_path(session_id).read_text())
|
|
170
|
+
return str(d["sock"]), int(d["pid"])
|
|
171
|
+
except (FileNotFoundError, ValueError, KeyError, TypeError, OSError):
|
|
172
|
+
return None, None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cleanup_executor(session_id: str) -> None:
|
|
176
|
+
"""Best-effort: nuke a session's executor socket + discovery file. Called by
|
|
177
|
+
the executor on exit and by the daemon when it reaps/kills the executor."""
|
|
178
|
+
for p in (executor_sock_path(session_id), executor_file_path(session_id)):
|
|
179
|
+
try:
|
|
180
|
+
p.unlink()
|
|
181
|
+
except (FileNotFoundError, IsADirectoryError, OSError):
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def make_executor_socket(session_id: str) -> socket.socket:
|
|
186
|
+
"""Create + bind the executor's AF_UNIX socket with 0600 perms (mirrors
|
|
187
|
+
`make_unix_socket`, but on the per-session executor path)."""
|
|
188
|
+
path = executor_sock_path(session_id)
|
|
189
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
if path.exists():
|
|
191
|
+
path.unlink()
|
|
192
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
193
|
+
old_umask = os.umask(0o077)
|
|
194
|
+
try:
|
|
195
|
+
s.bind(str(path))
|
|
196
|
+
finally:
|
|
197
|
+
os.umask(old_umask)
|
|
198
|
+
s.listen(8)
|
|
199
|
+
return s
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def endpoint_describe() -> dict:
|
|
203
|
+
"""Public-facing description of the IPC endpoint for `status` / `url --mode-b-proxy`.
|
|
204
|
+
Spec §6.1 --json shape."""
|
|
205
|
+
if IS_WINDOWS:
|
|
206
|
+
port, token = read_port_file()
|
|
207
|
+
if port is None:
|
|
208
|
+
return {"schema_version": 1, "transport": "tcp",
|
|
209
|
+
"host": "127.0.0.1", "port": None, "token": None}
|
|
210
|
+
return {"schema_version": 1, "transport": "tcp",
|
|
211
|
+
"host": "127.0.0.1", "port": port, "token": token}
|
|
212
|
+
return {"schema_version": 1, "transport": "unix",
|
|
213
|
+
"path": str(sock_path())}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---- Windows port-file: atomic write + read --------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def write_port_file(port: int, token: str) -> None:
|
|
220
|
+
"""Atomic write {.tmp → os.replace} so a concurrent reader never sees a
|
|
221
|
+
half-written file. Mirrors browser-harness `_ipc.py:179-181`."""
|
|
222
|
+
pf = port_path()
|
|
223
|
+
pf.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
tmp = pf.with_name(pf.name + ".tmp")
|
|
225
|
+
tmp.write_text(json.dumps({"port": port, "token": token}))
|
|
226
|
+
os.replace(tmp, pf)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def read_port_file() -> tuple[int | None, str | None]:
|
|
230
|
+
try:
|
|
231
|
+
d = json.loads(port_path().read_text())
|
|
232
|
+
return int(d["port"]), str(d["token"])
|
|
233
|
+
except (FileNotFoundError, ValueError, KeyError, TypeError, OSError):
|
|
234
|
+
return None, None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cleanup_endpoint() -> None:
|
|
238
|
+
"""Best-effort: nuke socket / port file. Called on graceful shutdown and
|
|
239
|
+
by `stop` before bind. Silent on missing files."""
|
|
240
|
+
paths = [sock_path() if not IS_WINDOWS else port_path(),
|
|
241
|
+
pid_path(), facade_path()]
|
|
242
|
+
for p in paths:
|
|
243
|
+
try:
|
|
244
|
+
p.unlink()
|
|
245
|
+
except (FileNotFoundError, IsADirectoryError, OSError):
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ---- ping handshake (stale-detect) -----------------------------------------
|
|
250
|
+
#
|
|
251
|
+
# Spec §6.7 calls for CDP `Browser.getVersion` over ws. But before we know
|
|
252
|
+
# whether the listener is *our* daemon, the cheapest probe is an HTTP GET that
|
|
253
|
+
# our daemon recognizes specifically and that anything else either rejects or
|
|
254
|
+
# doesn't answer.
|
|
255
|
+
#
|
|
256
|
+
# We use an HTTP request the ws server can intercept via process_request. The
|
|
257
|
+
# `/__ping__` path is reserved for this — daemon's process_request returns a
|
|
258
|
+
# 200 with body {"pong": true, "pid": N, "version": "..."}. A foreign listener
|
|
259
|
+
# might 404 or send garbage; anything not matching counts as "stale."
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def make_pong_body(pid: int) -> bytes:
|
|
263
|
+
"""Daemon side: build the /__ping__ response body.
|
|
264
|
+
|
|
265
|
+
Carries the daemon's package version so a client can detect a *stale*
|
|
266
|
+
daemon (running older code than what's installed on disk) and auto-restart
|
|
267
|
+
it — S6 (A2-a). A daemon too old to know about this field simply omits it;
|
|
268
|
+
the parser treats a missing version as stale.
|
|
269
|
+
"""
|
|
270
|
+
from . import __version__
|
|
271
|
+
return json.dumps(
|
|
272
|
+
{"pong": True, "pid": pid, "version": __version__}).encode()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def parse_pong(body: bytes) -> tuple[int | None, str | None]:
|
|
276
|
+
"""Client side: extract ``(pid, version)`` from a /__ping__ pong body.
|
|
277
|
+
|
|
278
|
+
Returns ``(None, None)`` for anything that isn't our pong shape. ``version``
|
|
279
|
+
is ``None`` when the daemon predates version-advertising — callers treat
|
|
280
|
+
that as stale (one needless restart on first upgrade beats silent failure).
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
payload = json.loads(body.decode("utf-8", errors="replace"))
|
|
284
|
+
except (ValueError, UnicodeDecodeError):
|
|
285
|
+
return None, None
|
|
286
|
+
if not isinstance(payload, dict) or payload.get("pong") is not True:
|
|
287
|
+
return None, None
|
|
288
|
+
pid = payload.get("pid")
|
|
289
|
+
if not isinstance(pid, int) or pid <= 0 or pid > (1 << 31):
|
|
290
|
+
return None, None
|
|
291
|
+
version = payload.get("version")
|
|
292
|
+
if not isinstance(version, str) or not version:
|
|
293
|
+
version = None
|
|
294
|
+
return pid, version
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async def ping_status_async(timeout: float = 1.0) -> tuple[int | None, str | None]:
|
|
298
|
+
"""Async client-side ping returning ``(pid, version)``.
|
|
299
|
+
|
|
300
|
+
``pid`` is None when the endpoint is not a live daemon (refused / wrong /
|
|
301
|
+
no response). ``version`` is the daemon's advertised package version, or
|
|
302
|
+
None if the daemon is too old to advertise one (S6 — treated as stale).
|
|
303
|
+
|
|
304
|
+
Used by `serve` cold-start to decide whether the existing socket file
|
|
305
|
+
belongs to a live daemon (=> exit 0, idempotent) or a stale corpse
|
|
306
|
+
(=> unlink + bind fresh).
|
|
307
|
+
"""
|
|
308
|
+
none = (None, None)
|
|
309
|
+
try:
|
|
310
|
+
if IS_WINDOWS:
|
|
311
|
+
port, _ = read_port_file()
|
|
312
|
+
if port is None:
|
|
313
|
+
return none
|
|
314
|
+
reader, writer = await asyncio.wait_for(
|
|
315
|
+
asyncio.open_connection("127.0.0.1", port), timeout=timeout)
|
|
316
|
+
else:
|
|
317
|
+
p = sock_path()
|
|
318
|
+
if not p.exists():
|
|
319
|
+
return none
|
|
320
|
+
reader, writer = await asyncio.wait_for(
|
|
321
|
+
asyncio.open_unix_connection(str(p)), timeout=timeout)
|
|
322
|
+
except (OSError, asyncio.TimeoutError):
|
|
323
|
+
return none
|
|
324
|
+
try:
|
|
325
|
+
try:
|
|
326
|
+
writer.write(b"GET /__ping__ HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
|
327
|
+
await asyncio.wait_for(writer.drain(), timeout=timeout)
|
|
328
|
+
except (BrokenPipeError, ConnectionResetError, OSError, asyncio.TimeoutError):
|
|
329
|
+
# The peer closed/crashed mid-write — definitely not our daemon.
|
|
330
|
+
return none
|
|
331
|
+
# Read until double-CRLF, then up to a reasonable body size.
|
|
332
|
+
data = b""
|
|
333
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
334
|
+
while b"\r\n\r\n" not in data and len(data) < 4096:
|
|
335
|
+
remaining = deadline - asyncio.get_running_loop().time()
|
|
336
|
+
if remaining <= 0:
|
|
337
|
+
return none
|
|
338
|
+
try:
|
|
339
|
+
chunk = await asyncio.wait_for(reader.read(1024), timeout=remaining)
|
|
340
|
+
except asyncio.TimeoutError:
|
|
341
|
+
return none
|
|
342
|
+
if not chunk:
|
|
343
|
+
break
|
|
344
|
+
data += chunk
|
|
345
|
+
# Read possible body
|
|
346
|
+
try:
|
|
347
|
+
body = await asyncio.wait_for(reader.read(1024), timeout=0.2)
|
|
348
|
+
data += body
|
|
349
|
+
except asyncio.TimeoutError:
|
|
350
|
+
pass
|
|
351
|
+
idx = data.find(b"\r\n\r\n")
|
|
352
|
+
if idx < 0:
|
|
353
|
+
return none
|
|
354
|
+
# Defensive parse, anything-not-our-shape = stale.
|
|
355
|
+
return parse_pong(data[idx + 4:])
|
|
356
|
+
finally:
|
|
357
|
+
try:
|
|
358
|
+
writer.close()
|
|
359
|
+
await asyncio.wait_for(writer.wait_closed(), timeout=0.5)
|
|
360
|
+
except (OSError, asyncio.TimeoutError):
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def ping_async(timeout: float = 1.0) -> int | None:
|
|
365
|
+
"""Async client-side ping. Returns the daemon's reported PID, or None
|
|
366
|
+
when the endpoint is not a live daemon. Thin wrapper over
|
|
367
|
+
:func:`ping_status_async` for callers that only care about liveness/pid."""
|
|
368
|
+
pid, _version = await ping_status_async(timeout=timeout)
|
|
369
|
+
return pid
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def ping_status_sync(timeout: float = 1.0) -> tuple[int | None, str | None]:
|
|
373
|
+
"""Synchronous ``(pid, version)`` probe for CLI paths without a running
|
|
374
|
+
loop. Returns ``(None, None)`` when nothing answers."""
|
|
375
|
+
coro = ping_status_async(timeout=timeout)
|
|
376
|
+
try:
|
|
377
|
+
return asyncio.run(coro)
|
|
378
|
+
except RuntimeError:
|
|
379
|
+
coro.close()
|
|
380
|
+
return None, None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def ping_sync(timeout: float = 1.0) -> int | None:
|
|
384
|
+
"""Synchronous variant for CLI status / stop paths that don't already
|
|
385
|
+
have an event loop running. Returns the daemon's PID, or None."""
|
|
386
|
+
pid, _version = ping_status_sync(timeout=timeout)
|
|
387
|
+
return pid
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ---- POSIX socket bind helper ---------------------------------------------
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def make_unix_socket() -> socket.socket:
|
|
394
|
+
"""Create + bind an AF_UNIX SOCK_STREAM with 0600 perms via umask(0o077).
|
|
395
|
+
|
|
396
|
+
Returns the bound, listening-ready socket. Pass it to
|
|
397
|
+
`websockets.unix_serve(handler, sock=...)`. Mirrors browser-harness
|
|
398
|
+
`_ipc.py:166-170`.
|
|
399
|
+
"""
|
|
400
|
+
path = sock_path()
|
|
401
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
402
|
+
if path.exists():
|
|
403
|
+
path.unlink()
|
|
404
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
405
|
+
old_umask = os.umask(0o077)
|
|
406
|
+
try:
|
|
407
|
+
s.bind(str(path))
|
|
408
|
+
finally:
|
|
409
|
+
os.umask(old_umask)
|
|
410
|
+
s.listen(8)
|
|
411
|
+
return s
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def make_tcp_socket() -> tuple[socket.socket, int, str]:
|
|
415
|
+
"""Windows path: bind 127.0.0.1:0, return (socket, port, token).
|
|
416
|
+
|
|
417
|
+
Caller writes the port-file. We hold the socket and pass it to
|
|
418
|
+
websockets.serve(sock=...).
|
|
419
|
+
"""
|
|
420
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
421
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
422
|
+
s.bind(("127.0.0.1", 0))
|
|
423
|
+
port = s.getsockname()[1]
|
|
424
|
+
s.listen(8)
|
|
425
|
+
token = secrets.token_hex(32)
|
|
426
|
+
return s, port, token
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---- pid file helpers ------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def write_pid(pid: int) -> None:
|
|
433
|
+
p = pid_path()
|
|
434
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
435
|
+
p.write_text(f"{pid}\n")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def read_pid() -> int | None:
|
|
439
|
+
try:
|
|
440
|
+
s = pid_path().read_text().strip()
|
|
441
|
+
v = int(s)
|
|
442
|
+
return v if 0 < v < (1 << 31) else None
|
|
443
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
444
|
+
return None
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""active-tab subcommand — H8 / US1 Mode A path.
|
|
2
|
+
|
|
3
|
+
Spec §5.4 + §6.4.1: Mode A has no persistent RPC channel, so every call spawns
|
|
4
|
+
a fresh ws, runs `Target.getTargets`, picks the page target with the most-recent
|
|
5
|
+
`lastAccessed` field, and exits. The accuracy field is hard-coded
|
|
6
|
+
`"heuristic-recent-activate"` in v0.1 — spec acknowledges this loses user-driven
|
|
7
|
+
tab clicks (Chrome UI clicks don't fire CDP `Target.activateTarget`), and that
|
|
8
|
+
limit is documented for the Skill.
|
|
9
|
+
|
|
10
|
+
Caller-visible side effect: this opens a ws. The Skill is supposed to route
|
|
11
|
+
around per-call ws cost via the long-lived REPL daemon
|
|
12
|
+
(see browserwright design §A.5). This CLI is the fallback.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from cdp_use.client import CDPClient
|
|
20
|
+
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .errors import Unavailable
|
|
23
|
+
from .resolver import resolve
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# DevTools target list contains entries with `type` in this set besides actual
|
|
27
|
+
# pages — we treat anything not type=="page" as ineligible to be "the user's
|
|
28
|
+
# tab," per playwriter cdp-relay's restricted-target filter (§A附录).
|
|
29
|
+
_REAL_PAGE_TYPE = "page"
|
|
30
|
+
_INTERNAL_URL_PREFIXES = (
|
|
31
|
+
"chrome://", "chrome-untrusted://", "devtools://", "edge://",
|
|
32
|
+
"chrome-extension://", "about:", "view-source:",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def active_tab(cfg: Config, session_id: str | None = None) -> dict[str, Any] | None:
|
|
37
|
+
"""Return the active-tab dict, or None when no eligible page exists.
|
|
38
|
+
|
|
39
|
+
Shape (spec §5.4 --json):
|
|
40
|
+
{targetId, url, title, accuracy, since_seconds}
|
|
41
|
+
"""
|
|
42
|
+
if not session_id:
|
|
43
|
+
raise Unavailable("active-tab requires a browserwright session id")
|
|
44
|
+
|
|
45
|
+
return await _active_tab_via_relay(cfg, session_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _active_tab_via_relay(
|
|
49
|
+
cfg: Config, session_id: str,
|
|
50
|
+
) -> dict[str, Any] | None:
|
|
51
|
+
"""Ask the running daemon for the active tab over its Mode B socket.
|
|
52
|
+
|
|
53
|
+
The extension backend answers ``BrowserwrightDaemon.getActiveTab`` from relay
|
|
54
|
+
state (no upstream browser ws to open). Returns the same dict shape as the
|
|
55
|
+
Mode A path, or ``None`` when the daemon reports no eligible tab.
|
|
56
|
+
"""
|
|
57
|
+
import asyncio
|
|
58
|
+
import json
|
|
59
|
+
|
|
60
|
+
import websockets
|
|
61
|
+
|
|
62
|
+
from . import _ipc
|
|
63
|
+
from urllib.parse import quote
|
|
64
|
+
|
|
65
|
+
session_q = f"&session={quote(str(session_id), safe='')}"
|
|
66
|
+
|
|
67
|
+
async def _drain(ws) -> dict:
|
|
68
|
+
for _ in range(20):
|
|
69
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=cfg.timeout)
|
|
70
|
+
msg = json.loads(raw)
|
|
71
|
+
if msg.get("id") == 1:
|
|
72
|
+
return msg
|
|
73
|
+
raise Unavailable("active-tab: no id=1 response from daemon relay")
|
|
74
|
+
|
|
75
|
+
if _ipc.IS_WINDOWS:
|
|
76
|
+
port, token = _ipc.read_port_file()
|
|
77
|
+
if port is None:
|
|
78
|
+
raise Unavailable("active-tab: no daemon running (extension relay)")
|
|
79
|
+
url = f"ws://127.0.0.1:{port}/?token={token}&client=cli-active-tab{session_q}"
|
|
80
|
+
async with websockets.connect(url, compression=None) as ws:
|
|
81
|
+
await ws.send(json.dumps({
|
|
82
|
+
"id": 1, "method": "BrowserwrightDaemon.getActiveTab",
|
|
83
|
+
"params": {"bsSession": session_id},
|
|
84
|
+
}))
|
|
85
|
+
msg = await _drain(ws)
|
|
86
|
+
else:
|
|
87
|
+
path = _ipc.sock_path()
|
|
88
|
+
if not path.exists():
|
|
89
|
+
raise Unavailable("active-tab: no daemon running (extension relay)")
|
|
90
|
+
async with websockets.unix_connect(
|
|
91
|
+
str(path), uri=f"ws://localhost/?client=cli-active-tab{session_q}",
|
|
92
|
+
compression=None) as ws:
|
|
93
|
+
await ws.send(json.dumps({
|
|
94
|
+
"id": 1, "method": "BrowserwrightDaemon.getActiveTab",
|
|
95
|
+
"params": {"bsSession": session_id},
|
|
96
|
+
}))
|
|
97
|
+
msg = await _drain(ws)
|
|
98
|
+
|
|
99
|
+
result = msg.get("result") or {}
|
|
100
|
+
if not result.get("targetId"):
|
|
101
|
+
return None
|
|
102
|
+
return {
|
|
103
|
+
"targetId": result.get("targetId"),
|
|
104
|
+
"url": result.get("url"),
|
|
105
|
+
"title": result.get("title", ""),
|
|
106
|
+
"accuracy": result.get("accuracy", "unknown"),
|
|
107
|
+
"since_seconds": result.get("since_seconds"),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _fetch_targets(ws_url: str, timeout: float) -> list[dict]:
|
|
112
|
+
"""Open a ws, run Target.getTargets, close. Single roundtrip."""
|
|
113
|
+
import asyncio
|
|
114
|
+
|
|
115
|
+
client = CDPClient(ws_url)
|
|
116
|
+
try:
|
|
117
|
+
# CDPClient.start() establishes the ws connection. Wrap it in a timeout
|
|
118
|
+
# so a hung Chrome (e.g. waiting for the user's Allow popup forever)
|
|
119
|
+
# doesn't pin this subprocess.
|
|
120
|
+
with _localhost_bypass_proxy(ws_url):
|
|
121
|
+
await asyncio.wait_for(client.start(), timeout=timeout)
|
|
122
|
+
try:
|
|
123
|
+
resp = await asyncio.wait_for(
|
|
124
|
+
client.send_raw("Target.getTargets"),
|
|
125
|
+
timeout=timeout,
|
|
126
|
+
)
|
|
127
|
+
finally:
|
|
128
|
+
await _silent_stop(client)
|
|
129
|
+
except (TimeoutError, OSError) as e:
|
|
130
|
+
raise Unavailable(
|
|
131
|
+
f"active-tab: failed to fetch targets via {ws_url}: {e}",
|
|
132
|
+
attempts={"active-tab": f"{type(e).__name__}: {e}"},
|
|
133
|
+
) from e
|
|
134
|
+
# cdp-use's send_raw returns the full {"id":N,"result":{...}} structure.
|
|
135
|
+
# Tolerate both shapes (`result` wrapped or already unwrapped) so we don't
|
|
136
|
+
# break on a future library version.
|
|
137
|
+
if isinstance(resp, dict) and "result" in resp and isinstance(resp["result"], dict):
|
|
138
|
+
infos = resp["result"].get("targetInfos", [])
|
|
139
|
+
elif isinstance(resp, dict):
|
|
140
|
+
infos = resp.get("targetInfos", [])
|
|
141
|
+
else:
|
|
142
|
+
infos = []
|
|
143
|
+
return infos if isinstance(infos, list) else []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _silent_stop(client: CDPClient) -> None:
|
|
147
|
+
try:
|
|
148
|
+
await client.stop()
|
|
149
|
+
except Exception:
|
|
150
|
+
# Closing a ws can race with Chrome closing first — never fatal.
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
import contextlib
|
|
155
|
+
import os
|
|
156
|
+
from urllib.parse import urlparse
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@contextlib.contextmanager
|
|
160
|
+
def _localhost_bypass_proxy(ws_url: str):
|
|
161
|
+
"""Temporarily extend NO_PROXY so the user's HTTP_PROXY / ALL_PROXY env vars
|
|
162
|
+
don't force this loopback ws through an outside SOCKS server. cdp-use doesn't
|
|
163
|
+
expose a `proxy=None` knob, but websockets v15 honors urllib.request.proxy_bypass
|
|
164
|
+
which honors NO_PROXY. Only mutates env when the target is a localhost URL.
|
|
165
|
+
"""
|
|
166
|
+
host = (urlparse(ws_url).hostname or "").lower()
|
|
167
|
+
if host not in ("127.0.0.1", "localhost", "::1", "[::1]"):
|
|
168
|
+
yield
|
|
169
|
+
return
|
|
170
|
+
prev = os.environ.get("NO_PROXY", "")
|
|
171
|
+
augmented = prev
|
|
172
|
+
for h in ("127.0.0.1", "localhost", "::1"):
|
|
173
|
+
if h not in augmented:
|
|
174
|
+
augmented = f"{augmented},{h}" if augmented else h
|
|
175
|
+
os.environ["NO_PROXY"] = augmented
|
|
176
|
+
# urllib caches proxy decisions; clear to make sure NO_PROXY takes effect.
|
|
177
|
+
try:
|
|
178
|
+
yield
|
|
179
|
+
finally:
|
|
180
|
+
if prev:
|
|
181
|
+
os.environ["NO_PROXY"] = prev
|
|
182
|
+
else:
|
|
183
|
+
os.environ.pop("NO_PROXY", None)
|