browserwright 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
browserwright/session.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Per-process Skill session.
|
|
2
|
+
|
|
3
|
+
Holds:
|
|
4
|
+
- one ``ModeBClient`` (long-lived daemon socket)
|
|
5
|
+
- one ``CDPSession`` (lazy: opened on first primitive that touches the browser)
|
|
6
|
+
- the currently-attached target id (the "current tab")
|
|
7
|
+
|
|
8
|
+
Concurrency model (v0.3 prep)
|
|
9
|
+
-----------------------------
|
|
10
|
+
|
|
11
|
+
Primitives reach the session through ``current_session()``. Historically this
|
|
12
|
+
returned a process-wide singleton — fine for REPL / inline / single-task
|
|
13
|
+
flows because there's only one Chrome ws to multiplex.
|
|
14
|
+
|
|
15
|
+
Layer 3 wants to fan out multiple tasks concurrently against the same daemon.
|
|
16
|
+
If two tasks ran in the same process they'd race over ``current_target_id``
|
|
17
|
+
(one task's ``new_tab`` would yank the other's attached tab). So v0.3 adds:
|
|
18
|
+
|
|
19
|
+
* ``Session`` instances per task, isolated state, no shared mutable surface
|
|
20
|
+
beyond the daemon CDP transport (which is already thread-safe).
|
|
21
|
+
* ``with_session(sess)`` context manager that pushes ``sess`` onto a
|
|
22
|
+
``ContextVar`` for the duration of the ``with`` block. Threads that enter
|
|
23
|
+
the context see *that* session via ``current_session()``; outside the
|
|
24
|
+
context they see the default singleton.
|
|
25
|
+
|
|
26
|
+
The default singleton stays — REPL / inline never need a fresh session and
|
|
27
|
+
overriding it would force callers to pass it through explicitly. The
|
|
28
|
+
``ContextVar`` is the override knob.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import contextvars
|
|
33
|
+
import os
|
|
34
|
+
import threading
|
|
35
|
+
from contextlib import contextmanager
|
|
36
|
+
from typing import Iterator, Optional
|
|
37
|
+
|
|
38
|
+
from .cdp import CDPSession
|
|
39
|
+
from .errors import DaemonUnavailable
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Session:
|
|
43
|
+
def __init__(self, daemon=None, *, record=None):
|
|
44
|
+
# ``daemon`` is a ``ModeBClient`` (long-lived socket), exposing
|
|
45
|
+
# ``resolve_ws_url`` / ``ws_url`` semantics through the methods we use
|
|
46
|
+
# below. (Mode A — the subprocess resolver — was removed.)
|
|
47
|
+
#
|
|
48
|
+
# ``record`` is a resolved session ledger record. There is one global
|
|
49
|
+
# daemon on a fixed socket; the session's ``id`` is carried as the ws
|
|
50
|
+
# client label (``skill-s<id>``) so the daemon routes this client to the
|
|
51
|
+
# session's UpstreamContext (per-session isolation lives daemon-side now).
|
|
52
|
+
# Stored as ``session_record`` to avoid shadowing the ``record()`` method.
|
|
53
|
+
self.session_record = record
|
|
54
|
+
if daemon is None:
|
|
55
|
+
if record is None:
|
|
56
|
+
from .errors import NoSession
|
|
57
|
+
raise NoSession(
|
|
58
|
+
"no session bound: a Session needs an explicit ledger "
|
|
59
|
+
"record (its backend/daemon comes from `session new`). "
|
|
60
|
+
"Pass record=/daemon= explicitly."
|
|
61
|
+
)
|
|
62
|
+
from .mode_b_client import client_for_session
|
|
63
|
+
daemon = client_for_session(record)
|
|
64
|
+
self.daemon = daemon
|
|
65
|
+
self._cdp: Optional[CDPSession] = None
|
|
66
|
+
self._cdp_lock = threading.Lock()
|
|
67
|
+
self.current_target_id: Optional[str] = None
|
|
68
|
+
# Last-seen accuracy from getActiveTab for warn-on-stale UX.
|
|
69
|
+
self.last_active_tab: Optional[dict] = None
|
|
70
|
+
# Caches keyed by host name → memory dict for performance.
|
|
71
|
+
self._site_mem_cache: dict[str, dict] = {}
|
|
72
|
+
# ``BS_HOME`` resolved once.
|
|
73
|
+
self.home = os.path.expanduser(os.environ.get("BS_HOME", "~/.browserwright"))
|
|
74
|
+
# Whether this Session was created for an isolated scope (with_session
|
|
75
|
+
# / task_runner per-task). Affects close(): we leave shared CDP
|
|
76
|
+
# transports alone, but isolated ones get their CDP closed too if it
|
|
77
|
+
# was opened just for this scope.
|
|
78
|
+
self._owns_cdp = True
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def cdp(self) -> CDPSession:
|
|
82
|
+
if self._cdp is not None and not self._cdp._closed:
|
|
83
|
+
return self._cdp
|
|
84
|
+
with self._cdp_lock:
|
|
85
|
+
if self._cdp is not None and not self._cdp._closed:
|
|
86
|
+
return self._cdp
|
|
87
|
+
url = self._resolve_ws_url()
|
|
88
|
+
self._cdp = CDPSession(url)
|
|
89
|
+
return self._cdp
|
|
90
|
+
|
|
91
|
+
def _resolve_ws_url(self) -> str:
|
|
92
|
+
"""Ask the underlying daemon client for a CDP ws URL.
|
|
93
|
+
|
|
94
|
+
``ModeBClient.resolve_ws_url`` aliases ``ws_url()``. On failure, retry
|
|
95
|
+
once after dropping the cached URL — spec §D.7.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
return self.daemon.resolve_ws_url()
|
|
99
|
+
except DaemonUnavailable:
|
|
100
|
+
self.daemon.invalidate()
|
|
101
|
+
return self.daemon.resolve_ws_url()
|
|
102
|
+
|
|
103
|
+
def close(self) -> None:
|
|
104
|
+
if self._cdp is not None and self._owns_cdp:
|
|
105
|
+
self._cdp.close()
|
|
106
|
+
self._cdp = None
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def backend_name(self) -> str:
|
|
110
|
+
"""Lazy-resolved daemon backend name (``"rdp"``, ``"extension"``, …).
|
|
111
|
+
|
|
112
|
+
Diagnostics only — the downstream API is unified, so primitives no
|
|
113
|
+
longer branch on the backend (backend divergence is absorbed daemon-side;
|
|
114
|
+
see docs/refactor-single-daemon.md). Surfaced for doctor / debugging.
|
|
115
|
+
Falls back to ``""`` when the daemon doesn't surface backend info.
|
|
116
|
+
"""
|
|
117
|
+
cached = getattr(self, "_backend_name_cache", None)
|
|
118
|
+
if cached is not None:
|
|
119
|
+
return cached
|
|
120
|
+
if isinstance(self.session_record, dict):
|
|
121
|
+
name = self.session_record.get("backend") or ""
|
|
122
|
+
if name:
|
|
123
|
+
self._backend_name_cache = name # type: ignore[attr-defined]
|
|
124
|
+
return name
|
|
125
|
+
info = None
|
|
126
|
+
getter = getattr(self.daemon, "get_backend_info", None)
|
|
127
|
+
if callable(getter):
|
|
128
|
+
# Narrow the catch to the failure modes the underlying clients
|
|
129
|
+
# actually surface: ModeBClient.get_backend_info wraps subprocess
|
|
130
|
+
# plumbing (FileNotFoundError, TimeoutExpired) and JSON parsing.
|
|
131
|
+
# OSError covers low-level I/O. AttributeError absorbs a missing
|
|
132
|
+
# inner shim rather than letting an internal bug crash backend-name
|
|
133
|
+
# resolution. Truly unexpected exceptions propagate.
|
|
134
|
+
import json as _json
|
|
135
|
+
import subprocess as _subprocess
|
|
136
|
+
try:
|
|
137
|
+
info = getter()
|
|
138
|
+
except (AttributeError, OSError,
|
|
139
|
+
_subprocess.CalledProcessError, _subprocess.TimeoutExpired,
|
|
140
|
+
_json.JSONDecodeError):
|
|
141
|
+
info = None
|
|
142
|
+
name = ""
|
|
143
|
+
if isinstance(info, dict):
|
|
144
|
+
name = info.get("backend") or info.get("name") or ""
|
|
145
|
+
self._backend_name_cache = name # type: ignore[attr-defined]
|
|
146
|
+
return name
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---- singleton + context-var override ---------------------------------
|
|
150
|
+
|
|
151
|
+
# Module-level default. Lazily created on first ``current_session()`` call
|
|
152
|
+
# from a context that hasn't pushed an override. Lives for the process.
|
|
153
|
+
_singleton: Optional[Session] = None
|
|
154
|
+
_singleton_lock = threading.Lock()
|
|
155
|
+
|
|
156
|
+
# ContextVar holds the currently-active Session for this thread / task.
|
|
157
|
+
# ``None`` means "no override → use the default singleton".
|
|
158
|
+
_active: contextvars.ContextVar[Optional[Session]] = contextvars.ContextVar(
|
|
159
|
+
"browserwright_session", default=None
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def current_session() -> Session:
|
|
164
|
+
"""Return the Session bound to the current execution context.
|
|
165
|
+
|
|
166
|
+
Resolution order:
|
|
167
|
+
1. The ``ContextVar`` push (most recent ``with_session(...)`` block).
|
|
168
|
+
2. The process-wide default singleton, bound by ``set_session()``.
|
|
169
|
+
|
|
170
|
+
There is no env-guessed default: a Session's backend/daemon comes from an
|
|
171
|
+
explicit ledger record. Entry points bind one via
|
|
172
|
+
``set_session(Session(record=...))`` before primitives run. Calling this
|
|
173
|
+
with nothing bound raises ``NoSession`` from ``Session.__init__``.
|
|
174
|
+
"""
|
|
175
|
+
override = _active.get()
|
|
176
|
+
if override is not None:
|
|
177
|
+
return override
|
|
178
|
+
global _singleton
|
|
179
|
+
if _singleton is None:
|
|
180
|
+
with _singleton_lock:
|
|
181
|
+
if _singleton is None:
|
|
182
|
+
_singleton = Session()
|
|
183
|
+
return _singleton
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def set_session(sess: Optional[Session]) -> None:
|
|
187
|
+
"""Bind the process-wide default Session. The CLI entry point calls
|
|
188
|
+
this with a record-bound Session; tests use it to install a mock. Pushing
|
|
189
|
+
via ``with_session()`` is preferred when the override should be scoped."""
|
|
190
|
+
global _singleton
|
|
191
|
+
_singleton = sess
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def isolated_session() -> Session:
|
|
195
|
+
"""A fresh Session for fan-out / isolated task runs.
|
|
196
|
+
|
|
197
|
+
Inherits the current session's daemon binding (so it drives the *same*
|
|
198
|
+
browser) but isolates target tracking (its own ``current_target_id``), so
|
|
199
|
+
concurrent tasks don't yank each other's attached tab. Prefers the ledger
|
|
200
|
+
record (own client connection) and falls back to sharing the parent's
|
|
201
|
+
daemon when the parent was constructed without a record (tests)."""
|
|
202
|
+
parent = current_session()
|
|
203
|
+
if parent.session_record is not None:
|
|
204
|
+
return Session(record=parent.session_record)
|
|
205
|
+
return Session(daemon=parent.daemon)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@contextmanager
|
|
209
|
+
def with_session(sess: Session) -> Iterator[Session]:
|
|
210
|
+
"""Run a block with ``sess`` as the active session.
|
|
211
|
+
|
|
212
|
+
Pattern (per-task isolation)::
|
|
213
|
+
|
|
214
|
+
from browserwright.session import isolated_session, with_session
|
|
215
|
+
with with_session(isolated_session()) as sess:
|
|
216
|
+
goto_url("https://example.com")
|
|
217
|
+
# this block's primitives operate on `sess`, not the default
|
|
218
|
+
# outside the `with`, primitives revert to the default singleton.
|
|
219
|
+
|
|
220
|
+
The session's CDP transport is *not* closed automatically — callers that
|
|
221
|
+
spin a Session for a one-shot task should call ``sess.close()`` after
|
|
222
|
+
the ``with``. This split exists because most callers want to reuse one
|
|
223
|
+
transport across many ``with_session`` blocks (Layer 3 task pool).
|
|
224
|
+
"""
|
|
225
|
+
token = _active.set(sess)
|
|
226
|
+
try:
|
|
227
|
+
yield sess
|
|
228
|
+
finally:
|
|
229
|
+
_active.reset(token)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Session creation/teardown per backend.
|
|
2
|
+
|
|
3
|
+
Creation is **explicit**: an agent picks ``extension`` / ``rdp --create`` /
|
|
4
|
+
``rdp --attach``. This module allocates the ledger entry and makes sure the
|
|
5
|
+
ONE global daemon is running.
|
|
6
|
+
|
|
7
|
+
Single-daemon model (docs/refactor-single-daemon.md §P3): there is exactly one
|
|
8
|
+
global daemon on a fixed socket (no ``--name`` / ``BD_NAME``). It serves both
|
|
9
|
+
backends simultaneously, routing per session. For rdp the daemon itself launches
|
|
10
|
+
and owns the per-session Chrome on ``ensureSession`` and tears it down on
|
|
11
|
+
``endSession`` — this module no longer spawns a per-session daemon or launches
|
|
12
|
+
Chrome directly. ``new()`` only:
|
|
13
|
+
- allocates the ledger entry (recording the chosen port in ``workspace`` so
|
|
14
|
+
the daemon pins the rdp Chrome to it), and
|
|
15
|
+
- ensures the single daemon is up.
|
|
16
|
+
|
|
17
|
+
Teardown talks to the single daemon via the ``browserwright-daemon`` CLI
|
|
18
|
+
(``end-session`` / ``disconnect``), which the daemon already understands — no
|
|
19
|
+
``--name`` is passed anymore.
|
|
20
|
+
|
|
21
|
+
Ownership rule: who ``create``s, closes; ``attach`` only reminds.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import socket
|
|
26
|
+
import subprocess
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from . import session_registry as reg
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _free_port() -> int:
|
|
33
|
+
"""Ask the OS for an unused localhost TCP port."""
|
|
34
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
35
|
+
try:
|
|
36
|
+
s.bind(("127.0.0.1", 0))
|
|
37
|
+
return s.getsockname()[1]
|
|
38
|
+
finally:
|
|
39
|
+
s.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _spawn_detached(cmd: list[str]) -> int:
|
|
43
|
+
"""Start a long-lived background process detached from this one; return pid."""
|
|
44
|
+
proc = subprocess.Popen(
|
|
45
|
+
cmd,
|
|
46
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
47
|
+
stdin=subprocess.DEVNULL, start_new_session=True,
|
|
48
|
+
)
|
|
49
|
+
return proc.pid
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run(cmd: list[str]) -> int:
|
|
53
|
+
"""Run a short-lived command; return its exit code (best-effort)."""
|
|
54
|
+
try:
|
|
55
|
+
return subprocess.run(cmd, capture_output=True, timeout=10).returncode
|
|
56
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _ensure_daemon_running() -> None:
|
|
61
|
+
"""Make sure the ONE global daemon is up; spawn ``serve`` detached if not.
|
|
62
|
+
|
|
63
|
+
There is no ``--name`` anymore — a single fixed-socket daemon serves every
|
|
64
|
+
session. ``serve`` itself stale-detects an already-running daemon and exits
|
|
65
|
+
1, so spawning unconditionally is safe (a redundant spawn is a no-op), but
|
|
66
|
+
we ping first to avoid the churn.
|
|
67
|
+
"""
|
|
68
|
+
from .daemon import _ipc
|
|
69
|
+
from .version import package_version
|
|
70
|
+
try:
|
|
71
|
+
pid, running_version = _ipc.ping_status_sync(timeout=1.0)
|
|
72
|
+
if pid is not None and running_version == package_version():
|
|
73
|
+
return # already running the installed version
|
|
74
|
+
if pid is not None:
|
|
75
|
+
_run(["browserwright-daemon", "stop"])
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
_spawn_detached(["browserwright-daemon", "serve"])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _close_browser(record: dict) -> None:
|
|
82
|
+
"""Tear down a create-owned rdp session's browser via the single daemon.
|
|
83
|
+
|
|
84
|
+
The daemon owns the per-session Chrome (launched on ``ensureSession``), so
|
|
85
|
+
``endSession`` closes the upstream + SIGTERMs that Chrome + drops the
|
|
86
|
+
context. Best-effort: a dead daemon just means the (ephemeral, C2) Chrome
|
|
87
|
+
already died with it. Only create-owned sessions reach here — attach
|
|
88
|
+
sessions never launched a browser we own."""
|
|
89
|
+
sid = record.get("id")
|
|
90
|
+
if sid:
|
|
91
|
+
_run(["browserwright-daemon", "end-session", "--session", str(sid)])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _reap_executor(record: dict) -> None:
|
|
95
|
+
"""Best-effort: reap this session's resident Phase B executor (no browser
|
|
96
|
+
teardown). Called for EVERY owner on `end()` so an attach session's
|
|
97
|
+
long-lived executor subprocess doesn't leak — the full `endSession` path is
|
|
98
|
+
create-only and would also close the browser an attach session must keep.
|
|
99
|
+
|
|
100
|
+
Best-effort by contract: a dead daemon / no-executor / stale binary all
|
|
101
|
+
return non-zero from `_run`, which we ignore — `session end` must never fail
|
|
102
|
+
because the executor couldn't be reaped (the orphan-sweep on the next daemon
|
|
103
|
+
start is the backstop)."""
|
|
104
|
+
sid = record.get("id")
|
|
105
|
+
if sid:
|
|
106
|
+
_run(["browserwright-daemon", "kill-executor", "--session", str(sid)])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def reset_executor(record: dict) -> str:
|
|
110
|
+
"""Recycle only this session's resident executor.
|
|
111
|
+
|
|
112
|
+
The session ledger entry, browser, context, tabs, and ownership semantics
|
|
113
|
+
are intentionally left intact. The next ``browserwright -s <id> -e ...``
|
|
114
|
+
call cold-starts a fresh executor against the same session.
|
|
115
|
+
"""
|
|
116
|
+
_ensure_daemon_running()
|
|
117
|
+
_reap_executor(record)
|
|
118
|
+
sid = record["id"]
|
|
119
|
+
return (
|
|
120
|
+
f"session {sid} reset; executor was recycled. "
|
|
121
|
+
"The browser and tabs were left untouched."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def reap(*, idle_seconds: float) -> list[dict]:
|
|
126
|
+
"""Prune idle sessions; for create-owned ones, also tear down the browser
|
|
127
|
+
the daemon launched. Returns the pruned records."""
|
|
128
|
+
pruned = reg.prune(idle_seconds=idle_seconds)
|
|
129
|
+
for rec in pruned:
|
|
130
|
+
if rec.get("owner") == "create":
|
|
131
|
+
_close_browser(rec)
|
|
132
|
+
return pruned
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def new(*, backend: str, create: bool = False, attach: Optional[object] = None,
|
|
136
|
+
name: Optional[str] = None) -> str:
|
|
137
|
+
"""Register a session and return its id.
|
|
138
|
+
|
|
139
|
+
- ``extension`` → an *attach* session sharing the one global daemon's
|
|
140
|
+
relay-backed upstream; the tab group is created lazily on first use, so
|
|
141
|
+
``workspace`` is None.
|
|
142
|
+
- ``rdp --create`` → owns an isolated browser the daemon launches on
|
|
143
|
+
``ensureSession``. We pick a free port now and record it in ``workspace``
|
|
144
|
+
so the daemon pins the per-session Chrome to it.
|
|
145
|
+
- ``rdp --attach <target>`` → attaches to an already-running browser; the
|
|
146
|
+
target (port) is recorded and the browser is left alone on end.
|
|
147
|
+
|
|
148
|
+
In every case we only allocate the ledger entry + ensure the one daemon is
|
|
149
|
+
running. The daemon does the Chrome launch on ``ensureSession``.
|
|
150
|
+
"""
|
|
151
|
+
name = name.strip() if isinstance(name, str) else None
|
|
152
|
+
if not name:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"session new requires --name=NAME — a short label (e.g. "
|
|
155
|
+
"--name=cf-bots). For extension sessions this becomes the Chrome "
|
|
156
|
+
"tab group title; for RDP sessions it labels the isolated browser "
|
|
157
|
+
"session. It need not be unique."
|
|
158
|
+
)
|
|
159
|
+
if backend == "extension":
|
|
160
|
+
sid = reg.allocate(backend="extension",
|
|
161
|
+
owner="attach", name=name)
|
|
162
|
+
_ensure_daemon_running()
|
|
163
|
+
return sid
|
|
164
|
+
if backend == "rdp":
|
|
165
|
+
owner = "create" if create else "attach"
|
|
166
|
+
# workspace["port"]: for --create pick a free port the daemon launches
|
|
167
|
+
# Chrome on; for --attach record the target port the daemon resolves.
|
|
168
|
+
if create:
|
|
169
|
+
workspace = {"port": _free_port()}
|
|
170
|
+
elif attach is not None:
|
|
171
|
+
workspace = {"port": int(attach), "target": attach}
|
|
172
|
+
else:
|
|
173
|
+
workspace = None
|
|
174
|
+
sid = reg.allocate(backend="rdp", owner=owner,
|
|
175
|
+
name=name, workspace=workspace)
|
|
176
|
+
_ensure_daemon_running()
|
|
177
|
+
return sid
|
|
178
|
+
raise ValueError(f"unknown backend {backend!r} (use extension|rdp)")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def choose(situation: str) -> dict:
|
|
182
|
+
"""Decide how to start a session for ``situation``.
|
|
183
|
+
|
|
184
|
+
Hit → return the recorded decision (auto-start). Miss → raise
|
|
185
|
+
:class:`NeedsUserConfirm` carrying a proposal that lists the three modes,
|
|
186
|
+
so the agent asks the user and then records the answer.
|
|
187
|
+
"""
|
|
188
|
+
from .errors import NeedsUserConfirm
|
|
189
|
+
from .memory import session_decisions
|
|
190
|
+
|
|
191
|
+
hit = session_decisions.lookup(situation)
|
|
192
|
+
if hit is not None:
|
|
193
|
+
return hit
|
|
194
|
+
raise NeedsUserConfirm(
|
|
195
|
+
what=f"how to start a browser session for: {situation}",
|
|
196
|
+
proposal={
|
|
197
|
+
"situation": situation,
|
|
198
|
+
"options": [
|
|
199
|
+
{"backend": "extension", "mode": "attach",
|
|
200
|
+
"desc": "drive the user's everyday Chrome via the extension (shared)"},
|
|
201
|
+
{"backend": "rdp", "mode": "create",
|
|
202
|
+
"desc": "launch a fresh isolated Chrome the session owns"},
|
|
203
|
+
{"backend": "rdp", "mode": "attach", "target": "<port|recipe>",
|
|
204
|
+
"desc": "attach to an already-running browser (e.g. a fingerprint browser)"},
|
|
205
|
+
],
|
|
206
|
+
"after_choice": "record it via memory.session_decisions.record(situation, decision)",
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _end_extension_workspace(record: dict) -> None:
|
|
212
|
+
"""Close the session's agent-owned extension tabs via the single daemon.
|
|
213
|
+
|
|
214
|
+
Best-effort: the shared browser itself stays open (extension sessions are
|
|
215
|
+
attach-owned); only the tabs this session opened are closed. The
|
|
216
|
+
``end-session`` CLI no longer takes ``--name`` — there is one daemon."""
|
|
217
|
+
cmd = ["browserwright-daemon", "end-session", "--session", record["id"]]
|
|
218
|
+
# Thread the durable numeric groupId (persisted in ledger.runtime on every
|
|
219
|
+
# open) so the daemon can close the whole group even when its in-memory
|
|
220
|
+
# binding was wiped (restart). The title is not used — names aren't unique.
|
|
221
|
+
runtime = record.get("runtime") or {}
|
|
222
|
+
gid = runtime.get("group_id")
|
|
223
|
+
if isinstance(gid, int) and gid >= 0:
|
|
224
|
+
cmd += ["--group-id", str(gid)]
|
|
225
|
+
_run(cmd)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def end(record: dict) -> str:
|
|
229
|
+
"""Tear down a session honoring ownership. Returns a human-readable line.
|
|
230
|
+
|
|
231
|
+
create-owned → the daemon closes the browser it launched (endSession).
|
|
232
|
+
attach → leave the browser running, remind the user.
|
|
233
|
+
extension → also close the session's agent-owned tabs (browser stays).
|
|
234
|
+
Always removes the ledger entry.
|
|
235
|
+
"""
|
|
236
|
+
sid = record["id"]
|
|
237
|
+
if record.get("backend") == "extension":
|
|
238
|
+
_end_extension_workspace(record)
|
|
239
|
+
if record.get("owner") == "create":
|
|
240
|
+
# `_close_browser` → daemon `endSession`, which ALSO kills the executor
|
|
241
|
+
# (symmetric in `_handle_end_session`), so no separate reap needed here.
|
|
242
|
+
_close_browser(record)
|
|
243
|
+
msg = f"session {sid} ended; the browser it launched was closed."
|
|
244
|
+
else:
|
|
245
|
+
# attach: leave the browser running (semantics unchanged) but still reap
|
|
246
|
+
# the session's resident executor so it doesn't leak — `endSession` is
|
|
247
|
+
# create-only and the attach path never otherwise contacts the daemon.
|
|
248
|
+
_reap_executor(record)
|
|
249
|
+
msg = (f"session {sid} ended. The browser is still running — you "
|
|
250
|
+
f"attached to it, so it was left untouched.")
|
|
251
|
+
reg.remove(sid)
|
|
252
|
+
return msg
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Resolve an explicitly requested session record (P1)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from . import session_registry as reg
|
|
7
|
+
from .errors import NoSession
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_session(session_id: Optional[str] = None) -> dict:
|
|
11
|
+
"""Return the ledger record for the current session.
|
|
12
|
+
|
|
13
|
+
Raises :class:`NoSession` when no id is provided or the id is unknown.
|
|
14
|
+
On success, bumps the record's ``last_seen`` and returns it.
|
|
15
|
+
"""
|
|
16
|
+
raw = session_id
|
|
17
|
+
sid = str(raw) if raw not in (None, "") else ""
|
|
18
|
+
if not sid:
|
|
19
|
+
raise NoSession()
|
|
20
|
+
rec = reg.get(sid)
|
|
21
|
+
if rec is None:
|
|
22
|
+
raise NoSession(f"unknown session id {sid!r} (not in ledger).")
|
|
23
|
+
reg.touch(sid)
|
|
24
|
+
return rec
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""File-locked session ledger: short id → session record (P1 isolation key)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fcntl
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Iterator, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _home() -> Path:
|
|
14
|
+
return Path(os.path.expanduser(os.environ.get("BS_HOME", "~/.browserwright")))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _dir() -> Path:
|
|
18
|
+
d = _home() / "sessions"
|
|
19
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ledger_path() -> Path:
|
|
24
|
+
return _dir() / "ledger.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def _locked() -> Iterator[dict]:
|
|
29
|
+
"""Exclusive flock around a read-modify-write of the ledger."""
|
|
30
|
+
lock = _dir() / ".lock"
|
|
31
|
+
with open(lock, "w") as lf:
|
|
32
|
+
fcntl.flock(lf, fcntl.LOCK_EX)
|
|
33
|
+
try:
|
|
34
|
+
p = _ledger_path()
|
|
35
|
+
data = json.loads(p.read_text()) if p.exists() else {"next_id": 1, "sessions": {}}
|
|
36
|
+
yield data
|
|
37
|
+
p.write_text(json.dumps(data))
|
|
38
|
+
finally:
|
|
39
|
+
fcntl.flock(lf, fcntl.LOCK_UN)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def allocate(*, backend: str, owner: str,
|
|
43
|
+
workspace: Optional[object] = None, name: Optional[str] = None,
|
|
44
|
+
unique_name: bool = False) -> str:
|
|
45
|
+
now = time.time()
|
|
46
|
+
with _locked() as data:
|
|
47
|
+
if unique_name:
|
|
48
|
+
# Globally-unique name guard. Raising here (after the `yield` in
|
|
49
|
+
# _locked) aborts before `p.write_text`, so a rejected allocation
|
|
50
|
+
# leaves the ledger untouched.
|
|
51
|
+
for e in data["sessions"].values():
|
|
52
|
+
if e.get("name") == name:
|
|
53
|
+
conflict = e.get("id")
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"session name {name!r} is already taken by session "
|
|
56
|
+
f"{conflict!r}. Names must be globally unique. Either "
|
|
57
|
+
f"pick a different --name, reuse the existing session "
|
|
58
|
+
f"with `browserwright -s {conflict} -e ...`, or "
|
|
59
|
+
f"end it first: browserwright session end "
|
|
60
|
+
f"--session={conflict}"
|
|
61
|
+
)
|
|
62
|
+
sid = str(data["next_id"])
|
|
63
|
+
data["next_id"] += 1
|
|
64
|
+
data["sessions"][sid] = {
|
|
65
|
+
"id": sid, "backend": backend,
|
|
66
|
+
"workspace": workspace, "owner": owner, "name": name,
|
|
67
|
+
"created_at": now, "last_seen": now,
|
|
68
|
+
}
|
|
69
|
+
return sid
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get(session_id: str) -> Optional[dict]:
|
|
73
|
+
p = _ledger_path()
|
|
74
|
+
if not p.exists():
|
|
75
|
+
return None
|
|
76
|
+
return json.loads(p.read_text())["sessions"].get(session_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _with_entry(session_id: str, fn: Callable[[dict], object]) -> Optional[dict]:
|
|
80
|
+
"""Apply ``fn`` to a session entry in-place under the lock; return the entry."""
|
|
81
|
+
with _locked() as data:
|
|
82
|
+
entry = data["sessions"].get(session_id)
|
|
83
|
+
if entry is None:
|
|
84
|
+
return None
|
|
85
|
+
fn(entry)
|
|
86
|
+
return entry
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def touch(session_id: str) -> Optional[dict]:
|
|
90
|
+
"""Bump ``last_seen`` to now."""
|
|
91
|
+
now = time.time()
|
|
92
|
+
return _with_entry(session_id, lambda e: e.update(last_seen=now))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def update(session_id: str, **fields) -> Optional[dict]:
|
|
96
|
+
"""Patch fields on a session record.
|
|
97
|
+
|
|
98
|
+
``backend`` is fixed at creation and immutable for the session's whole life
|
|
99
|
+
(single-daemon refactor, decision 2): a change to a DIFFERENT backend is
|
|
100
|
+
rejected. Raising before ``e.update`` (and before ``_locked``'s post-yield
|
|
101
|
+
``write_text``) leaves the ledger untouched. A no-op same-value patch is
|
|
102
|
+
allowed so callers that re-write the whole record don't trip the guard."""
|
|
103
|
+
def _patch(e: dict) -> None:
|
|
104
|
+
if "backend" in fields and fields["backend"] != e.get("backend"):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"session {session_id!r} backend is immutable: refusing to "
|
|
107
|
+
f"change {e.get('backend')!r} → {fields['backend']!r}")
|
|
108
|
+
e.update(**fields)
|
|
109
|
+
return _with_entry(session_id, _patch)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def remove(session_id: str) -> Optional[dict]:
|
|
113
|
+
"""Drop a session from the ledger; return the removed record (or None)."""
|
|
114
|
+
with _locked() as data:
|
|
115
|
+
return data["sessions"].pop(session_id, None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def list_all() -> list[dict]:
|
|
119
|
+
"""All session records, ordered by id."""
|
|
120
|
+
p = _ledger_path()
|
|
121
|
+
if not p.exists():
|
|
122
|
+
return []
|
|
123
|
+
sessions = json.loads(p.read_text())["sessions"]
|
|
124
|
+
return [sessions[k] for k in sorted(sessions, key=int)]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def prune(*, idle_seconds: float) -> list[dict]:
|
|
128
|
+
"""Remove sessions idle longer than ``idle_seconds``; return removed records."""
|
|
129
|
+
now = time.time()
|
|
130
|
+
with _locked() as data:
|
|
131
|
+
stale = [sid for sid, e in data["sessions"].items()
|
|
132
|
+
if now - e.get("last_seen", 0.0) >= idle_seconds]
|
|
133
|
+
return [data["sessions"].pop(sid) for sid in stale]
|