browserwright 0.17.0__tar.gz → 0.17.2__tar.gz
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-0.17.0/src/browserwright.egg-info → browserwright-0.17.2}/PKG-INFO +1 -1
- {browserwright-0.17.0 → browserwright-0.17.2}/pyproject.toml +1 -1
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/config.py +43 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/facade.py +58 -9
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/facade_extension.py +13 -2
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/listener.py +15 -3
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/relay.py +46 -3
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon_url.py +48 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/errors.py +12 -2
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/health.py +81 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/playwright_handle.py +77 -3
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session.py +7 -2
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_create.py +8 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_runtime.py +9 -1
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/skill_runtime.md +33 -0
- {browserwright-0.17.0 → browserwright-0.17.2/src/browserwright.egg-info}/PKG-INFO +1 -1
- {browserwright-0.17.0 → browserwright-0.17.2}/LICENSE +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/README.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/setup.cfg +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/__main__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/__main__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/client.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/process.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_executor/protocol.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/_text.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/cdp.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/cli.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_ipc.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_net.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_rpc.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/_stale.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/base.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/cdp.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/extension.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/cli.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/doctor.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/errors.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/launch_chrome.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/launchagent.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/observability.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/platforms.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/probe.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/relay_status.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/resolver.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/restart_guard.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/daemon.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/exec_relay.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/executor_registry.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/extension_upstream.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/proxy.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/state.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/status.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/upstream.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/verbs.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/supervise.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/userscripts.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/discovery.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/install.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/_lock.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/_md.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/_yaml.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/global_mem.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/memory/site_mem.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/mode_b_client.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/output_schema.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/discovery_api.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/http.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/primitives/site.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/__init__.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_md_convert.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_md_normalize.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_namespace.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/_smart_goto.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/inline.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/markdown.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/snapshot.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/vendor/README.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/repl/vendor/readability.js +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_ctx.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/session_registry.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/skill_doc.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/tab_surface.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/task_runner.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/version.py +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/SOURCES.txt +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/dependency_links.txt +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/entry_points.txt +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/requires.txt +0 -0
- {browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: browserwright
|
|
3
|
-
Version: 0.17.
|
|
3
|
+
Version: 0.17.2
|
|
4
4
|
Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -15,7 +15,7 @@ name = "browserwright"
|
|
|
15
15
|
# stamping regex is `^(version\s*=\s*)["'][^"']+["']\s*$` and CI aborts unless
|
|
16
16
|
# it matches exactly once. `tests/skill/test_release_versioning.py` enforces all
|
|
17
17
|
# of that before a tag is ever pushed. See RELEASING.md.
|
|
18
|
-
version = "0.17.
|
|
18
|
+
version = "0.17.2"
|
|
19
19
|
description = "Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends)."
|
|
20
20
|
requires-python = ">=3.11"
|
|
21
21
|
license = "AGPL-3.0-only"
|
|
@@ -35,6 +35,49 @@ DEFAULT_FACADE_PORT = 19990
|
|
|
35
35
|
# interface (e.g. `0.0.0.0` or a specific tailnet IP) for remote CDP clients.
|
|
36
36
|
DEFAULT_FACADE_HOST = "127.0.0.1"
|
|
37
37
|
|
|
38
|
+
#: The loopback address every local client falls back to (`DEFAULT_DAEMON_URL`
|
|
39
|
+
#: in `daemon_url.py` is built from it).
|
|
40
|
+
LOOPBACK_HOST = "127.0.0.1"
|
|
41
|
+
|
|
42
|
+
#: Hosts whose bind ALREADY covers loopback, so no second listener is needed:
|
|
43
|
+
#: loopback itself, and the two wildcards.
|
|
44
|
+
_LOOPBACK_COVERING_HOSTS = frozenset({
|
|
45
|
+
"", "0.0.0.0", "::", "::0", "*",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def needs_loopback_cobind(host: str) -> bool:
|
|
50
|
+
"""Whether binding *host* leaves 127.0.0.1 unserved.
|
|
51
|
+
|
|
52
|
+
A bind to a **specific** address listens on that address only — binding
|
|
53
|
+
`100.72.20.32` (the documented `--facade-host <tailnet-ip>` remote-access
|
|
54
|
+
setup) means `127.0.0.1:19990` is genuinely not listening, so every local
|
|
55
|
+
client that resolves the loopback default gets ECONNREFUSED while the
|
|
56
|
+
daemon is demonstrably up. The endpoint state file is supposed to bridge
|
|
57
|
+
that, but it is a best-effort channel (an unreadable or differently-rooted
|
|
58
|
+
`XDG_RUNTIME_DIR` silently falls through to the default). Remote access
|
|
59
|
+
must not cost local access, so the facade co-binds loopback whenever this
|
|
60
|
+
returns True.
|
|
61
|
+
|
|
62
|
+
Wildcards (`0.0.0.0`, `::`) and loopback itself already serve 127.0.0.1.
|
|
63
|
+
"""
|
|
64
|
+
text = (host or "").strip()
|
|
65
|
+
if text.lower() in _LOOPBACK_COVERING_HOSTS:
|
|
66
|
+
return False
|
|
67
|
+
if text.lower() in ("localhost", "localhost."):
|
|
68
|
+
return False
|
|
69
|
+
import ipaddress
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
addr = ipaddress.ip_address(text.strip("[]"))
|
|
73
|
+
except ValueError:
|
|
74
|
+
# A hostname we cannot classify without resolving. Co-binding loopback
|
|
75
|
+
# is harmless (it fails soft) and is the safer default.
|
|
76
|
+
return True
|
|
77
|
+
if addr.is_loopback or addr.is_unspecified:
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
80
|
+
|
|
38
81
|
|
|
39
82
|
def check_name(name: str) -> str:
|
|
40
83
|
"""Path-traversal guard for filesystem-bound names (e.g. `--profile`)."""
|
|
@@ -64,7 +64,8 @@ import websockets
|
|
|
64
64
|
from websockets.asyncio.server import ServerConnection, serve
|
|
65
65
|
|
|
66
66
|
from .. import _ipc
|
|
67
|
-
from ..config import DEFAULT_FACADE_PORT, Config
|
|
67
|
+
from ..config import (DEFAULT_FACADE_PORT, LOOPBACK_HOST, Config,
|
|
68
|
+
needs_loopback_cobind)
|
|
68
69
|
from .. import __version__
|
|
69
70
|
from ..errors import Unavailable
|
|
70
71
|
from ..resolver import resolve as resolve_upstream
|
|
@@ -128,6 +129,14 @@ class PlaywrightFacade:
|
|
|
128
129
|
self._port = port
|
|
129
130
|
self._host = host
|
|
130
131
|
self._server: Any = None
|
|
132
|
+
# A bind to a *specific* non-loopback IP (the documented remote-access
|
|
133
|
+
# setup, `--facade-host <tailnet-ip>`) does not listen on 127.0.0.1 at
|
|
134
|
+
# all, which silently breaks every LOCAL client — they resolve the
|
|
135
|
+
# loopback default when the endpoint state file is not visible to them
|
|
136
|
+
# (different XDG_RUNTIME_DIR, sandboxed /tmp, another user). Remote
|
|
137
|
+
# access must not cost local access, so we additionally bind loopback
|
|
138
|
+
# on the same port and serve both from one handler.
|
|
139
|
+
self._loopback_server: Any = None
|
|
131
140
|
# PR2: for the extension backend the facade has no resolvable upstream
|
|
132
141
|
# ws — it bridges through the daemon's shared RelayServer. The listener
|
|
133
142
|
# passes a getter (the relay is created during run_serve startup, and
|
|
@@ -149,12 +158,12 @@ class PlaywrightFacade:
|
|
|
149
158
|
|
|
150
159
|
# ---- lifecycle -------------------------------------------------------
|
|
151
160
|
|
|
152
|
-
async def
|
|
153
|
-
"""Bind
|
|
154
|
-
|
|
161
|
+
async def _serve_on(self, host: str, port: int) -> Any:
|
|
162
|
+
"""Bind one ws+HTTP listener for this facade on ``host:port``."""
|
|
163
|
+
return await serve(
|
|
155
164
|
self._handle_client,
|
|
156
|
-
|
|
157
|
-
|
|
165
|
+
host,
|
|
166
|
+
port,
|
|
158
167
|
process_request=self._process_request,
|
|
159
168
|
compression=None,
|
|
160
169
|
ping_interval=20,
|
|
@@ -164,6 +173,16 @@ class PlaywrightFacade:
|
|
|
164
173
|
# frames — see `_MAX_WS_SIZE`.
|
|
165
174
|
max_size=_MAX_WS_SIZE,
|
|
166
175
|
)
|
|
176
|
+
|
|
177
|
+
async def start(self) -> int:
|
|
178
|
+
"""Bind the facade ws+HTTP server. Returns the actually-bound port.
|
|
179
|
+
|
|
180
|
+
When ``host`` names a specific non-loopback address, loopback is bound
|
|
181
|
+
as a SECOND listener on the same port so local clients keep working
|
|
182
|
+
(see ``_loopback_server``). That co-bind is best-effort: it must never
|
|
183
|
+
turn a working remote bind into a fatal startup failure.
|
|
184
|
+
"""
|
|
185
|
+
self._server = await self._serve_on(self._host, self._port)
|
|
167
186
|
for sock in self._server.sockets:
|
|
168
187
|
sa = sock.getsockname()
|
|
169
188
|
if isinstance(sa, tuple) and len(sa) >= 2:
|
|
@@ -171,6 +190,32 @@ class PlaywrightFacade:
|
|
|
171
190
|
break
|
|
172
191
|
logger.info("endpoint listening on http://%s:%d (%s)",
|
|
173
192
|
self._host, self._port, ", ".join(_WS_PATHS))
|
|
193
|
+
if needs_loopback_cobind(self._host):
|
|
194
|
+
try:
|
|
195
|
+
self._loopback_server = await self._serve_on(
|
|
196
|
+
LOOPBACK_HOST, self._port)
|
|
197
|
+
logger.info(
|
|
198
|
+
"endpoint also listening on http://%s:%d "
|
|
199
|
+
"(loopback co-bind so local clients keep working)",
|
|
200
|
+
LOOPBACK_HOST, self._port)
|
|
201
|
+
except OSError as e:
|
|
202
|
+
# Someone else holds loopback:port, or the ephemeral port the
|
|
203
|
+
# primary bind won is taken on loopback. Remote clients still
|
|
204
|
+
# work; local ones need an explicit endpoint. Say so loudly —
|
|
205
|
+
# this is exactly the failure mode that reads as "the daemon
|
|
206
|
+
# is running but nothing can reach it".
|
|
207
|
+
self._loopback_server = None
|
|
208
|
+
logger.warning(
|
|
209
|
+
"endpoint could NOT also bind %s:%d (%s) — LOCAL clients "
|
|
210
|
+
"that resolve the loopback default will fail to connect. "
|
|
211
|
+
"Point them at http://%s:%d via $BW_DAEMON_URL.",
|
|
212
|
+
LOOPBACK_HOST, self._port, e, self._host, self._port)
|
|
213
|
+
logger.warning(
|
|
214
|
+
"endpoint is bound to non-loopback %s — it is reachable from "
|
|
215
|
+
"every host that can route there, and it drives a real "
|
|
216
|
+
"browser with no application-layer auth (ADR-0011). Keep it "
|
|
217
|
+
"on a trusted tunnel/tailnet only.",
|
|
218
|
+
self._host)
|
|
174
219
|
if self._relay_getter is not None:
|
|
175
220
|
self._reaper_task = asyncio.create_task(self._auto_reaper_loop())
|
|
176
221
|
return self._port
|
|
@@ -192,10 +237,14 @@ class PlaywrightFacade:
|
|
|
192
237
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
193
238
|
await task
|
|
194
239
|
self._sessions.clear()
|
|
195
|
-
self._server.
|
|
196
|
-
|
|
197
|
-
|
|
240
|
+
for srv in (self._server, self._loopback_server):
|
|
241
|
+
if srv is None:
|
|
242
|
+
continue
|
|
243
|
+
srv.close()
|
|
244
|
+
with contextlib.suppress(Exception):
|
|
245
|
+
await srv.wait_closed()
|
|
198
246
|
self._server = None
|
|
247
|
+
self._loopback_server = None
|
|
199
248
|
|
|
200
249
|
@property
|
|
201
250
|
def port(self) -> int:
|
{browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/facade_extension.py
RENAMED
|
@@ -94,8 +94,19 @@ _RUNTIME_REENABLE_PAUSE = 0.05
|
|
|
94
94
|
# Instead of skipping the announce permanently we re-check for a short window
|
|
95
95
|
# (the binding lands milliseconds later); a tab that never becomes visible
|
|
96
96
|
# (a foreign tab) expires without an announce — the scope check still gates.
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
#
|
|
98
|
+
# BUG B: this window used to be 5 x 0.1s = 0.5s, which was the real ceiling on
|
|
99
|
+
# a cold bind. It has to outlast an extension MV3 service-worker wake-up (the
|
|
100
|
+
# `scoped_target_infos` round trip goes to the SW), and it must not expire
|
|
101
|
+
# before the CLIENT's bind budget — otherwise the daemon quietly stops trying
|
|
102
|
+
# while the client is still waiting, and the tab is never announced for the
|
|
103
|
+
# life of the bridge, which is exactly what surfaced as a 100%-reproducible
|
|
104
|
+
# `PageBindTimeout` with a leaked tab per attempt. 30 x 0.2s = 6s sits under
|
|
105
|
+
# the 10s client budget (`repl.playwright_handle._PAGE_BIND_TIMEOUT_S`) with
|
|
106
|
+
# room for the round trip. Cost on the happy path is zero: the loop returns on
|
|
107
|
+
# the first successful check, and a foreign tab is idle waiting, not work.
|
|
108
|
+
_VISIBILITY_RETRY_COUNT = 30
|
|
109
|
+
_VISIBILITY_RETRY_INTERVAL = 0.2
|
|
99
110
|
|
|
100
111
|
|
|
101
112
|
# Synthetic browserContextId for synthesized page targets. The extension backend
|
|
@@ -917,7 +917,9 @@ class _UpstreamHolder:
|
|
|
917
917
|
# fallback for an adapter that reports nothing.
|
|
918
918
|
await self.state.set_connected(ext.ws_url or "ext://relay")
|
|
919
919
|
|
|
920
|
-
async def _on_extension_hello(
|
|
920
|
+
async def _on_extension_hello(
|
|
921
|
+
self, *, install_id: str = "", first_seen: bool = False,
|
|
922
|
+
) -> None:
|
|
921
923
|
"""A (auto-recovery): extension (re)connected with a fresh SW.
|
|
922
924
|
|
|
923
925
|
A reloaded/updated SW reconnects with an EMPTY ``attachedTabs`` set,
|
|
@@ -957,9 +959,19 @@ class _UpstreamHolder:
|
|
|
957
959
|
continue
|
|
958
960
|
try:
|
|
959
961
|
await ext.recover_session(sid)
|
|
962
|
+
# GH#79: say which of the two it was. This line used to
|
|
963
|
+
# read "after extension reconnect" unconditionally — it
|
|
964
|
+
# fires on EVERY hello, including the very first one from
|
|
965
|
+
# a brand-new browser profile, and reading it in a
|
|
966
|
+
# session-scoped e2e log is what made a fresh Chrome per
|
|
967
|
+
# test look like a service worker churning between
|
|
968
|
+
# commands.
|
|
960
969
|
logger.info(
|
|
961
|
-
"auto-recovered session %s after extension
|
|
962
|
-
|
|
970
|
+
"auto-recovered session %s after extension %s "
|
|
971
|
+
"(install_id=%s)",
|
|
972
|
+
sid,
|
|
973
|
+
"first connect" if first_seen else "reconnect",
|
|
974
|
+
install_id or "(unknown)")
|
|
963
975
|
except Exception: # noqa: BLE001 - no group / empty group /
|
|
964
976
|
# still reconnecting -- the next hello retries.
|
|
965
977
|
pass
|
|
@@ -236,6 +236,7 @@ class RelayServer:
|
|
|
236
236
|
)
|
|
237
237
|
self._server: Any = None
|
|
238
238
|
self._extensions: dict[str, _ExtensionConn] = {}
|
|
239
|
+
self._seen_install_ids: set[str] = set()
|
|
239
240
|
# Monotonic connection epoch. A fresh extension hello may represent a
|
|
240
241
|
# Chrome restart, where numeric tab/group ids can be recycled. Session
|
|
241
242
|
# adapters use this to demote in-memory group bindings back to
|
|
@@ -260,7 +261,16 @@ class RelayServer:
|
|
|
260
261
|
# (fresh SW after a reload/update, or a ws reconnect). The listener
|
|
261
262
|
# uses it to re-attach extension sessions whose ghost table was lost
|
|
262
263
|
# with the previous connection. Set by the listener.
|
|
263
|
-
self._on_extension_hello:
|
|
264
|
+
self._on_extension_hello: (
|
|
265
|
+
Callable[..., Awaitable[None]] | None) = None
|
|
266
|
+
# GH#79: install_ids that have said hello to THIS daemon before, so a
|
|
267
|
+
# reconnect can be told apart from a first connect. background.js
|
|
268
|
+
# persists the id in `chrome.storage.local`, so it survives service
|
|
269
|
+
# worker restarts and extension reloads; a genuinely new id means a new
|
|
270
|
+
# profile or a reinstall, not a churning SW. Reading a session-scoped
|
|
271
|
+
# daemon.log without that distinction is what made the e2e harness's
|
|
272
|
+
# one-fresh-Chrome-per-test look like an extension reconnecting with a
|
|
273
|
+
# new id between commands (issue #79).
|
|
264
274
|
|
|
265
275
|
# ---- lifecycle -------------------------------------------------------
|
|
266
276
|
|
|
@@ -1265,11 +1275,37 @@ class RelayServer:
|
|
|
1265
1275
|
)
|
|
1266
1276
|
comparison = compare_versions(ext.browserwright_version or ext.version, __version__)
|
|
1267
1277
|
ext.version_drift = comparison.drift.value
|
|
1278
|
+
# GH#79: one live connection per install_id. A single MV3 service
|
|
1279
|
+
# worker could dial the relay twice (a superseded socket's late
|
|
1280
|
+
# `onclose` cleared the extension's module-level `ws`, so its
|
|
1281
|
+
# `maintainLoop` opened a duplicate a tick later) and both sockets
|
|
1282
|
+
# said `hello` with the same install_id. `_extensions` is keyed by
|
|
1283
|
+
# install_id, so the re-key below silently evicts the older entry
|
|
1284
|
+
# while its TCP connection stays ESTABLISHED — a ghost we keep
|
|
1285
|
+
# app-pinging and can never route to. Close it explicitly instead.
|
|
1286
|
+
# The extension-side fix is in `chrome-extension/background.js`,
|
|
1287
|
+
# but it only reaches users when a new build ships to the Web
|
|
1288
|
+
# Store, so the invariant is enforced here too.
|
|
1289
|
+
superseded = [
|
|
1290
|
+
other for other in self._extensions.values()
|
|
1291
|
+
if other is not ext
|
|
1292
|
+
and ext.install_id
|
|
1293
|
+
and other.install_id == ext.install_id
|
|
1294
|
+
]
|
|
1268
1295
|
# Re-key the extension by install_id (so multiple extensions don't
|
|
1269
1296
|
# collide on temp_key collisions).
|
|
1270
1297
|
self._extensions.pop(temp_key, None)
|
|
1271
1298
|
self._extensions[ext.install_id or temp_key] = ext
|
|
1272
1299
|
ext.hello_received.set()
|
|
1300
|
+
for other in superseded:
|
|
1301
|
+
logger.info(
|
|
1302
|
+
"superseding older relay connection for install_id=%s "
|
|
1303
|
+
"(the extension dialled twice)",
|
|
1304
|
+
ext.install_id,
|
|
1305
|
+
)
|
|
1306
|
+
asyncio.create_task(self._force_close_extension(
|
|
1307
|
+
other, reason="superseded by a newer connection from the "
|
|
1308
|
+
"same install_id"))
|
|
1273
1309
|
if ext.app_ping_task is None or ext.app_ping_task.done():
|
|
1274
1310
|
ext.app_ping_task = asyncio.create_task(self._app_ping_loop(ext))
|
|
1275
1311
|
self._first_ready.set()
|
|
@@ -1298,8 +1334,14 @@ class RelayServer:
|
|
|
1298
1334
|
ext.browserwright_version or ext.version,
|
|
1299
1335
|
__version__,
|
|
1300
1336
|
)
|
|
1337
|
+
first_seen = bool(
|
|
1338
|
+
ext.install_id) and ext.install_id not in self._seen_install_ids
|
|
1339
|
+
if ext.install_id:
|
|
1340
|
+
self._seen_install_ids.add(ext.install_id)
|
|
1301
1341
|
logger.info(
|
|
1302
|
-
"extension hello: install_id=%s browser=%s version=%s
|
|
1342
|
+
"extension hello (%s): install_id=%s browser=%s version=%s "
|
|
1343
|
+
"protocol=%s",
|
|
1344
|
+
"first connect" if first_seen else "reconnect",
|
|
1303
1345
|
ext.install_id,
|
|
1304
1346
|
ext.browser,
|
|
1305
1347
|
ext.version,
|
|
@@ -1321,7 +1363,8 @@ class RelayServer:
|
|
|
1321
1363
|
await self._maybe_reload_for_version_drift(ext)
|
|
1322
1364
|
if self._on_extension_hello is not None:
|
|
1323
1365
|
try:
|
|
1324
|
-
await self._on_extension_hello(
|
|
1366
|
+
await self._on_extension_hello(
|
|
1367
|
+
install_id=ext.install_id, first_seen=first_seen)
|
|
1325
1368
|
except Exception as e: # noqa: BLE001 - never break hello
|
|
1326
1369
|
logger.warning(
|
|
1327
1370
|
"extension hello callback failed: %r", e)
|
|
@@ -244,6 +244,54 @@ _SOURCE_LABEL = {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
def local_unreachable_fix(ep: DaemonEndpoint) -> str:
|
|
248
|
+
"""The `fix` for "nothing answered" on a NON-explicitly-configured endpoint.
|
|
249
|
+
|
|
250
|
+
The class default — "start the single global daemon: `browserwright-daemon
|
|
251
|
+
serve`" — is a dead end whenever the daemon is already running, which is
|
|
252
|
+
the common case here: the daemon bound a *specific* non-loopback host
|
|
253
|
+
(`--facade-host <tailnet-ip>`) and this client resolved something else. So
|
|
254
|
+
name the real divergence instead of guessing.
|
|
255
|
+
"""
|
|
256
|
+
published = _from_state_file()
|
|
257
|
+
normalized = _normalize(published) if published else None
|
|
258
|
+
|
|
259
|
+
if ep.source == "default" and normalized and normalized != ep.url:
|
|
260
|
+
return (
|
|
261
|
+
f"a daemon published {normalized} in its endpoint state file, but "
|
|
262
|
+
f"this client resolved the built-in default {ep.url} — they "
|
|
263
|
+
f"disagree. Point the client at it (`export {ENV_VAR}="
|
|
264
|
+
f"{normalized}`), or rebind the daemon so loopback is served too "
|
|
265
|
+
"(`browserwright-daemon install --facade-host 0.0.0.0` then "
|
|
266
|
+
"`browserwright-daemon restart`)."
|
|
267
|
+
)
|
|
268
|
+
if ep.source == "state_file":
|
|
269
|
+
host_note = ""
|
|
270
|
+
if not ep.is_loopback:
|
|
271
|
+
host_note = (
|
|
272
|
+
f" That endpoint is bound to the non-loopback host "
|
|
273
|
+
f"{ep.host}, so it is only reachable over that interface — if "
|
|
274
|
+
"it is down (VPN/tailnet off), nothing local can reach the "
|
|
275
|
+
"daemon."
|
|
276
|
+
)
|
|
277
|
+
return (
|
|
278
|
+
f"the running daemon published {ep.url} but nothing answered "
|
|
279
|
+
f"there.{host_note} Check it with `browserwright-daemon status` "
|
|
280
|
+
"and `lsof -nP -iTCP:"
|
|
281
|
+
f"{ep.port} -sTCP:LISTEN`, then `browserwright-daemon restart`. "
|
|
282
|
+
"If the daemon is up, the state file is stale."
|
|
283
|
+
)
|
|
284
|
+
return (
|
|
285
|
+
f"nothing is listening on the default endpoint {ep.url}. Check "
|
|
286
|
+
f"`browserwright-daemon status`; if a daemon IS running it is bound "
|
|
287
|
+
"elsewhere (see `--facade-host`) — point this client at it with "
|
|
288
|
+
f"${ENV_VAR}. Otherwise start one: `browserwright-daemon serve`. "
|
|
289
|
+
f"Note `lsof -nP -iTCP:{ep.port} -sTCP:LISTEN` shows a foreign holder "
|
|
290
|
+
"of the port (a proxy such as Surge can answer HTTP on it without a "
|
|
291
|
+
"daemon behind it)."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
247
295
|
def not_ours_to_signal_message(ep: DaemonEndpoint, action: str) -> str:
|
|
248
296
|
"""Why a local signal-based ``action`` is refused for a remote endpoint."""
|
|
249
297
|
return (
|
|
@@ -43,9 +43,19 @@ class PageBindTimeout(BrowserwrightError):
|
|
|
43
43
|
|
|
44
44
|
exit_code = 3
|
|
45
45
|
retryable = True
|
|
46
|
+
# BUG B: `retryable` has to be honest. A retry only helps when the daemon
|
|
47
|
+
# was still going to announce the tab — a cold/reconnecting extension
|
|
48
|
+
# service worker is exactly that case, and the bind budget
|
|
49
|
+
# (`$BW_PAGE_BIND_TIMEOUT`) is the knob for it. If retries do NOT help, the
|
|
50
|
+
# extension side is not answering at all, and `session reset` will not
|
|
51
|
+
# change that — say what to check instead of looping the user.
|
|
46
52
|
default_fix = (
|
|
47
|
-
"retry the same browserwright command
|
|
48
|
-
"
|
|
53
|
+
"retry the same browserwright command (a cold extension service worker "
|
|
54
|
+
"can miss the bind window; raise it with `export "
|
|
55
|
+
"BW_PAGE_BIND_TIMEOUT=30`). If EVERY retry fails, the extension is not "
|
|
56
|
+
"answering: check `browserwright doctor` and that the browserwright "
|
|
57
|
+
"extension is enabled and its Chrome window is open, then "
|
|
58
|
+
"`browserwright session reset <id>`"
|
|
49
59
|
)
|
|
50
60
|
|
|
51
61
|
def __init__(
|
|
@@ -183,6 +183,17 @@ def doctor_checks() -> dict:
|
|
|
183
183
|
"field)",
|
|
184
184
|
"update browserwright-daemon to match browserwright")
|
|
185
185
|
|
|
186
|
+
# 3b. endpoint_reachable — can a LOCAL client actually dial the endpoint?
|
|
187
|
+
# BUG A: check 3 above only *reports* the advertised address, so a
|
|
188
|
+
# daemon bound to a specific non-loopback host (`--facade-host
|
|
189
|
+
# <tailnet-ip>`) read as a clean bill of health while every local
|
|
190
|
+
# client failed with ECONNREFUSED on 127.0.0.1. Doctor has to dial, not
|
|
191
|
+
# echo — a health check that cannot observe the reported failure is the
|
|
192
|
+
# gap, not a passing check.
|
|
193
|
+
if not synthetic and info.get("alive") is not False:
|
|
194
|
+
for check in _endpoint_reachability_checks():
|
|
195
|
+
add(**check)
|
|
196
|
+
|
|
186
197
|
# 4. schema version sanity (catches a daemon too old to speak the blob)
|
|
187
198
|
sv = info.get("schema_version")
|
|
188
199
|
if not synthetic:
|
|
@@ -299,3 +310,73 @@ def doctor_checks() -> dict:
|
|
|
299
310
|
"checks": checks,
|
|
300
311
|
"raw": info,
|
|
301
312
|
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _probe_tcp(host: str, port: int, *, timeout: float = 1.5) -> str | None:
|
|
316
|
+
"""``None`` when a TCP connect succeeds, else a short reason."""
|
|
317
|
+
import socket
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
321
|
+
return None
|
|
322
|
+
except OSError as e:
|
|
323
|
+
return e.strerror or str(e)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _endpoint_reachability_checks() -> list[dict]:
|
|
327
|
+
"""Dial the resolved endpoint AND loopback; report the divergence.
|
|
328
|
+
|
|
329
|
+
Two distinct failures hide behind "the daemon is running":
|
|
330
|
+
- the endpoint the client resolves answers nothing at all;
|
|
331
|
+
- it answers, but loopback (what every client falls back to when the
|
|
332
|
+
endpoint state file is not visible) does not.
|
|
333
|
+
The second is BUG A, and it is invisible unless you actually connect.
|
|
334
|
+
"""
|
|
335
|
+
from .daemon_url import daemon_endpoint
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
ep = daemon_endpoint()
|
|
339
|
+
except Exception: # noqa: BLE001 - doctor must never raise
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
resolved_err = _probe_tcp(ep.host, ep.port)
|
|
343
|
+
if resolved_err is not None:
|
|
344
|
+
return [{
|
|
345
|
+
"name": "endpoint_reachable",
|
|
346
|
+
"status": "fail",
|
|
347
|
+
"message": (f"nothing answered at {ep.host}:{ep.port} "
|
|
348
|
+
f"(the endpoint resolved from {ep.source})"),
|
|
349
|
+
"fix": ("check `browserwright-daemon status` and `lsof -nP -iTCP:"
|
|
350
|
+
f"{ep.port} -sTCP:LISTEN`, then `browserwright-daemon "
|
|
351
|
+
"restart`"),
|
|
352
|
+
}]
|
|
353
|
+
|
|
354
|
+
if ep.is_loopback:
|
|
355
|
+
return [{
|
|
356
|
+
"name": "endpoint_reachable",
|
|
357
|
+
"status": "pass",
|
|
358
|
+
"message": f"endpoint answers at {ep.host}:{ep.port}",
|
|
359
|
+
"fix": "",
|
|
360
|
+
}]
|
|
361
|
+
|
|
362
|
+
loopback_err = _probe_tcp("127.0.0.1", ep.port)
|
|
363
|
+
if loopback_err is None:
|
|
364
|
+
return [{
|
|
365
|
+
"name": "endpoint_reachable",
|
|
366
|
+
"status": "pass",
|
|
367
|
+
"message": (f"endpoint answers at {ep.host}:{ep.port} and on "
|
|
368
|
+
"127.0.0.1"),
|
|
369
|
+
"fix": "",
|
|
370
|
+
}]
|
|
371
|
+
return [{
|
|
372
|
+
"name": "endpoint_reachable",
|
|
373
|
+
"status": "fail",
|
|
374
|
+
"message": (f"the daemon answers at {ep.host}:{ep.port} but NOT on "
|
|
375
|
+
f"127.0.0.1:{ep.port} ({loopback_err}) — any local client "
|
|
376
|
+
"that cannot read the endpoint state file will fail to "
|
|
377
|
+
"connect"),
|
|
378
|
+
"fix": ("rebind so loopback is served too: `browserwright-daemon "
|
|
379
|
+
"install --facade-host 0.0.0.0` then `browserwright-daemon "
|
|
380
|
+
"restart`; or point clients at it with "
|
|
381
|
+
f"`export BW_DAEMON_URL=http://{ep.host}:{ep.port}`"),
|
|
382
|
+
}]
|
|
@@ -32,13 +32,45 @@ there is no loop conflict with Playwright's sync driver.
|
|
|
32
32
|
"""
|
|
33
33
|
from __future__ import annotations
|
|
34
34
|
|
|
35
|
+
import logging
|
|
35
36
|
from typing import Any
|
|
36
37
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
37
38
|
from uuid import uuid4
|
|
38
39
|
|
|
39
40
|
from ..errors import BrowserwrightError, PageBindTimeout
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _page_bind_timeout_s() -> float:
|
|
46
|
+
"""How long to wait for Playwright to expose the agent-resolved tab.
|
|
47
|
+
|
|
48
|
+
BUG B: this was a hardcoded 2.0s, which is shorter than an extension MV3
|
|
49
|
+
service worker takes to wake up. When the SW is cold — or has just
|
|
50
|
+
reconnected to the relay, which happens routinely between commands — the
|
|
51
|
+
daemon's announce lands well after the budget, the bind fails, and the
|
|
52
|
+
caller is told to retry a command whose only real problem was being asked
|
|
53
|
+
too early. A wait measured in seconds costs nothing on the happy path (the
|
|
54
|
+
announce normally lands in milliseconds and the loop returns immediately),
|
|
55
|
+
while the old budget turned a slow wake-up into a hard failure.
|
|
56
|
+
|
|
57
|
+
Override with ``BW_PAGE_BIND_TIMEOUT`` (seconds) for a very slow machine.
|
|
58
|
+
"""
|
|
59
|
+
import os
|
|
60
|
+
|
|
61
|
+
raw = (os.environ.get("BW_PAGE_BIND_TIMEOUT") or "").strip()
|
|
62
|
+
if raw:
|
|
63
|
+
try:
|
|
64
|
+
value = float(raw)
|
|
65
|
+
except ValueError:
|
|
66
|
+
value = 0.0
|
|
67
|
+
if value > 0:
|
|
68
|
+
return value
|
|
69
|
+
return _PAGE_BIND_TIMEOUT_S
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: The bind budget when nothing overrides it. Tests monkeypatch this name.
|
|
73
|
+
_PAGE_BIND_TIMEOUT_S = 10.0
|
|
42
74
|
_PAGE_BIND_POLL_INTERVAL_S = 0.05
|
|
43
75
|
|
|
44
76
|
|
|
@@ -250,14 +282,18 @@ def bind_current_page(context: Any, sess: Any) -> Any:
|
|
|
250
282
|
# Resolve/create + persist the session's current tab via the agent path.
|
|
251
283
|
info = resolve_current_target(sess)
|
|
252
284
|
target_id = info.get("targetId") if isinstance(info, dict) else None
|
|
285
|
+
# Did THIS call open the tab? Only step 4 of `resolve_current_target` sets
|
|
286
|
+
# it, so a reused tab is never mistaken for one we may discard.
|
|
287
|
+
we_opened_it = bool(info.get("opened")) if isinstance(info, dict) else False
|
|
253
288
|
|
|
289
|
+
budget = _page_bind_timeout_s()
|
|
254
290
|
if target_id:
|
|
255
291
|
page = _wait_for_target_page(
|
|
256
292
|
context,
|
|
257
293
|
sess,
|
|
258
294
|
target_id,
|
|
259
295
|
info.get("url"),
|
|
260
|
-
timeout=
|
|
296
|
+
timeout=budget,
|
|
261
297
|
)
|
|
262
298
|
if page is not None:
|
|
263
299
|
return patch_page_goto(page)
|
|
@@ -267,12 +303,50 @@ def bind_current_page(context: Any, sess: Any) -> Any:
|
|
|
267
303
|
# second target merely because the facade has not exposed the first one to
|
|
268
304
|
# Playwright yet: that splits the ledger and Playwright views and is the
|
|
269
305
|
# source of duplicate user-visible tabs.
|
|
306
|
+
#
|
|
307
|
+
# BUG B: a tab THIS call opened and then failed to bind has no owner —
|
|
308
|
+
# `PageBindTimeout` is advertised as retryable, so the caller retries,
|
|
309
|
+
# `resolve_current_target` finds no usable tab again and opens another.
|
|
310
|
+
# Whether the previous one survives depends on `session end` later finding
|
|
311
|
+
# it in the session's tab group, which is precisely what is unreliable when
|
|
312
|
+
# the announce failed. No user-visible leak has been observed; this closes
|
|
313
|
+
# the window rather than fixing a confirmed one. Roll our own creation back
|
|
314
|
+
# before raising.
|
|
315
|
+
if we_opened_it and target_id:
|
|
316
|
+
_discard_unbindable_tab(sess, target_id)
|
|
270
317
|
raise PageBindTimeout(
|
|
271
318
|
target_id=target_id or "",
|
|
272
|
-
timeout=
|
|
319
|
+
timeout=budget,
|
|
273
320
|
)
|
|
274
321
|
|
|
275
322
|
|
|
323
|
+
def _discard_unbindable_tab(sess: Any, target_id: str) -> None:
|
|
324
|
+
"""Close a tab we opened but could not bind, and clear its ledger binding.
|
|
325
|
+
|
|
326
|
+
Strictly best-effort: the bind already failed, and a failure to clean up
|
|
327
|
+
must not replace ``PageBindTimeout`` (which names the real problem) with a
|
|
328
|
+
teardown error. Worst case we are back to the old leak.
|
|
329
|
+
|
|
330
|
+
Clearing the durable binding matters as much as closing the tab: leaving
|
|
331
|
+
the ledger pointed at a tab that is gone makes the NEXT call take the
|
|
332
|
+
recovery path against a dead target instead of opening a clean one.
|
|
333
|
+
"""
|
|
334
|
+
from ..session_runtime import close_session_tab, persist_target
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
close_session_tab(sess, target_id=target_id)
|
|
338
|
+
except Exception: # noqa: BLE001 - see docstring: never mask the bind error
|
|
339
|
+
logger.warning("could not close unbindable tab %s", target_id,
|
|
340
|
+
exc_info=True)
|
|
341
|
+
try:
|
|
342
|
+
if getattr(sess, "current_target_id", None) == target_id:
|
|
343
|
+
sess.current_target_id = None
|
|
344
|
+
persist_target(None, sess=sess)
|
|
345
|
+
except Exception: # noqa: BLE001 - best-effort
|
|
346
|
+
logger.warning("could not clear binding for unbindable tab %s",
|
|
347
|
+
target_id, exc_info=True)
|
|
348
|
+
|
|
349
|
+
|
|
276
350
|
def _wait_for_target_page(
|
|
277
351
|
context: Any,
|
|
278
352
|
sess: Any,
|
|
@@ -98,13 +98,18 @@ class Session:
|
|
|
98
98
|
return self._cdp
|
|
99
99
|
|
|
100
100
|
def _unreachable(self, url: str, cause: BaseException) -> DaemonUnavailable:
|
|
101
|
-
from .daemon_url import daemon_endpoint,
|
|
101
|
+
from .daemon_url import (daemon_endpoint, local_unreachable_fix,
|
|
102
|
+
unreachable_message)
|
|
102
103
|
|
|
103
104
|
endpoint = daemon_endpoint()
|
|
104
105
|
if endpoint.explicit:
|
|
105
106
|
return DaemonUnavailable(f"{unreachable_message(endpoint)} ({cause})")
|
|
107
|
+
# BUG A: the class default fix ("start the daemon") is actively
|
|
108
|
+
# misleading here — the daemon is usually running, just bound to a host
|
|
109
|
+
# this client did not resolve. Diagnose the divergence instead.
|
|
106
110
|
return DaemonUnavailable(
|
|
107
|
-
f"no browserwright daemon answered at {url}: {cause}"
|
|
111
|
+
f"no browserwright daemon answered at {url}: {cause}",
|
|
112
|
+
fix=local_unreachable_fix(endpoint))
|
|
108
113
|
|
|
109
114
|
def _resolve_ws_url(self) -> str:
|
|
110
115
|
"""Ask the underlying daemon client for a CDP ws URL.
|
|
@@ -476,6 +476,14 @@ def end(record: dict) -> str:
|
|
|
476
476
|
f"kept for retry.{hint}")
|
|
477
477
|
if record.get("owner") == "create":
|
|
478
478
|
msg = f"session {sid} ended; the browser it launched was closed."
|
|
479
|
+
elif record.get("backend") == "extension":
|
|
480
|
+
# BUG B: the `attach` wording below is a cdp story — it claims nothing
|
|
481
|
+
# in the browser was touched, while the daemon has just closed every
|
|
482
|
+
# tab in this session's tab group. Reporting "left untouched" for a
|
|
483
|
+
# teardown that closes tabs is how a leaked tab and a torn-down one
|
|
484
|
+
# became indistinguishable to the user.
|
|
485
|
+
msg = (f"session {sid} ended; its tab group was closed. Your Chrome "
|
|
486
|
+
f"is still running — only this session's tabs were touched.")
|
|
479
487
|
else:
|
|
480
488
|
msg = (f"session {sid} ended. The browser is still running — you "
|
|
481
489
|
f"attached to it, so it was left untouched.")
|
|
@@ -388,9 +388,17 @@ def resolve_current_target(sess) -> dict:
|
|
|
388
388
|
return {"targetId": tabs[0]["targetId"], "url": tabs[0]["url"],
|
|
389
389
|
"title": tabs[0]["title"], "accuracy": "unknown"}
|
|
390
390
|
# 4. Empty session — open a fresh working tab (NOT adopt).
|
|
391
|
+
#
|
|
392
|
+
# `opened: True` marks the tab as CREATED BY THIS CALL, which is the only
|
|
393
|
+
# thing that distinguishes it from the reuse paths above. Callers that can
|
|
394
|
+
# fail after this point (`repl.playwright_handle.bind_current_page`) need
|
|
395
|
+
# it to know whether a failure of theirs leaves a user-visible tab behind
|
|
396
|
+
# that nobody else will ever claim — steps 1-3 hand back a tab the session
|
|
397
|
+
# already owned, and closing one of those on a failed bind would destroy
|
|
398
|
+
# the agent's actual working tab.
|
|
391
399
|
opened = open_session_tab(sess, "about:blank",
|
|
392
400
|
skip_post_attach_commands=True)
|
|
393
|
-
return opened | {"accuracy": "unknown"}
|
|
401
|
+
return opened | {"accuracy": "unknown", "opened": True}
|
|
394
402
|
|
|
395
403
|
|
|
396
404
|
def eval_js(sess, expression: str, *, await_promise: bool = False) -> Any:
|
|
@@ -373,9 +373,42 @@ Reusable flows belong in site-skill tasks. A task's `run(args, ctx)` receives th
|
|
|
373
373
|
|
|
374
374
|
```bash
|
|
375
375
|
browserwright list-tasks
|
|
376
|
+
browserwright list-tasks --query="search the web"
|
|
376
377
|
browserwright -s "$sid" task wikipedia.org/lookup --title="Browser automation"
|
|
377
378
|
```
|
|
378
379
|
|
|
380
|
+
**Look before you improvise.** Run `list-tasks` before hand-writing a flow for a site. A saved task replays a known-good path instead of re-deriving it from a fresh snapshot.
|
|
381
|
+
|
|
382
|
+
**Solidify when the flow repeats.** Write a task once either of these is true, without waiting to be asked:
|
|
383
|
+
|
|
384
|
+
- You have driven the same site flow twice, or the user asks for something they will plainly ask for again — a recurring report, a standing search, a routine form.
|
|
385
|
+
- You just spent several snapshot/click rounds working out a path that a URL template plus two selectors can now replay directly.
|
|
386
|
+
|
|
387
|
+
Author it with the `Write` tool; the runtime is filesystem-driven and there is no scaffolding command. Put the file at `$BS_HOME/site-skills/<eTLD+1>/tasks/<name>.py` (default `~/.browserwright/site-skills/...`), or at `./site-skills/...` in the CWD when the user wants it version-controlled with the project. Reads consult project-local `./site-skills/` first, then `$BS_HOME/site-skills/`, then the bundled starter set — first hit per site name wins, so a project task shadows a personal one. `remember()` and `bootstrap_site()` write to `./site-skills/` when that directory already exists and to `$BS_HOME` otherwise.
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
"""One-line description — this becomes the task's listed summary."""
|
|
391
|
+
|
|
392
|
+
ARGS = {
|
|
393
|
+
"query": {"type": "str", "required": True, "desc": "Search term"},
|
|
394
|
+
"lang": {"type": "str", "required": False, "default": "en", "desc": "Language code"},
|
|
395
|
+
}
|
|
396
|
+
OUTPUT = "{title: str, url: str}"
|
|
397
|
+
TAGS = ["search"]
|
|
398
|
+
REQUIRES_LOGIN = False
|
|
399
|
+
ESTIMATED_DURATION_SEC = 5
|
|
400
|
+
LAST_VERIFIED = "2026-05-25" # ISO date you last saw this actually work
|
|
401
|
+
|
|
402
|
+
def run(args, ctx=None):
|
|
403
|
+
# `page` / `context` / `snapshot` are injected — same surface as inline code.
|
|
404
|
+
page.goto(f"https://example.com/search?q={args['query']}", wait_until="load")
|
|
405
|
+
return {"title": page.title(), "url": page.url}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Only `ARGS` and `run` are required; the rest is metadata `list-tasks` ranks and displays. The runtime imports the file, injects `page` / `context` / `snapshot` lazily (a task that never touches them opens no browser), validates the call against `ARGS`, then calls `run(args, ctx)`. Define `OUTPUT_SCHEMA` and the return shape is validated too. `ctx.memory` is the parsed frontmatter of that site's `memory.md`, so a task can read the selectors you recorded with `remember()`.
|
|
409
|
+
|
|
410
|
+
Keep the two halves in sync. The note you write with `remember()` is what makes a task cheap to repair when the site changes, and `LAST_VERIFIED` is what tells the next agent whether to trust it. Set `BROKEN_SINCE = "<ISO date>"` instead of deleting a task you found broken and could not fix.
|
|
411
|
+
|
|
379
412
|
## Non-browser Helpers
|
|
380
413
|
|
|
381
414
|
These run without driving the browser:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: browserwright
|
|
3
|
-
Version: 0.17.
|
|
3
|
+
Version: 0.17.2
|
|
4
4
|
Summary: Browserwright — let AI/code agents drive a real or isolated browser and author userscripts. Single package: the agent-facing REPL/site-skills/memory layer plus the bundled browser-resolving daemon (CDP proxy + extension/cloud backends).
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
Requires-Python: >=3.11
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/backends/extension.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/executor_registry.py
RENAMED
|
File without changes
|
{browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright/daemon/server/extension_upstream.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{browserwright-0.17.0 → browserwright-0.17.2}/src/browserwright.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|