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