browserwright 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. browserwright-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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]