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
|
@@ -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,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
|