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,234 @@
1
+ """Platform-specific Chrome / Chromium-family profile locations.
2
+
3
+ This table is sourced verbatim from browser-harness `daemon.py:36-65` — the spec
4
+ (§8.3) is explicit: "this table is fought for, do not reinvent it." Any addition
5
+ should match a real installed browser and ship with a smoke test.
6
+
7
+ Covers macOS, Linux, Linux Flatpak, Windows × Chrome (Stable/Canary) / Chromium
8
+ / Edge (Stable/Beta/Dev/Canary) / Brave / Arc / Dia / Comet.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import platform
14
+ import shutil
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+
19
+ def proc_start_time(pid: int) -> str | None:
20
+ """Best-effort process start-time fingerprint, used to detect PID reuse
21
+ before signalling a daemon (see ``cli._cmd_stop``).
22
+
23
+ Returns an opaque but *stable* string identifying when the process started,
24
+ or ``None`` when the platform can't answer (no such pid, or unsupported) —
25
+ callers must treat ``None`` as "can't verify" and fall back, never as a
26
+ match.
27
+
28
+ - Linux: field 22 (``starttime``, in clock ticks since boot) of
29
+ ``/proc/<pid>/stat``.
30
+ - macOS / BSD: ``ps -o lstart= -p <pid>`` (a stable wall-clock string).
31
+ """
32
+ # Linux fast path — no subprocess.
33
+ try:
34
+ stat = Path(f"/proc/{pid}/stat")
35
+ if stat.exists():
36
+ data = stat.read_text()
37
+ # comm (field 2) is parenthesised and may contain spaces/parens;
38
+ # split after the final ')' so positional fields stay aligned.
39
+ rparen = data.rfind(")")
40
+ if rparen != -1:
41
+ # After "pid (comm) " the remaining fields start at state
42
+ # (field 3). starttime is field 22 → index 22 - 3 = 19.
43
+ fields = data[rparen + 2:].split()
44
+ if len(fields) > 19:
45
+ return fields[19]
46
+ except (OSError, ValueError, IndexError):
47
+ pass
48
+ # macOS / BSD — ask ps for the start timestamp.
49
+ try:
50
+ out = subprocess.run(
51
+ ["ps", "-o", "lstart=", "-p", str(pid)],
52
+ capture_output=True, text=True, timeout=3.0)
53
+ if out.returncode == 0:
54
+ s = out.stdout.strip()
55
+ return s or None
56
+ except (OSError, subprocess.SubprocessError):
57
+ pass
58
+ return None
59
+
60
+
61
+ def profile_paths() -> list[Path]:
62
+ """The full cross-platform profile list. Order is significant only as
63
+ documentation — callers should always tie-break by mtime, never by order.
64
+ """
65
+ home = Path.home()
66
+ return [
67
+ # macOS
68
+ home / "Library/Application Support/Google/Chrome",
69
+ home / "Library/Application Support/Google/Chrome Canary",
70
+ home / "Library/Application Support/Comet",
71
+ home / "Library/Application Support/Arc/User Data",
72
+ home / "Library/Application Support/Dia/User Data",
73
+ home / "Library/Application Support/Microsoft Edge",
74
+ home / "Library/Application Support/Microsoft Edge Beta",
75
+ home / "Library/Application Support/Microsoft Edge Dev",
76
+ home / "Library/Application Support/Microsoft Edge Canary",
77
+ home / "Library/Application Support/BraveSoftware/Brave-Browser",
78
+ # Linux (native)
79
+ home / ".config/google-chrome",
80
+ home / ".config/chromium",
81
+ home / ".config/chromium-browser",
82
+ home / ".config/microsoft-edge",
83
+ home / ".config/microsoft-edge-beta",
84
+ home / ".config/microsoft-edge-dev",
85
+ # Linux (Flatpak)
86
+ home / ".var/app/org.chromium.Chromium/config/chromium",
87
+ home / ".var/app/com.google.Chrome/config/google-chrome",
88
+ home / ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser",
89
+ home / ".var/app/com.microsoft.Edge/config/microsoft-edge",
90
+ # Windows
91
+ home / "AppData/Local/Google/Chrome/User Data",
92
+ home / "AppData/Local/Google/Chrome SxS/User Data",
93
+ home / "AppData/Local/Chromium/User Data",
94
+ home / "AppData/Local/Microsoft/Edge/User Data",
95
+ home / "AppData/Local/Microsoft/Edge Beta/User Data",
96
+ home / "AppData/Local/Microsoft/Edge Dev/User Data",
97
+ home / "AppData/Local/Microsoft/Edge SxS/User Data",
98
+ home / "AppData/Local/BraveSoftware/Brave-Browser/User Data",
99
+ ]
100
+
101
+
102
+ # Likely-binary paths for `launch-chrome` (§5.5 step 1 fallback). PATH lookup is
103
+ # tried first; this list is only consulted if PATH yields nothing.
104
+ def chrome_binary_candidates() -> list[Path]:
105
+ system = platform.system()
106
+ if system == "Darwin":
107
+ return [
108
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
109
+ Path("/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"),
110
+ Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
111
+ Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
112
+ Path("/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"),
113
+ ]
114
+ if system == "Windows":
115
+ pf = os.environ.get("PROGRAMFILES", r"C:\Program Files")
116
+ pfx86 = os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")
117
+ local = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData/Local"))
118
+ return [
119
+ Path(pf) / "Google/Chrome/Application/chrome.exe",
120
+ Path(pfx86) / "Google/Chrome/Application/chrome.exe",
121
+ Path(local) / "Google/Chrome/Application/chrome.exe",
122
+ Path(pf) / "Microsoft/Edge/Application/msedge.exe",
123
+ Path(pfx86) / "Microsoft/Edge/Application/msedge.exe",
124
+ ]
125
+ # Linux + everything else
126
+ return [
127
+ Path("/usr/bin/google-chrome"),
128
+ Path("/usr/bin/google-chrome-stable"),
129
+ Path("/usr/bin/chromium"),
130
+ Path("/usr/bin/chromium-browser"),
131
+ Path("/usr/bin/microsoft-edge"),
132
+ Path("/usr/bin/brave-browser"),
133
+ Path("/snap/bin/chromium"),
134
+ ]
135
+
136
+
137
+ def _binary_is_runnable(path: Path, timeout: float = 3.0) -> bool:
138
+ """Validate a Chrome candidate by `<binary> --version` returning exit 0.
139
+
140
+ Task #12: macOS Homebrew installs of `chromium` sometimes leave a wrapper
141
+ shell script at `/opt/homebrew/bin/chromium` that exec's a non-existent
142
+ `.app` and exits 126. PATH-lookup `shutil.which("chromium")` happily
143
+ picks that up and `launch_chrome` falls into the now-fixed Bug #2 poll
144
+ race symptom WITHOUT the underlying poll-race actually being present.
145
+
146
+ Cheap (`--version` returns < 50ms in steady state) + side-effect-free
147
+ (no `--user-data-dir` involved). On a healthy install, all browser
148
+ binaries respond to `--version`. We don't parse the output — just check
149
+ the exit code.
150
+ """
151
+ import subprocess
152
+ try:
153
+ result = subprocess.run(
154
+ [str(path), "--version"],
155
+ capture_output=True,
156
+ timeout=timeout,
157
+ )
158
+ except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired):
159
+ return False
160
+ except OSError:
161
+ return False
162
+ return result.returncode == 0
163
+
164
+
165
+ def discover_chrome_binary(explicit: str | None = None) -> Path | None:
166
+ """Implements §5.5 step 1: --chrome-binary > BD_CHROME_BINARY (caller passes
167
+ in via `explicit`) > platform default `.app` list (macOS) > $PATH walk
168
+ with `--version` validation.
169
+
170
+ Returns the resolved absolute Path or None when nothing matches. Caller
171
+ decides whether to raise ChromeBinaryNotFound — keeping the policy out here
172
+ lets unit tests assert on (binary_path is None) without depending on $HOME.
173
+
174
+ Order rationale (Task #12 fix):
175
+ - macOS, prefer the real `.app` bundle paths from
176
+ `chrome_binary_candidates()` BEFORE `shutil.which` PATH lookup.
177
+ Reason: Homebrew leaves stale wrapper scripts on PATH that exit 126
178
+ (= Bug #2 symptom without the actual poll race).
179
+ - Linux + Windows, PATH is the canonical install signal; check it first
180
+ but still validate via `--version` so partial / broken installs fail
181
+ fast with a clean error.
182
+ """
183
+ if explicit:
184
+ p = Path(explicit).expanduser()
185
+ return p if p.exists() and _binary_is_runnable(p) else None
186
+
187
+ candidates: list[Path] = []
188
+ if platform.system() == "Darwin":
189
+ # macOS: real .app paths first, then PATH fallback.
190
+ candidates.extend(chrome_binary_candidates())
191
+ for name in ("google-chrome", "google-chrome-stable", "chromium",
192
+ "chromium-browser", "microsoft-edge", "brave-browser"):
193
+ if (which := shutil.which(name)):
194
+ candidates.append(Path(which))
195
+ else:
196
+ # Linux + Windows: PATH first, then platform defaults.
197
+ for name in ("google-chrome", "google-chrome-stable", "chromium",
198
+ "chromium-browser", "microsoft-edge", "brave-browser"):
199
+ if (which := shutil.which(name)):
200
+ candidates.append(Path(which))
201
+ candidates.extend(chrome_binary_candidates())
202
+
203
+ seen: set[Path] = set()
204
+ for p in candidates:
205
+ if p in seen:
206
+ continue
207
+ seen.add(p)
208
+ if not p.exists():
209
+ continue
210
+ if _binary_is_runnable(p):
211
+ return p
212
+ return None
213
+
214
+
215
+ def runtime_dir() -> Path:
216
+ """Where pid / sock files live. Spec §5.5 step 7 + §6.7.
217
+
218
+ Mirrors browser-harness _ipc.py logic — XDG_RUNTIME_DIR on POSIX, %TEMP% on
219
+ Windows, /tmp fallback (gettempdir() returns long /var/folders on macOS which
220
+ is unsafe for AF_UNIX sun_path's 104-byte budget).
221
+ """
222
+ if (xdg := os.environ.get("XDG_RUNTIME_DIR")):
223
+ return Path(xdg)
224
+ if platform.system() == "Windows":
225
+ import tempfile
226
+ return Path(tempfile.gettempdir())
227
+ return Path("/tmp")
228
+
229
+
230
+ def cache_dir() -> Path:
231
+ """Where persistent launch-chrome profiles live (§5.5 step 2)."""
232
+ if (xdg := os.environ.get("XDG_CACHE_HOME")):
233
+ return Path(xdg) / "browserwright-daemon"
234
+ return Path.home() / ".cache" / "browserwright-daemon"
@@ -0,0 +1,72 @@
1
+ """Mode A URL resolver.
2
+
3
+ Spec §5.1: walk the backend chain, return the first that yields a ResolveResult,
4
+ otherwise aggregate per-backend failure reasons and raise Unavailable. When
5
+ --backend is explicit, only that one backend runs (no fallback) — that's H10.
6
+
7
+ Importantly: resolve() never opens a ws. It only does HTTP discovery and
8
+ filesystem reads. The Skill opens the ws downstream. (§2.3 banner sync.)
9
+
10
+ The `caller_context` ContextVar communicates to backends whether the resolve
11
+ is happening on the Mode A short-conn path or inside the Mode B long-running
12
+ daemon. Reserved for future backends that need to diverge per call site.
13
+ Async-safe by virtue of contextvars.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import contextvars
18
+
19
+ from .backends import all_backends, get_backend
20
+ from .config import Config
21
+ from .errors import Unavailable
22
+
23
+
24
+ # Values: "mode_a" (default — Mode A CLI invocation) or "mode_b_serve"
25
+ # (called from inside `browserwright-daemon serve`). Extend with care; backends
26
+ # read this and may diverge behavior.
27
+ caller_context: contextvars.ContextVar[str] = contextvars.ContextVar(
28
+ "bd_caller", default="mode_a"
29
+ )
30
+
31
+
32
+ async def resolve(cfg: Config):
33
+ """Return a ResolveResult for the first backend that succeeds.
34
+
35
+ cfg.backend pinning behavior:
36
+ - None -> try every backend in registry order (env > rdp), then bail.
37
+ `extension` and `cloud` are deliberately excluded from the
38
+ auto-fallback (see _CHAIN_OPT_OUT below).
39
+ - str -> try only that one
40
+ """
41
+ if cfg.backend is not None:
42
+ backend = get_backend(cfg.backend, cfg)
43
+ try:
44
+ return await backend.resolve(cfg.timeout)
45
+ except Unavailable:
46
+ raise # explicit backend: surface attempts as-is
47
+ # Auto chain. Two backends are explicitly NOT in the auto-fallback:
48
+ # `extension` — LOCAL_RELAY kind, requires daemon-serve to be the
49
+ # ws server; `resolve()` is meaningless for Mode A.
50
+ # `cloud` — requires explicit endpoint + auth_kind config; we
51
+ # don't want a stale `[backends.cloud]` row to silently surface
52
+ # in `browserwright-daemon url` and confuse users who expected local
53
+ # Chrome to be reachable.
54
+ # Both still work when `--backend cloud` / `--backend extension` is
55
+ # explicit (the branch above), they're just absent from auto-fallback.
56
+ attempts: dict[str, str] = {}
57
+ _CHAIN_OPT_OUT = {"extension", "cloud"}
58
+ for backend in all_backends(cfg):
59
+ if backend.name in _CHAIN_OPT_OUT:
60
+ continue
61
+ try:
62
+ return await backend.resolve(cfg.timeout)
63
+ except Unavailable as e:
64
+ # Preserve each backend's specific reason
65
+ if e.attempts:
66
+ attempts.update(e.attempts)
67
+ else:
68
+ attempts[backend.name] = str(e)
69
+ raise Unavailable(
70
+ "no backend could resolve a CDP WebSocket URL",
71
+ attempts=attempts,
72
+ )
@@ -0,0 +1,6 @@
1
+ """Mode B (v0.2) — long-running daemon process.
2
+
3
+ This package is loaded only by `browserwright-daemon serve` and friends. v0.1 Mode A
4
+ subcommands never import from here, keeping the import graph + cold-start cost
5
+ of the CLI minimal.
6
+ """
@@ -0,0 +1,229 @@
1
+ """Single global daemon: multi-upstream, session-keyed routing.
2
+
3
+ `docs/refactor-single-daemon.md` §"Key insight: the engine already exists":
4
+ `Router` + `DaemonState` + `_UpstreamHolder` are already a complete
5
+ single-upstream / multi-client engine — they only touch `self.state.*` and one
6
+ `self._upstream_send`. So this module does NOT rewrite any routing/translation
7
+ logic. It:
8
+
9
+ - bundles one `(state, router, holder)` triple per upstream into an
10
+ `UpstreamContext` (the per-upstream unit of `docs §Decomposition`), and
11
+ - adds the thin global `Daemon` that owns the shared (extension/real-browser)
12
+ context plus one lazily-created context per rdp session, and dispatches a
13
+ connecting client to the right context by reading the session's *immutable*
14
+ backend from the ledger.
15
+
16
+ Cross-talk is structurally impossible: a client is bound to exactly one context
17
+ for its whole connection, so each context's `Router._broadcast` only ever
18
+ reaches that context's own clients (browser-level events stay scoped to the
19
+ upstream that produced them).
20
+
21
+ Phase boundaries (this file is Phase 2):
22
+ - The actual per-session rdp Chrome *launch* is Phase 3. Here we only create
23
+ the context object + its `(state, router, holder)` triple, wiring the
24
+ holder with a cfg whose rdp port comes from the session's workspace; the
25
+ holder's existing resolve/connect path is left untouched.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ import itertools
31
+ import logging
32
+
33
+ from ... import session_registry
34
+ from ..config import Config
35
+ from .state import DaemonState
36
+ from .proxy import Router
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class UnknownSessionError(KeyError):
42
+ """Raised when an explicit session-bound client names no ledger session."""
43
+
44
+
45
+ # ---- per-upstream context --------------------------------------------------
46
+
47
+
48
+ class UpstreamContext:
49
+ """One live upstream: its `(state, router, holder)` triple.
50
+
51
+ Mirrors exactly what `run_serve` used to build for the single upstream —
52
+ just bundled so the `Daemon` can hold N of them. The holder is created by
53
+ `make_context` (it lives in listener.py to avoid an import cycle), so this
54
+ class is a passive bundle: it owns no construction logic, only the handle
55
+ each dispatch path needs (`router` to register a client, `state` to release
56
+ it, `holder` for lifecycle).
57
+ """
58
+
59
+ def __init__(self, *, backend: str, state: DaemonState, router: Router,
60
+ holder: object, session_id: str | None = None):
61
+ self.backend = backend
62
+ self.state = state
63
+ self.router = router
64
+ # Typed as object to keep this module free of a listener import cycle
65
+ # (_UpstreamHolder lives in listener.py and imports nothing from here).
66
+ self.holder = holder
67
+ # None for the shared context; the rdp session id otherwise.
68
+ self.session_id = session_id
69
+
70
+
71
+ # ---- the thin global daemon ------------------------------------------------
72
+
73
+
74
+ class Daemon:
75
+ """Global daemon: one shared context + one context per rdp session.
76
+
77
+ `shared_context` is the always-on, real-browser upstream (backend ==
78
+ `cfg.backend`, default `extension`); for the extension backend its holder
79
+ owns the always-on `RelayServer` started eagerly in `run_serve`. Every
80
+ extension/env/cloud session routes here.
81
+
82
+ `contexts` holds one `UpstreamContext` per rdp session, created lazily on
83
+ first reference (Phase 3 makes the holder actually launch the per-session
84
+ Chrome — here it is only wired with a port-pinned cfg).
85
+ """
86
+
87
+ def __init__(self, *, cfg: Config, shared_context: UpstreamContext,
88
+ make_context):
89
+ self.cfg = cfg
90
+ self.shared_context = shared_context
91
+ self.contexts: dict[str, UpstreamContext] = {}
92
+ # Phase B: per-session persistent executor subprocesses, keyed by
93
+ # session id (mirrors `contexts`). Lazily spawned by the
94
+ # `ensureExecutor` verb (PR1). Supervised by the daemon (PR2): idle/crash
95
+ # reap via the idle-watchdog, endSession kill in the endSession handler,
96
+ # kill-all on graceful shutdown, orphan-sweep on startup.
97
+ from .executor_registry import ExecutorRegistry
98
+ self.executors = ExecutorRegistry()
99
+ # Injected factory `(backend, cfg, session_id) -> UpstreamContext`.
100
+ # Lives in listener.py (it builds the _UpstreamHolder); injected to
101
+ # avoid an import cycle.
102
+ self._make_context = make_context
103
+ # Global, unique-across-contexts client id source — purely for
104
+ # log-friendliness so two contexts never print the same client #.
105
+ self._next_client_id: "itertools.count[int]" = itertools.count(1)
106
+ # Back-reference set on each context's router so RPC handlers
107
+ # (ensureSession / endSession) can reach the daemon to create/drop
108
+ # an rdp context.
109
+ shared_context.router.daemon = self # type: ignore[attr-defined]
110
+
111
+ def all_contexts(self) -> list[UpstreamContext]:
112
+ """Shared context first, then every rdp context — used by shutdown /
113
+ idle / signal paths that must iterate every live upstream."""
114
+ return [self.shared_context, *self.contexts.values()]
115
+
116
+ def context_for(self, session_id: str | None, *, require_known: bool = False) -> UpstreamContext:
117
+ """Resolve the `UpstreamContext` that should serve `session_id`.
118
+
119
+ - None / empty session → the shared (real-browser) context.
120
+ - ledger backend == "rdp" → a per-session context (created lazily).
121
+ - extension/env/cloud → the shared context.
122
+ - unknown explicit session → raises when `require_known=True`.
123
+
124
+ The backend is the ledger's immutable `backend` field — never a client
125
+ param (docs §RPCs). Sessionless clients keep the historical shared
126
+ context; explicit session-bound clients can require the ledger record so
127
+ a typo or stale id never silently falls into the extension backend.
128
+ """
129
+ if not session_id:
130
+ return self.shared_context
131
+ record = session_registry.get(session_id)
132
+ if record is None:
133
+ if require_known:
134
+ raise UnknownSessionError(session_id)
135
+ return self.shared_context
136
+ backend = record.get("backend")
137
+ if backend == "rdp":
138
+ return self._ensure_rdp_context(session_id, record)
139
+ # extension / env / cloud → shared upstream.
140
+ return self.shared_context
141
+
142
+ def context_for_required(self, session_id: str) -> UpstreamContext:
143
+ """Resolve an explicitly session-bound context, failing closed."""
144
+ return self.context_for(session_id, require_known=True)
145
+
146
+ def _ensure_rdp_context(self, session_id: str, record: dict) -> UpstreamContext:
147
+ """Get or create the per-session rdp context.
148
+
149
+ Phase 2 scope: build the context object (its own state/router/holder)
150
+ and wire the holder with a cfg whose rdp port comes from the session's
151
+ `workspace["port"]` when present (else the existing resolve path is
152
+ left intact). The actual Chrome *launch* is Phase 3 — the holder's
153
+ lazy-open will then resolve+connect to that port.
154
+ """
155
+ ctx = self.contexts.get(session_id)
156
+ if ctx is not None:
157
+ return ctx
158
+ cfg = self._rdp_cfg_for(record)
159
+ ctx = self._make_context(backend="rdp", cfg=cfg, session_id=session_id)
160
+ # Preserve ownership semantics past the ledger→context boundary:
161
+ # create-owned sessions launch/kill daemon-owned Chrome; attach
162
+ # sessions only connect to the caller-provided port.
163
+ try:
164
+ ctx.holder.rdp_owns_browser = record.get("owner") == "create" # type: ignore[attr-defined]
165
+ except Exception:
166
+ pass
167
+ # Same daemon back-reference the shared context got, so the rdp
168
+ # context's RPC handlers can drop themselves on endSession.
169
+ ctx.router.daemon = self # type: ignore[attr-defined]
170
+ self.contexts[session_id] = ctx
171
+ logger.info("created rdp upstream context for session %s (port=%s)",
172
+ session_id, cfg.backends.rdp.port)
173
+ return ctx
174
+
175
+ def _rdp_cfg_for(self, record: dict) -> Config:
176
+ """Derive a per-session rdp Config from the ledger record.
177
+
178
+ Pins `backend="rdp"` and, when the session's workspace carries a port,
179
+ `backends.rdp.port` so the holder's resolve path targets the right
180
+ per-session Chrome. Leaves the resolve path otherwise unchanged.
181
+ """
182
+ workspace = record.get("workspace")
183
+ port = None
184
+ if isinstance(workspace, dict):
185
+ raw = workspace.get("port")
186
+ if isinstance(raw, int):
187
+ port = raw
188
+ cfg = dataclasses.replace(self.cfg, backend="rdp")
189
+ if port is not None:
190
+ # `replace` shares the nested BackendsConfig instance; copy the rdp
191
+ # sub-config so per-session port pinning never mutates the shared
192
+ # cfg (or another session's context).
193
+ cfg.backends = dataclasses.replace(
194
+ cfg.backends,
195
+ rdp=dataclasses.replace(cfg.backends.rdp, port=port),
196
+ )
197
+ return cfg
198
+
199
+ def drop_rdp_context(self, session_id: str) -> UpstreamContext | None:
200
+ """Remove an rdp context from the registry. Returns the dropped
201
+ context, or None if absent.
202
+
203
+ This only de-registers the context (sync, callable from `_on_upstream_
204
+ closed` after teardown already ran). To also close the upstream + kill
205
+ the owned Chrome, use the async `teardown_rdp_context` instead — it runs
206
+ the holder's `trigger_close` (which SIGTERMs the Chrome) before
207
+ dropping."""
208
+ return self.contexts.pop(session_id, None)
209
+
210
+ async def teardown_rdp_context(self, session_id: str) -> bool:
211
+ """Phase 3 endSession teardown: close the per-session upstream, kill the
212
+ daemon-owned Chrome (the holder's `trigger_close` SIGTERMs `rdp_pid`),
213
+ and drop the context. Returns True if a context was found + torn down.
214
+
215
+ Idempotent-ish: a missing context returns False so the caller can still
216
+ answer the wire RPC with a uniform success shape."""
217
+ ctx = self.contexts.get(session_id)
218
+ if ctx is None:
219
+ return False
220
+ try:
221
+ # "skill_disconnect" is the closest honest CloseReason — the client
222
+ # explicitly asked to end this session (vs chrome_exit / idle).
223
+ await ctx.holder.trigger_close("skill_disconnect") # type: ignore[attr-defined]
224
+ except Exception as e:
225
+ logger.warning("teardown rdp context %s close failed: %r",
226
+ session_id, e)
227
+ self.contexts.pop(session_id, None)
228
+ logger.info("tore down rdp context for session %s", session_id)
229
+ return True