browserwright 0.7.4__tar.gz → 0.8.1__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.
Files changed (108) hide show
  1. {browserwright-0.7.4 → browserwright-0.8.1}/PKG-INFO +1 -1
  2. browserwright-0.8.1/pyproject.toml +112 -0
  3. browserwright-0.7.4/src/browserwright/api.py → browserwright-0.8.1/src/browserwright/__init__.py +30 -16
  4. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/__init__.py +4 -14
  5. browserwright-0.8.1/src/browserwright/_executor/client.py +301 -0
  6. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/process.py +310 -157
  7. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/protocol.py +139 -3
  8. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/cdp.py +27 -0
  9. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/cli.py +167 -46
  10. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/_ipc.py +147 -6
  11. browserwright-0.8.1/src/browserwright/daemon/_rpc.py +85 -0
  12. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/_stale.py +16 -37
  13. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/extension.py +2 -12
  14. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/cli.py +336 -398
  15. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/config.py +8 -4
  16. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/launch_chrome.py +109 -9
  17. browserwright-0.8.1/src/browserwright/daemon/launchagent.py +224 -0
  18. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/observability.py +56 -0
  19. browserwright-0.8.1/src/browserwright/daemon/probe.py +194 -0
  20. browserwright-0.8.1/src/browserwright/daemon/relay_status.py +51 -0
  21. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/daemon.py +199 -5
  22. browserwright-0.8.1/src/browserwright/daemon/server/executor_registry.py +703 -0
  23. browserwright-0.8.1/src/browserwright/daemon/server/extension_upstream.py +1314 -0
  24. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/facade.py +44 -14
  25. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/facade_extension.py +102 -40
  26. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/listener.py +256 -107
  27. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/proxy.py +182 -68
  28. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/relay.py +214 -46
  29. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/state.py +19 -0
  30. browserwright-0.8.1/src/browserwright/daemon/server/status.py +282 -0
  31. browserwright-0.8.1/src/browserwright/daemon/server/upstream.py +887 -0
  32. browserwright-0.8.1/src/browserwright/daemon/server/verbs.py +867 -0
  33. browserwright-0.8.1/src/browserwright/daemon/supervise.py +162 -0
  34. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/errors.py +31 -3
  35. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/health.py +2 -2
  36. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/install.py +2 -2
  37. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/_md.py +0 -1
  38. browserwright-0.8.1/src/browserwright/primitives/__init__.py +18 -0
  39. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/primitives/site.py +0 -1
  40. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/inline.py +13 -7
  41. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/playwright_handle.py +167 -40
  42. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session.py +30 -7
  43. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_create.py +142 -58
  44. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_registry.py +46 -2
  45. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/skill_runtime.md +50 -8
  46. browserwright-0.8.1/src/browserwright/task_runner.py +347 -0
  47. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/PKG-INFO +1 -1
  48. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/SOURCES.txt +6 -1
  49. browserwright-0.7.4/pyproject.toml +0 -71
  50. browserwright-0.7.4/src/browserwright/__init__.py +0 -33
  51. browserwright-0.7.4/src/browserwright/_executor/client.py +0 -135
  52. browserwright-0.7.4/src/browserwright/daemon/server/executor_registry.py +0 -408
  53. browserwright-0.7.4/src/browserwright/daemon/server/extension_upstream.py +0 -673
  54. browserwright-0.7.4/src/browserwright/daemon/server/upstream.py +0 -252
  55. browserwright-0.7.4/src/browserwright/daemon/server/verbs.py +0 -1155
  56. browserwright-0.7.4/src/browserwright/primitives/__init__.py +0 -35
  57. browserwright-0.7.4/src/browserwright/task_runner.py +0 -134
  58. {browserwright-0.7.4 → browserwright-0.8.1}/README.md +0 -0
  59. {browserwright-0.7.4 → browserwright-0.8.1}/setup.cfg +0 -0
  60. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/__main__.py +0 -0
  61. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/_executor/__main__.py +0 -0
  62. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/__init__.py +0 -0
  63. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/__init__.py +0 -0
  64. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/base.py +0 -0
  65. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/backends/cdp.py +0 -0
  66. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/doctor.py +0 -0
  67. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/errors.py +0 -0
  68. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/platforms.py +0 -0
  69. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/resolver.py +0 -0
  70. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/server/__init__.py +0 -0
  71. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/daemon/userscripts.py +0 -0
  72. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/discovery.py +0 -0
  73. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/__init__.py +0 -0
  74. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/_lock.py +0 -0
  75. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/_yaml.py +0 -0
  76. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/global_mem.py +0 -0
  77. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/memory/site_mem.py +0 -0
  78. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/mode_b_client.py +0 -0
  79. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/output_schema.py +0 -0
  80. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/primitives/discovery_api.py +0 -0
  81. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/primitives/http.py +0 -0
  82. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/__init__.py +0 -0
  83. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/_namespace.py +0 -0
  84. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/_smart_goto.py +0 -0
  85. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/repl/snapshot.py +0 -0
  86. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_ctx.py +0 -0
  87. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/session_runtime.py +0 -0
  88. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/github.com/SKILL.md +0 -0
  89. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/github.com/memory.md +0 -0
  90. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/github.com/tasks/list_issues.py +0 -0
  91. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/google.com/SKILL.md +0 -0
  92. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/google.com/memory.md +0 -0
  93. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/google.com/tasks/search.py +0 -0
  94. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/producthunt.com/SKILL.md +0 -0
  95. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/producthunt.com/memory.md +0 -0
  96. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/producthunt.com/tasks/today.py +0 -0
  97. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/wikipedia.org/SKILL.md +0 -0
  98. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/wikipedia.org/memory.md +0 -0
  99. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +0 -0
  100. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/ycombinator.com/SKILL.md +0 -0
  101. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/ycombinator.com/memory.md +0 -0
  102. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +0 -0
  103. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/skill_doc.py +0 -0
  104. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright/version.py +0 -0
  105. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/dependency_links.txt +0 -0
  106. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/entry_points.txt +0 -0
  107. {browserwright-0.7.4 → browserwright-0.8.1}/src/browserwright.egg-info/requires.txt +0 -0
  108. {browserwright-0.7.4 → browserwright-0.8.1}/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.7.4
3
+ Version: 0.8.1
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
  Requires-Python: >=3.11
6
6
  Requires-Dist: websockets==15.0.1
@@ -0,0 +1,112 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "browserwright"
7
+ # Placeholder, never bumped per release — the git tag is the single source of
8
+ # truth. `.github/workflows/release.yml` rewrites this line (and
9
+ # `chrome-extension/manifest.json`) from `GITHUB_REF_NAME` at build time, so a
10
+ # release commit must NOT edit it. The sentinel is `0.0.0` on purpose: it is
11
+ # valid semver, is below every published version, and can never be mistaken for
12
+ # a real release — which a stale-but-plausible number (this used to sit at
13
+ # "0.6.2" while tags had reached v0.8.0) very much could. Keep it in lockstep
14
+ # with the manifest, keep the line on its own with no trailing comment: the
15
+ # stamping regex is `^(version\s*=\s*)["'][^"']+["']\s*$` and CI aborts unless
16
+ # it matches exactly once. `tests/skill/test_release_versioning.py` enforces all
17
+ # of that before a tag is ever pushed. See RELEASING.md.
18
+ version = "0.8.1"
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
+ requires-python = ">=3.11"
21
+ dependencies = [
22
+ "websockets==15.0.1",
23
+ "pillow==12.2.0",
24
+ "httpx>=0.27",
25
+ # Phase C: the skill heredoc injects a real Playwright `page`/`context`
26
+ # bound to the daemon facade, so playwright is a runtime dependency now (it
27
+ # was previously dev-only). `playwright install chromium` is still needed
28
+ # for tests that launch a browser; the daemon-facade path drives the
29
+ # daemon-resolved Chrome and does not need Playwright's bundled browser.
30
+ "playwright>=1.60.0",
31
+ "pyyaml>=6.0.3",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ ux = ["rich>=13"]
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "coverage>=7",
40
+ "pytest>=8",
41
+ "pytest-asyncio>=0.23",
42
+ ]
43
+
44
+ [project.scripts]
45
+ browserwright = "browserwright.cli:main"
46
+ browserwright-daemon = "browserwright.daemon.cli:main"
47
+
48
+ [tool.setuptools]
49
+ package-dir = {"" = "src"}
50
+ include-package-data = true
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["src"]
54
+
55
+ [tool.setuptools.package-data]
56
+ browserwright = [
57
+ "site_skills_starter/**/*.md",
58
+ "site_skills_starter/**/*.py",
59
+ "skill_runtime.md",
60
+ ]
61
+
62
+ [tool.setuptools.exclude-package-data]
63
+ browserwright = ["**/__pycache__/*", "**/*.pyc", "**/*.pyo"]
64
+
65
+ [tool.pytest.ini_options]
66
+ pythonpath = ["src"]
67
+ asyncio_mode = "auto"
68
+ testpaths = ["tests"]
69
+ markers = [
70
+ "real_chrome: end-to-end test that launches a real Chrome + extension (skipped unless explicitly selected)",
71
+ ]
72
+
73
+ # Lint config. `select` is explicit on purpose: ruff's *default* rule set grew
74
+ # substantially by 0.16.x (ASYNC/S/BLE/B/UP/RUF/... are now on by default), which
75
+ # silently took this repo from clean to 646 findings without a line of code
76
+ # changing. Pinning the ruff version (mise `[tools]`) *and* the rule set here is
77
+ # what makes `mise run lint` mean the same thing on every machine and across ruff
78
+ # upgrades. Widening the set is a deliberate, separate decision — do it in one
79
+ # focused pass, not as a side effect of an unrelated ruff release.
80
+ [tool.ruff]
81
+ target-version = "py311"
82
+
83
+ [tool.ruff.lint]
84
+ select = ["E4", "E7", "E9", "F"]
85
+
86
+ [tool.ruff.lint.per-file-ignores]
87
+ # api.py exists purely to re-export the agent-facing surface; the names it
88
+ # imports are listed in `EXPORTS` as *strings*, which ruff can't see as usage.
89
+ # Without this, `--fix` strips the whole re-export block and every
90
+ # `api.<name>` lookup dies at runtime (verified: it breaks 22 test modules).
91
+ "src/browserwright/api.py" = ["F401"]
92
+ # Site-skill task snippets are executed with a Playwright `page` injected into
93
+ # globals by the executor — `page` is undefined at import time by design.
94
+ "src/browserwright/site_skills_starter/**" = ["F821"]
95
+ # pytest fixtures are shadowed by same-named test parameters (the standard
96
+ # fixture-injection pattern), and the e2e helpers extend sys.path before
97
+ # importing, so imports legitimately follow statements.
98
+ "tests/daemon/e2e/**" = ["E402", "F811"]
99
+ # Agent hook scripts bootstrap sys.path before importing their own modules.
100
+ ".claude/hooks/**" = ["E402"]
101
+ ".cursor/hooks/**" = ["E402"]
102
+
103
+ [tool.coverage.run]
104
+ source = ["src/browserwright"]
105
+ omit = [
106
+ "src/browserwright/__main__.py",
107
+ "src/browserwright/site_skills_starter/*",
108
+ ]
109
+
110
+ [tool.coverage.report]
111
+ skip_covered = true
112
+ sort = "Cover"
@@ -1,26 +1,38 @@
1
- """Canonical primitive surface for ``from browserwright import *``.
1
+ """browserwright — Layer 2 of the browser stack.
2
2
 
3
- The inline / repl / task entry points all assemble their exec globals from
4
- this module. Keeping the list in one place means an agent who imports
5
- ``browserwright`` directly from a saved task gets the same names the REPL
6
- gave them.
3
+ Public surface: ``browserwright.cli:main`` is the CLI entry point.
4
+
5
+ For programmatic use inside REPL/task scripts, import names from the top-level
6
+ ``browserwright`` namespace::
7
+
8
+ from browserwright import http_get, remember, run_task
9
+
10
+ ``EXPORTS`` below is the canonical primitive surface. The inline / repl / task
11
+ entry points all assemble their exec globals from it
12
+ (``repl/_namespace.build_globals``), and ``skill_doc`` renders the generated
13
+ skill documentation from it — so the list an agent reads always matches the
14
+ list the binary actually binds. Keeping it in one place means an agent who
15
+ imports ``browserwright`` directly from a saved task gets the same names the
16
+ REPL gave them.
7
17
 
8
18
  The legacy CDP browser-driving primitives (``open``/``goto_url``/
9
19
  ``click_at_xy``/``js``/``cdp``/``capture_screenshot``/``snapshot``/… — the
10
20
  whole page/tab interaction surface) are DELETED. The agent drives the browser
11
21
  with **real Playwright** via the injected ``page`` / ``context`` (bound to the
12
- session's current tab, reused across heredocs) and observes with
13
- ``snapshot()`` (a first-party AI aria snapshot whose ``[ref=eN]`` refs feed
14
- ``page.locator("aria-ref=eN")``). Those three names are injected per-heredoc
15
- by ``repl/_namespace.build_globals``, NOT exported here. The internal tab
16
- lifecycle the Playwright binding glue relies on lives in
17
- ``browserwright.session_runtime``.
22
+ session's current tab, reused across heredocs) and observes with ``snapshot()``
23
+ (a first-party AI aria snapshot whose ``[ref=eN]`` refs feed
24
+ ``page.locator("aria-ref=eN")``). Those three names are injected per-heredoc by
25
+ ``repl/_namespace.build_globals``, NOT exported here; they are bound per call
26
+ to the session's current tab. The internal tab lifecycle the Playwright binding
27
+ glue relies on lives in ``browserwright.session_runtime``.
18
28
 
19
29
  What remains in EXPORTS is the set of NON-browser-driving helpers that do not
20
30
  overlap Playwright: ``http_get`` (no-browser escape hatch), the memory verbs,
21
31
  and the site-skill / task layer.
22
32
  """
23
- from .errors import (
33
+ from .version import __version__ # noqa: F401
34
+
35
+ from .errors import ( # noqa: F401
24
36
  AuthWall,
25
37
  BrowserwrightError,
26
38
  Captcha,
@@ -31,16 +43,18 @@ from .errors import (
31
43
  NetworkError,
32
44
  PageLoadFailed,
33
45
  )
34
- from .primitives import (
35
- bootstrap_site,
36
- http_get,
46
+ from .primitives.discovery_api import ( # noqa: F401
37
47
  list_site_skills,
38
48
  load_site_skill,
49
+ run_task,
50
+ )
51
+ from .primitives.http import http_get # noqa: F401
52
+ from .primitives.site import ( # noqa: F401
53
+ bootstrap_site,
39
54
  memory_read,
40
55
  remember,
41
56
  remember_global,
42
57
  remember_preference,
43
- run_task,
44
58
  )
45
59
 
46
60
  EXPORTS = [
@@ -29,19 +29,9 @@ Status: PR1 (process skeleton + data plane), PR2 (daemon-side supervision —
29
29
  idle reap / endSession kill / crash reap / orphan sweep), and PR3 (``reset()`` +
30
30
  full output protocol: warnings / screenshots / truncation / traceback-bearing
31
31
  errors + per-call timeout enforcement) are all in place.
32
+
33
+ This package deliberately re-exports nothing: import ``.protocol`` (wire
34
+ types) or ``.client`` (thin client) directly, which is what every call site
35
+ already does.
32
36
  """
33
37
  from __future__ import annotations
34
-
35
- from .protocol import (
36
- ExecuteRequest,
37
- ExecuteResponse,
38
- recv_message,
39
- send_message,
40
- )
41
-
42
- __all__ = [
43
- "ExecuteRequest",
44
- "ExecuteResponse",
45
- "recv_message",
46
- "send_message",
47
- ]
@@ -0,0 +1,301 @@
1
+ """Thin-client side of the executor data plane.
2
+
3
+ Used by ``repl/inline.py`` when inline code touches ``page`` / ``context`` /
4
+ ``snapshot`` / ``state`` / ``reset``: the whole code body is shipped to the
5
+ session's resident executor and the response is replayed locally.
6
+
7
+ Control plane (spawn + discover) goes through the daemon's
8
+ ``BrowserwrightDaemon.ensureExecutor`` verb over the EXISTING mode_b socket
9
+ (tiny payload). The daemon spawns the executor if absent, waits for it to bind +
10
+ write its ``_ipc`` discovery file, and returns the socket path. The data plane
11
+ (this module) then connects DIRECTLY to that socket — keeping arbitrary code +
12
+ large output off the daemon's event loop (Fork 2).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import signal
17
+ import socket
18
+ import threading
19
+ import time
20
+ from collections.abc import Iterator
21
+ from contextlib import contextmanager
22
+ from dataclasses import dataclass
23
+
24
+ from .. import session_registry as reg
25
+ from ..errors import BrowserwrightError
26
+ from .protocol import (
27
+ DEFAULT_TIMEOUT_MS,
28
+ ExecuteRequest,
29
+ ExecuteResponse,
30
+ TaskEnvelope,
31
+ recv_message,
32
+ send_message,
33
+ )
34
+
35
+ # Allow the executor's deadline response a small framing/delivery margin. The
36
+ # browser cold-start is part of the request deadline, not hidden extra time.
37
+ _RESPONSE_DELIVERY_SLACK_S = 2.0
38
+
39
+
40
+ class ExecutorUnavailable(BrowserwrightError):
41
+ """The session's executor could not be ensured/reached.
42
+
43
+ Surfaced when ``ensureExecutor`` fails or the executor socket can't be
44
+ connected — actionable: the daemon must be running (it spawns the
45
+ executor)."""
46
+
47
+ default_fix = ("ensure the daemon is running (`browserwright-daemon status "
48
+ "--json` should show `alive`); if the executor is stale, "
49
+ "call `reset()` as a standalone/final inline statement, "
50
+ "then retry in a new command; or run `browserwright "
51
+ "session reset <id>` before retrying.")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ExecutorLease:
56
+ sock_path: str
57
+ executor_id: str
58
+
59
+
60
+ def _ensure_executor_lease(sess) -> ExecutorLease:
61
+ """Ask the daemon to ensure the session's executor and return its socket
62
+ path. Uses the session's mode_b CDP client (``sess.cdp``) to send the
63
+ control-plane verb."""
64
+ sid = _session_id(sess)
65
+ try:
66
+ # The browserwright session is already bound on the websocket query
67
+ # (`?session=<id>`). Do not pass it as CDP's top-level `sessionId`;
68
+ # that field means "attached target session" inside the proxy mux.
69
+ res = sess.cdp.send("BrowserwrightDaemon.ensureExecutor", bsSession=sid)
70
+ except Exception as e:
71
+ raise ExecutorUnavailable(
72
+ f"ensureExecutor failed for session {sid!r}: {e}"
73
+ ) from e
74
+ sock_path = res.get("exec_sock") if isinstance(res, dict) else None
75
+ executor_id = res.get("executor_id") if isinstance(res, dict) else None
76
+ if not isinstance(sock_path, str) or not sock_path:
77
+ raise ExecutorUnavailable(
78
+ f"ensureExecutor returned no socket for session {sid!r}: {res!r}"
79
+ )
80
+ if not isinstance(executor_id, str) or not executor_id:
81
+ raise ExecutorUnavailable(
82
+ "ensureExecutor returned no executor instance identity; restart "
83
+ "the daemon so timeout cleanup cannot target a newer process"
84
+ )
85
+ return ExecutorLease(sock_path=sock_path, executor_id=executor_id)
86
+
87
+
88
+ def ensure_executor(sess) -> str:
89
+ """Compatibility wrapper returning only the executor socket path."""
90
+ return _ensure_executor_lease(sess).sock_path
91
+
92
+
93
+ def run_on_executor(
94
+ sess,
95
+ code: str,
96
+ *,
97
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
98
+ env: dict[str, str] | None = None,
99
+ ) -> ExecuteResponse:
100
+ """Ship ``code`` to the session's executor and return its response.
101
+
102
+ Ensures the executor (control plane), connects its socket (data plane),
103
+ sends one :class:`ExecuteRequest`, reads one :class:`ExecuteResponse`.
104
+
105
+ The executor enforces ``timeout_ms`` across cold-start and user code. A
106
+ terminal deadline/reset response is not returned to the caller until the
107
+ daemon confirms that exact executor instance has exited."""
108
+ return _run_request_on_executor(
109
+ sess,
110
+ ExecuteRequest(
111
+ code=code,
112
+ timeout_ms=timeout_ms,
113
+ env=dict(env or {}),
114
+ ),
115
+ )
116
+
117
+
118
+ def run_task_on_executor(
119
+ sess,
120
+ site: str,
121
+ name: str,
122
+ *,
123
+ args: dict | None = None,
124
+ isolated: bool = False,
125
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
126
+ env: dict[str, str] | None = None,
127
+ ) -> ExecuteResponse:
128
+ """Run one validated site-skill task on the resident executor surface."""
129
+ return _run_request_on_executor(
130
+ sess,
131
+ ExecuteRequest(
132
+ code="",
133
+ timeout_ms=timeout_ms,
134
+ env=dict(env or {}),
135
+ task=TaskEnvelope(
136
+ site=site,
137
+ name=name,
138
+ args=dict(args or {}),
139
+ isolated=isolated,
140
+ ),
141
+ ),
142
+ )
143
+
144
+
145
+ def _run_request_on_executor(
146
+ sess,
147
+ request: ExecuteRequest,
148
+ ) -> ExecuteResponse:
149
+ """Send one request through the shared lease/reap data-plane lifecycle."""
150
+ # Validate/copy before touching the daemon or opening a socket. The
151
+ # executor validates again at the trust boundary.
152
+ request = ExecuteRequest.from_dict(request.to_dict())
153
+ sid = _session_id(sess)
154
+ # Session idle is "time since the last user/agent instruction", not
155
+ # executor process liveness. Touch before contacting the executor so a
156
+ # wedged or long-running executor cannot prevent the durable idle clock
157
+ # from reflecting that a new instruction arrived.
158
+ reg.touch(sid)
159
+ lease = _ensure_executor_lease(sess)
160
+ # The executor owns the exact outer deadline, including first-call
161
+ # cold-start. Give only a small framing/delivery margin so its terminal
162
+ # response wins the race against the local socket timeout.
163
+ recv_timeout = (
164
+ max(request.timeout_ms, 1) / 1000.0 + _RESPONSE_DELIVERY_SLACK_S
165
+ )
166
+ conn = _connect(lease.sock_path, timeout=recv_timeout)
167
+ sent = False
168
+ interrupted: BaseException | None = None
169
+ transport_error: ConnectionError | OSError | ValueError | None = None
170
+ msg: dict | None = None
171
+ try:
172
+ with _sigterm_as_system_exit():
173
+ request.executor_id = lease.executor_id
174
+ send_message(conn, request.to_dict())
175
+ sent = True
176
+ msg = recv_message(conn)
177
+ except (KeyboardInterrupt, SystemExit) as e:
178
+ interrupted = e
179
+ except (ConnectionError, OSError, ValueError) as e:
180
+ transport_error = e
181
+ finally:
182
+ try:
183
+ conn.close()
184
+ except OSError:
185
+ pass
186
+
187
+ # Close the data plane before asking the daemon to wait for process death.
188
+ if interrupted is not None:
189
+ if sent:
190
+ _best_effort_recycle(sess, lease.executor_id)
191
+ raise interrupted
192
+ if transport_error is not None:
193
+ recycle_error: Exception | None = None
194
+ if sent:
195
+ try:
196
+ _confirm_recycled(sess, lease.executor_id)
197
+ except Exception as cleanup_error: # noqa: BLE001
198
+ recycle_error = cleanup_error
199
+ suffix = (
200
+ f"; executor reap could not be confirmed: {recycle_error}"
201
+ if recycle_error is not None
202
+ else ""
203
+ )
204
+ raise ExecutorUnavailable(
205
+ f"executor data-plane error on {lease.sock_path!r}: "
206
+ f"{transport_error}{suffix}"
207
+ ) from transport_error
208
+ if msg is None:
209
+ raise ExecutorUnavailable(
210
+ f"executor returned no response on {lease.sock_path!r}"
211
+ )
212
+ try:
213
+ response = ExecuteResponse.from_dict(msg)
214
+ except (TypeError, ValueError) as e:
215
+ try:
216
+ _confirm_recycled(sess, lease.executor_id)
217
+ except Exception as cleanup_error: # noqa: BLE001
218
+ raise ExecutorUnavailable(
219
+ "executor returned a malformed response and its reap could "
220
+ f"not be confirmed: {cleanup_error}"
221
+ ) from e
222
+ raise ExecutorUnavailable(
223
+ f"executor returned a malformed response: {e}"
224
+ ) from e
225
+ if response.terminal_reason is not None:
226
+ _confirm_recycled(sess, lease.executor_id)
227
+ return response
228
+
229
+
230
+ def _confirm_recycled(sess, executor_id: str) -> dict:
231
+ """Ask the daemon to reap one exact executor and wait for process death."""
232
+ sid = _session_id(sess)
233
+ result = sess.cdp.send(
234
+ "BrowserwrightDaemon.killExecutor",
235
+ bsSession=sid,
236
+ executorId=executor_id,
237
+ wait=True,
238
+ )
239
+ if not isinstance(result, dict) or result.get("reaped") is not True:
240
+ raise ExecutorUnavailable(
241
+ f"daemon did not confirm executor {executor_id!r} was reaped: {result!r}"
242
+ )
243
+ return result
244
+
245
+
246
+ def _best_effort_recycle(sess, executor_id: str) -> None:
247
+ try:
248
+ _confirm_recycled(sess, executor_id)
249
+ except BaseException: # noqa: BLE001 - cannot mask the original interrupt
250
+ return
251
+
252
+
253
+ @contextmanager
254
+ def _sigterm_as_system_exit() -> Iterator[None]:
255
+ """Make SIGTERM unwind the blocking recv so exact-instance reap is tried."""
256
+ if (
257
+ not hasattr(signal, "SIGTERM")
258
+ or threading.current_thread() is not threading.main_thread()
259
+ ):
260
+ yield
261
+ return
262
+ previous = signal.getsignal(signal.SIGTERM)
263
+
264
+ def _handle(signum, _frame):
265
+ raise SystemExit(128 + int(signum))
266
+
267
+ try:
268
+ signal.signal(signal.SIGTERM, _handle)
269
+ yield
270
+ finally:
271
+ signal.signal(signal.SIGTERM, previous)
272
+
273
+
274
+ def _connect(sock_path: str, *, timeout: float = 30.0,
275
+ retry_until: float = 5.0) -> socket.socket:
276
+ """Connect the executor's unix socket, briefly retrying a not-yet-bound
277
+ socket (the daemon returns the path the moment it spawns; the bind may race
278
+ by a few ms)."""
279
+ deadline = time.monotonic() + retry_until
280
+ last: OSError | None = None
281
+ while True:
282
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
283
+ s.settimeout(timeout)
284
+ try:
285
+ s.connect(sock_path)
286
+ return s
287
+ except OSError as e:
288
+ last = e
289
+ s.close()
290
+ if time.monotonic() >= deadline:
291
+ raise ExecutorUnavailable(
292
+ f"could not connect executor socket {sock_path!r}: {e}"
293
+ ) from last
294
+ time.sleep(0.05)
295
+
296
+
297
+ def _session_id(sess) -> str:
298
+ rec = getattr(sess, "session_record", None)
299
+ if isinstance(rec, dict) and rec.get("id"):
300
+ return str(rec["id"])
301
+ raise ExecutorUnavailable("no session id bound; cannot reach an executor")