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/install.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Interactive install wizard.
|
|
2
|
+
|
|
3
|
+
Goal: walk a fresh user through the Chrome-source options the daemon
|
|
4
|
+
supports and persist their pick into ``global.md`` so future Skill processes
|
|
5
|
+
auto-connect via the right backend.
|
|
6
|
+
|
|
7
|
+
Choices (order matters — the default is option 1):
|
|
8
|
+
|
|
9
|
+
1. 隔离 profile (rdp + browserwright-daemon launch-chrome) — **Recommended for
|
|
10
|
+
scraping / dev work**. Zero popups, zero banner, doesn't touch the
|
|
11
|
+
user's daily Chrome.
|
|
12
|
+
2. 指纹浏览器 (rdp + custom port) — AdsPower / MultiLogin / GoLogin /
|
|
13
|
+
比特浏览器, etc. User supplies the port number.
|
|
14
|
+
3. Browser extension relay — drive the user's daily Chrome without any
|
|
15
|
+
popups or banners. Requires loading the unpacked extension from
|
|
16
|
+
``browserwright-daemon/chrome-extension/``. Surfaced as live when the
|
|
17
|
+
daemon's ``doctor`` reports the extension backend available.
|
|
18
|
+
4. Cloud / remote browser (Browser Use / Browserless / Hyperbrowser) —
|
|
19
|
+
hosted Chrome via auth provider.
|
|
20
|
+
|
|
21
|
+
Detection is minimal — we ask the user.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from .memory.global_mem import global_memory
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# (key, backend, label, description). The order here is the order shown.
|
|
37
|
+
_OPTIONS: list[Tuple[str, str, str, str]] = [
|
|
38
|
+
("1", "rdp",
|
|
39
|
+
"隔离 profile (rdp + browserwright-daemon launch-chrome) [Recommended]",
|
|
40
|
+
"Skill 起一个独立 user-data-dir 的后台 Chrome;零打扰、零 popup。"),
|
|
41
|
+
("2", "rdp",
|
|
42
|
+
"指纹浏览器 (rdp + 自定义端口)",
|
|
43
|
+
"AdsPower / MultiLogin / GoLogin / 比特浏览器 等;你已开好对应 profile。"),
|
|
44
|
+
("3", "extension",
|
|
45
|
+
"Browser extension relay (drives your daily Chrome, no popup)",
|
|
46
|
+
"通过加载到 Chrome 的扩展中继 CDP,零 popup、零横幅;连接日常 Chrome 的唯一路径。"),
|
|
47
|
+
("4", "cloud",
|
|
48
|
+
"Cloud/Remote browser (Browser Use / Browserless / Hyperbrowser)",
|
|
49
|
+
"远程 Chrome 服务,通过 daemon 内置 AuthProvider 抽象处理 \n"
|
|
50
|
+
" Bearer / Basic / mTLS 鉴权。零本地 Chrome 进程。"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _prompt(msg: str, *, default: Optional[str] = None) -> str:
|
|
55
|
+
suffix = f" [{default}]" if default else ""
|
|
56
|
+
try:
|
|
57
|
+
ans = input(f"{msg}{suffix}: ").strip()
|
|
58
|
+
except EOFError:
|
|
59
|
+
return default or ""
|
|
60
|
+
return ans or (default or "")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _yesno(msg: str, *, default_yes: bool = True) -> bool:
|
|
64
|
+
suffix = "Y/n" if default_yes else "y/N"
|
|
65
|
+
ans = _prompt(f"{msg} ({suffix})").lower()
|
|
66
|
+
if not ans:
|
|
67
|
+
return default_yes
|
|
68
|
+
return ans.startswith("y")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def chrome_extension_path() -> Optional[str]:
|
|
72
|
+
"""Return the absolute path to the daemon's ``chrome-extension/`` directory,
|
|
73
|
+
or ``None`` if we can't determine it.
|
|
74
|
+
|
|
75
|
+
Resolution order:
|
|
76
|
+
1. ``$BS_CHROME_EXTENSION_PATH`` env (test/dev override).
|
|
77
|
+
2. ``browserwright-daemon extension-path --json`` subprocess (the v0.4 daemon
|
|
78
|
+
exposes the resource path; cheap one-shot, no ws side effects).
|
|
79
|
+
3. Best-effort walk from ``which browserwright-daemon`` — many dev checkouts
|
|
80
|
+
keep ``chrome-extension/`` as a sibling of the daemon's ``src/``
|
|
81
|
+
tree, which puts it two levels up from the installed bin.
|
|
82
|
+
4. ``None`` → wizard prints generic guidance.
|
|
83
|
+
"""
|
|
84
|
+
env_path = os.environ.get("BS_CHROME_EXTENSION_PATH")
|
|
85
|
+
if env_path:
|
|
86
|
+
return env_path
|
|
87
|
+
|
|
88
|
+
# (2) Ask the daemon itself.
|
|
89
|
+
try:
|
|
90
|
+
proc = subprocess.run(
|
|
91
|
+
["browserwright-daemon", "extension-path", "--json"],
|
|
92
|
+
capture_output=True, text=True, timeout=5,
|
|
93
|
+
)
|
|
94
|
+
if proc.returncode == 0 and proc.stdout.strip():
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(proc.stdout)
|
|
97
|
+
p = data.get("path") if isinstance(data, dict) else None
|
|
98
|
+
if p and os.path.isdir(p):
|
|
99
|
+
return p
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
# Some daemon builds may emit a bare path on stdout.
|
|
102
|
+
p = proc.stdout.strip().splitlines()[0]
|
|
103
|
+
if os.path.isdir(p):
|
|
104
|
+
return p
|
|
105
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# (3) Best-effort walk from the installed binary.
|
|
109
|
+
bin_path = shutil.which("browserwright-daemon")
|
|
110
|
+
if bin_path:
|
|
111
|
+
candidate = Path(bin_path).resolve().parent.parent / "chrome-extension"
|
|
112
|
+
if candidate.is_dir():
|
|
113
|
+
return str(candidate)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _wizard_doctor_backends() -> list[dict]:
|
|
118
|
+
"""One ``health.daemon_doctor()`` call shared by every wizard
|
|
119
|
+
option-availability detector.
|
|
120
|
+
|
|
121
|
+
spec H3 / §D.2.3: ``doctor`` is contract-bound to **zero ws side
|
|
122
|
+
effects**, so calling it at wizard entry is safe.
|
|
123
|
+
|
|
124
|
+
**Detection contract for every future option (v0.5+)**: any new
|
|
125
|
+
``_<backend>_backend_available()`` helper MUST derive its answer
|
|
126
|
+
from this dict only. Do not open a CDP ws, do not subprocess a
|
|
127
|
+
backend-specific ``--probe`` command, do not curl a cloud provider.
|
|
128
|
+
If the signal you need isn't in doctor's JSON, extend the daemon's
|
|
129
|
+
doctor schema first.
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
from .health import daemon_doctor
|
|
133
|
+
info = daemon_doctor()
|
|
134
|
+
except Exception: # noqa: BLE001
|
|
135
|
+
return []
|
|
136
|
+
if info.get("skill_synthetic"):
|
|
137
|
+
return []
|
|
138
|
+
return list(info.get("backends", []) or [])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _backend_available_from(backends: list[dict], name: str) -> bool:
|
|
142
|
+
return any(b.get("name") == name and b.get("available")
|
|
143
|
+
for b in backends)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _extension_backend_available() -> bool:
|
|
147
|
+
"""Best-effort: ask the daemon's doctor if an ``extension`` backend
|
|
148
|
+
is registered + available. Any failure → treat as unavailable (the
|
|
149
|
+
label will read "coming v0.4")."""
|
|
150
|
+
return _backend_available_from(_wizard_doctor_backends(), "extension")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _cloud_backend_entry(backends: Optional[list[dict]] = None) -> Optional[dict]:
|
|
154
|
+
"""Return the daemon's ``cloud`` backend doctor entry, or ``None``.
|
|
155
|
+
|
|
156
|
+
Contract (v0.5, pre-agreed with daemon-impl-2 — see HANDOFF v0.5)::
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
"name": "cloud",
|
|
160
|
+
"available": bool, # config complete + auth provider loadable
|
|
161
|
+
"ws_url": str | None,
|
|
162
|
+
"detail": str, # "<provider> (auth_kind=..., endpoint=...)"
|
|
163
|
+
# or "not configured; run ..."
|
|
164
|
+
"ux_cost": "auth-required", # new enum value, not "popup"
|
|
165
|
+
"ux_warning": str | None,
|
|
166
|
+
"needs_user_action": str | None,
|
|
167
|
+
"extras": {
|
|
168
|
+
"provider": "browser-use"|"browserless"|"hyperbrowser"|"generic",
|
|
169
|
+
"endpoint": "wss://..." or "https://...",
|
|
170
|
+
"auth_kind": "bearer"|"basic"|"mtls",
|
|
171
|
+
"configured": bool,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
If ``backends`` is provided, scan it; otherwise fetch via
|
|
176
|
+
``_wizard_doctor_backends()``. Returns ``None`` if doctor isn't
|
|
177
|
+
reachable or the entry is missing.
|
|
178
|
+
"""
|
|
179
|
+
if backends is None:
|
|
180
|
+
backends = _wizard_doctor_backends()
|
|
181
|
+
for b in backends:
|
|
182
|
+
if b.get("name") == "cloud":
|
|
183
|
+
return b
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _cloud_backend_available() -> bool:
|
|
188
|
+
"""v0.5 mirror of ``_extension_backend_available()``: ask doctor if the
|
|
189
|
+
daemon's ``cloud`` backend (Browser Use / Browserless / Hyperbrowser /
|
|
190
|
+
generic) is registered and currently available.
|
|
191
|
+
|
|
192
|
+
The daemon-side cloud backend handles auth abstraction (Bearer / Basic
|
|
193
|
+
/ mTLS) and credential lifecycle; the Skill side only collects
|
|
194
|
+
*references* to credentials (env-var names, cert file paths) — never
|
|
195
|
+
the secrets themselves — and persists them to ``global.md``.
|
|
196
|
+
"""
|
|
197
|
+
entry = _cloud_backend_entry()
|
|
198
|
+
return bool(entry and entry.get("available"))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# v0.5 cloud config TOML table names. Matches the daemon 0.5.0 schema:
|
|
202
|
+
# top section keeps endpoint / auth_kind / provider_hint; per-kind credential
|
|
203
|
+
# *references* live in a ``[backends.cloud.auth.<kind>]`` subtable so the
|
|
204
|
+
# daemon's polymorphic ``AuthProvider`` builder can pick the right
|
|
205
|
+
# implementation. Kept as module-level constants so a future daemon schema
|
|
206
|
+
# rename is one-place + one-grep.
|
|
207
|
+
_CLOUD_TOML_TOP_SECTION = "[backends.cloud]"
|
|
208
|
+
_CLOUD_TOML_AUTH_SECTION_FMT = "[backends.cloud.auth.{kind}]"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _cloud_owned_sections(auth_kinds: tuple[str, ...] = (
|
|
212
|
+
"bearer", "basic", "mtls", "oauth2")) -> set[str]:
|
|
213
|
+
"""All TOML sections the wizard considers its own. Includes the
|
|
214
|
+
top-level cloud table and every auth subtable (even unused ones —
|
|
215
|
+
so when the user switches from bearer to mtls the old subtable
|
|
216
|
+
doesn't linger)."""
|
|
217
|
+
out = {_CLOUD_TOML_TOP_SECTION}
|
|
218
|
+
for k in auth_kinds:
|
|
219
|
+
out.add(_CLOUD_TOML_AUTH_SECTION_FMT.format(kind=k))
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _daemon_config_path() -> Path:
|
|
224
|
+
"""Return ``~/.config/browserwright-daemon/config.toml`` (XDG-aware).
|
|
225
|
+
|
|
226
|
+
``$BS_DAEMON_CONFIG_PATH`` env override exists for tests + the rare
|
|
227
|
+
case where a user wants a non-XDG location.
|
|
228
|
+
"""
|
|
229
|
+
override = os.environ.get("BS_DAEMON_CONFIG_PATH")
|
|
230
|
+
if override:
|
|
231
|
+
return Path(override)
|
|
232
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
233
|
+
return Path(base) / "browserwright-daemon" / "config.toml"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _toml_escape(s: str) -> str:
|
|
237
|
+
"""TOML basic-string escape (REVIEW.md F-13).
|
|
238
|
+
|
|
239
|
+
Handles backslash, double-quote, and the full ASCII control-char
|
|
240
|
+
range (TOML basic strings reject unescaped 0x00–0x1F + 0x7F). The
|
|
241
|
+
inputs the wizard collects (env-var names, file paths, endpoint
|
|
242
|
+
URLs) should never contain control chars in practice — if one
|
|
243
|
+
sneaks in via env / clipboard paste, we **reject** rather than
|
|
244
|
+
silently emit an invalid TOML literal. The error name + offset
|
|
245
|
+
surface in the resulting ``ValueError`` so the user can fix the
|
|
246
|
+
source.
|
|
247
|
+
"""
|
|
248
|
+
text = str(s)
|
|
249
|
+
for i, ch in enumerate(text):
|
|
250
|
+
cp = ord(ch)
|
|
251
|
+
if cp == 0x7F or (cp < 0x20 and ch not in "\t\n\r"):
|
|
252
|
+
raise ValueError(
|
|
253
|
+
f"TOML emit refused: control character U+{cp:04X} at "
|
|
254
|
+
f"offset {i} of input {text!r}. Strip the control "
|
|
255
|
+
f"character (likely a stray newline / clipboard "
|
|
256
|
+
f"artifact) and re-run the wizard."
|
|
257
|
+
)
|
|
258
|
+
return (
|
|
259
|
+
text
|
|
260
|
+
.replace('\\', '\\\\')
|
|
261
|
+
.replace('"', '\\"')
|
|
262
|
+
.replace('\n', '\\n')
|
|
263
|
+
.replace('\r', '\\r')
|
|
264
|
+
.replace('\t', '\\t')
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# Mapping from wizard ``cloud_fields`` keys to (toml_section, toml_key)
|
|
269
|
+
# pairs. Drives both the emit logic and the strip-existing-sections
|
|
270
|
+
# logic so the two stay in sync.
|
|
271
|
+
_AUTH_FIELD_LAYOUT: dict[str, tuple[str, str, str]] = {
|
|
272
|
+
# wizard key (auth_kind, toml key)
|
|
273
|
+
"cloud_token_env": ("bearer", "token_env"),
|
|
274
|
+
"cloud_username_env": ("basic", "username_env"),
|
|
275
|
+
"cloud_password_env": ("basic", "password_env"),
|
|
276
|
+
"cloud_cert_file": ("mtls", "cert_file"),
|
|
277
|
+
"cloud_key_file": ("mtls", "key_file"),
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _write_daemon_cloud_config(provider_hint: str, auth_kind: str,
|
|
282
|
+
fields: dict) -> Path:
|
|
283
|
+
"""Persist daemon-0.5.0-shaped cloud config to ``config.toml``.
|
|
284
|
+
|
|
285
|
+
Emits two sections::
|
|
286
|
+
|
|
287
|
+
[backends.cloud]
|
|
288
|
+
endpoint = "..." # if collected
|
|
289
|
+
auth_kind = "<kind>"
|
|
290
|
+
provider_hint = "<provider>" # display name only; informational
|
|
291
|
+
|
|
292
|
+
[backends.cloud.auth.<kind>]
|
|
293
|
+
# per-kind credential references, never the secret itself:
|
|
294
|
+
# bearer → token_env
|
|
295
|
+
# basic → username_env, password_env
|
|
296
|
+
# mtls → cert_file, key_file
|
|
297
|
+
|
|
298
|
+
The wizard owns both sections **wholesale**: existing
|
|
299
|
+
``[backends.cloud]`` and every ``[backends.cloud.auth.<kind>]`` is
|
|
300
|
+
replaced on re-run (whether or not the user changed auth_kind, so
|
|
301
|
+
stale subtables don't linger). All other sections — ``[server]``,
|
|
302
|
+
``[logging]``, ``[backends.rdp]``, etc. — are preserved verbatim.
|
|
303
|
+
|
|
304
|
+
Hand-rolled TOML emit (no ``tomli_w`` dep). The shapes we produce
|
|
305
|
+
are basic strings + scalars; daemon reads via stdlib ``tomllib``.
|
|
306
|
+
"""
|
|
307
|
+
path = _daemon_config_path()
|
|
308
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
|
|
310
|
+
owned = _cloud_owned_sections()
|
|
311
|
+
# Read & strip any owned sections from the existing file.
|
|
312
|
+
pre_lines: list[str] = []
|
|
313
|
+
if path.exists():
|
|
314
|
+
in_owned = False
|
|
315
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
316
|
+
stripped = line.strip()
|
|
317
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
318
|
+
in_owned = stripped in owned
|
|
319
|
+
if not in_owned:
|
|
320
|
+
pre_lines.append(line)
|
|
321
|
+
continue
|
|
322
|
+
if not in_owned:
|
|
323
|
+
pre_lines.append(line)
|
|
324
|
+
|
|
325
|
+
# Build the new top-level [backends.cloud] block.
|
|
326
|
+
top: list[str] = [_CLOUD_TOML_TOP_SECTION]
|
|
327
|
+
if "cloud_endpoint" in fields:
|
|
328
|
+
top.append(f'endpoint = "{_toml_escape(fields["cloud_endpoint"])}"')
|
|
329
|
+
top.append(f'auth_kind = "{_toml_escape(auth_kind)}"')
|
|
330
|
+
top.append(f'provider_hint = "{_toml_escape(provider_hint)}"')
|
|
331
|
+
|
|
332
|
+
# Build the [backends.cloud.auth.<kind>] subtable from the fields
|
|
333
|
+
# whose layout matches this auth_kind.
|
|
334
|
+
sub: list[str] = [_CLOUD_TOML_AUTH_SECTION_FMT.format(kind=auth_kind)]
|
|
335
|
+
sub_keys_emitted = 0
|
|
336
|
+
for wkey, (kind, toml_key) in _AUTH_FIELD_LAYOUT.items():
|
|
337
|
+
if kind != auth_kind or wkey not in fields:
|
|
338
|
+
continue
|
|
339
|
+
sub.append(f'{toml_key} = "{_toml_escape(fields[wkey])}"')
|
|
340
|
+
sub_keys_emitted += 1
|
|
341
|
+
|
|
342
|
+
while pre_lines and pre_lines[-1].strip() == "":
|
|
343
|
+
pre_lines.pop()
|
|
344
|
+
parts: list[str] = list(pre_lines)
|
|
345
|
+
if parts:
|
|
346
|
+
parts.append("") # separator before our top section
|
|
347
|
+
parts.extend(top)
|
|
348
|
+
if sub_keys_emitted > 0:
|
|
349
|
+
parts.append("") # blank line between top and subtable
|
|
350
|
+
parts.extend(sub)
|
|
351
|
+
parts.append("") # trailing newline
|
|
352
|
+
path.write_text("\n".join(parts), encoding="utf-8")
|
|
353
|
+
return path
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
_VALID_CLOUD_PROVIDERS = ("browser-use", "browserless", "hyperbrowser", "generic")
|
|
357
|
+
_VALID_CLOUD_AUTH_KINDS = ("bearer", "basic", "mtls")
|
|
358
|
+
# Auth kinds the wizard recognises by name but refuses (until shipped).
|
|
359
|
+
# Keeps the user from typing a typo and getting "unknown auth_kind" when
|
|
360
|
+
# the real answer is "not yet implemented".
|
|
361
|
+
_COMING_CLOUD_AUTH_KINDS = {"oauth2": "v0.6"}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _collect_cloud_fields(provider: str, auth_kind: str,
|
|
365
|
+
extras: Optional[dict] = None) -> dict:
|
|
366
|
+
"""Per-auth-kind credential *references* — never the secret itself.
|
|
367
|
+
|
|
368
|
+
bearer → env var **name** holding the token (e.g. ``BROWSER_USE_API_KEY``);
|
|
369
|
+
the daemon reads the live value at startup.
|
|
370
|
+
basic → endpoint URL with ``user:pass@`` embedded (URL-RFC).
|
|
371
|
+
mtls → cert + key file paths.
|
|
372
|
+
|
|
373
|
+
``extras`` (optional): doctor's ``extras`` dict from a previously-
|
|
374
|
+
configured cloud backend entry. When present we use its values as
|
|
375
|
+
prompt defaults so re-running ``browserwright install`` doesn't
|
|
376
|
+
require the user to re-type everything.
|
|
377
|
+
"""
|
|
378
|
+
extras = extras or {}
|
|
379
|
+
out: dict = {"cloud_provider_hint": provider, "cloud_auth_kind": auth_kind}
|
|
380
|
+
if auth_kind == "bearer":
|
|
381
|
+
default_envvar = extras.get("token_env") or "BROWSER_USE_API_KEY"
|
|
382
|
+
envvar = _prompt(
|
|
383
|
+
"Env var name holding the bearer token (e.g. BROWSER_USE_API_KEY)",
|
|
384
|
+
default=default_envvar,
|
|
385
|
+
)
|
|
386
|
+
if not envvar:
|
|
387
|
+
raise ValueError("bearer auth requires an env-var name")
|
|
388
|
+
out["cloud_token_env"] = envvar
|
|
389
|
+
endpoint = _prompt("Cloud endpoint (wss://...)",
|
|
390
|
+
default=extras.get("endpoint") or "")
|
|
391
|
+
if endpoint:
|
|
392
|
+
out["cloud_endpoint"] = endpoint
|
|
393
|
+
elif auth_kind == "basic":
|
|
394
|
+
# v0.5 — header-mode basic auth (daemon ``BasicAuth`` default).
|
|
395
|
+
# Credentials live in env at daemon `serve` time; the wizard only
|
|
396
|
+
# collects the env-var **names**. URL stays credential-free.
|
|
397
|
+
default_user_env = extras.get("username_env") or "BROWSERLESS_USER"
|
|
398
|
+
default_pass_env = extras.get("password_env") or "BROWSERLESS_PASS"
|
|
399
|
+
user_env = _prompt(
|
|
400
|
+
"Env var name holding the basic-auth username (e.g. BROWSERLESS_USER)",
|
|
401
|
+
default=default_user_env,
|
|
402
|
+
)
|
|
403
|
+
pass_env = _prompt(
|
|
404
|
+
"Env var name holding the basic-auth password (e.g. BROWSERLESS_PASS)",
|
|
405
|
+
default=default_pass_env,
|
|
406
|
+
)
|
|
407
|
+
if not user_env or not pass_env:
|
|
408
|
+
raise ValueError("basic auth requires both username_env and password_env")
|
|
409
|
+
out["cloud_username_env"] = user_env
|
|
410
|
+
out["cloud_password_env"] = pass_env
|
|
411
|
+
endpoint = _prompt(
|
|
412
|
+
"Cloud endpoint (bare URL, no creds — wss://api.browserless.io/ws)",
|
|
413
|
+
default=extras.get("endpoint") or "",
|
|
414
|
+
)
|
|
415
|
+
if not endpoint:
|
|
416
|
+
raise ValueError("basic auth requires an endpoint URL")
|
|
417
|
+
if "@" in endpoint:
|
|
418
|
+
# URL-embedded creds are a daemon-side opt-in
|
|
419
|
+
# (``embed_in_url=true``). The wizard's default flow uses
|
|
420
|
+
# header mode; refuse the embedded form to avoid storing the
|
|
421
|
+
# secret in plain memory.
|
|
422
|
+
raise ValueError(
|
|
423
|
+
"basic auth wizard collects credentials via env-var names "
|
|
424
|
+
"(daemon header mode). Strip user:pass@ from the URL and "
|
|
425
|
+
"supply env-var names instead."
|
|
426
|
+
)
|
|
427
|
+
out["cloud_endpoint"] = endpoint
|
|
428
|
+
elif auth_kind == "mtls":
|
|
429
|
+
cert = _prompt("Path to client cert (PEM)",
|
|
430
|
+
default=extras.get("cert_file") or "")
|
|
431
|
+
key = _prompt("Path to client key (PEM)",
|
|
432
|
+
default=extras.get("key_file") or "")
|
|
433
|
+
if not cert or not key:
|
|
434
|
+
raise ValueError("mtls auth requires both cert and key paths")
|
|
435
|
+
out["cloud_cert_file"] = cert
|
|
436
|
+
out["cloud_key_file"] = key
|
|
437
|
+
endpoint = _prompt("Cloud endpoint (wss://...)",
|
|
438
|
+
default=extras.get("endpoint") or "")
|
|
439
|
+
if endpoint:
|
|
440
|
+
out["cloud_endpoint"] = endpoint
|
|
441
|
+
else:
|
|
442
|
+
raise ValueError(f"unknown auth_kind: {auth_kind!r}")
|
|
443
|
+
return out
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def run() -> int:
|
|
447
|
+
print("browserwright install wizard")
|
|
448
|
+
print("=" * 32)
|
|
449
|
+
print()
|
|
450
|
+
# Single shared doctor probe (spec H3 zero-side-effect contract). Options
|
|
451
|
+
# 3 (extension) and 4 (cloud) consume the same dict so the wizard pays
|
|
452
|
+
# at most one subprocess regardless of backend count.
|
|
453
|
+
backends = _wizard_doctor_backends()
|
|
454
|
+
ext_live = _backend_available_from(backends, "extension")
|
|
455
|
+
cloud_entry = _cloud_backend_entry(backends)
|
|
456
|
+
cloud_live = bool(cloud_entry and cloud_entry.get("available"))
|
|
457
|
+
cloud_extras = (cloud_entry or {}).get("extras") or {}
|
|
458
|
+
|
|
459
|
+
print("Pick how Skill should connect to Chrome:")
|
|
460
|
+
for key, _backend, label, desc in _OPTIONS:
|
|
461
|
+
if key == "3" and not ext_live:
|
|
462
|
+
shown_label = f"{label} (daemon reports extension backend not yet available)"
|
|
463
|
+
elif key == "4" and not cloud_live:
|
|
464
|
+
shown_label = f"{label} (daemon reports cloud backend not yet available)"
|
|
465
|
+
else:
|
|
466
|
+
shown_label = label
|
|
467
|
+
print(f" {key}. {shown_label}")
|
|
468
|
+
for ln in desc.splitlines():
|
|
469
|
+
print(f" {ln}")
|
|
470
|
+
print()
|
|
471
|
+
|
|
472
|
+
choice = _prompt("Choose 1 / 2 / 3 / 4", default="1")
|
|
473
|
+
match = next((o for o in _OPTIONS if o[0] == choice), None)
|
|
474
|
+
if match is None:
|
|
475
|
+
print(f"unknown choice: {choice!r}", file=sys.stderr)
|
|
476
|
+
return 1
|
|
477
|
+
_key, backend, label, _desc = match
|
|
478
|
+
|
|
479
|
+
if choice == "3" and not ext_live:
|
|
480
|
+
print()
|
|
481
|
+
print("Extension backend is not yet available in your installed daemon.")
|
|
482
|
+
print("Re-run this wizard after upgrading to a daemon build that ships")
|
|
483
|
+
print("the extension backend, or pick option 1 / 2 / 4 for now.")
|
|
484
|
+
return 1
|
|
485
|
+
if choice == "4" and not cloud_live:
|
|
486
|
+
print()
|
|
487
|
+
print("Cloud backend is not yet available in your installed daemon.")
|
|
488
|
+
print("Re-run this wizard after upgrading to a daemon build that ships")
|
|
489
|
+
print("the cloud backend, or pick option 1 / 2 / 3 for now.")
|
|
490
|
+
return 1
|
|
491
|
+
|
|
492
|
+
extra_note = label
|
|
493
|
+
cloud_fields: dict = {}
|
|
494
|
+
if choice == "2":
|
|
495
|
+
port = _prompt("Fingerprint browser CDP port (e.g. 9223)", default="9222")
|
|
496
|
+
try:
|
|
497
|
+
int(port)
|
|
498
|
+
except ValueError:
|
|
499
|
+
print(f"port must be an integer, got {port!r}", file=sys.stderr)
|
|
500
|
+
return 1
|
|
501
|
+
extra_note = f"{label}, port={port}"
|
|
502
|
+
|
|
503
|
+
if choice == "4":
|
|
504
|
+
# Pre-fill prompts from doctor's ``extras`` when the daemon
|
|
505
|
+
# already has a cloud config — re-running install shouldn't make
|
|
506
|
+
# the user re-type unchanged fields.
|
|
507
|
+
default_provider = cloud_extras.get("provider") or "browser-use"
|
|
508
|
+
provider = _prompt(
|
|
509
|
+
"Provider (browser-use / browserless / hyperbrowser / generic)",
|
|
510
|
+
default=default_provider,
|
|
511
|
+
)
|
|
512
|
+
if provider not in _VALID_CLOUD_PROVIDERS:
|
|
513
|
+
print(f"unknown provider: {provider!r}. "
|
|
514
|
+
f"Pick one of {_VALID_CLOUD_PROVIDERS}.", file=sys.stderr)
|
|
515
|
+
return 1
|
|
516
|
+
default_auth = cloud_extras.get("auth_kind") or "bearer"
|
|
517
|
+
auth_kind = _prompt(
|
|
518
|
+
"Auth kind (bearer / basic / mtls; oauth2 coming v0.6)",
|
|
519
|
+
default=default_auth,
|
|
520
|
+
)
|
|
521
|
+
if auth_kind in _COMING_CLOUD_AUTH_KINDS:
|
|
522
|
+
target_ver = _COMING_CLOUD_AUTH_KINDS[auth_kind]
|
|
523
|
+
print(f"{auth_kind!r} auth is coming in {target_ver} — "
|
|
524
|
+
"not yet supported by daemon. Pick bearer / basic / mtls.",
|
|
525
|
+
file=sys.stderr)
|
|
526
|
+
return 1
|
|
527
|
+
if auth_kind not in _VALID_CLOUD_AUTH_KINDS:
|
|
528
|
+
print(f"unknown auth_kind: {auth_kind!r}. "
|
|
529
|
+
f"Pick one of {_VALID_CLOUD_AUTH_KINDS}.", file=sys.stderr)
|
|
530
|
+
return 1
|
|
531
|
+
try:
|
|
532
|
+
cloud_fields = _collect_cloud_fields(provider, auth_kind,
|
|
533
|
+
extras=cloud_extras)
|
|
534
|
+
except ValueError as e:
|
|
535
|
+
print(f"cloud setup aborted: {e}", file=sys.stderr)
|
|
536
|
+
return 1
|
|
537
|
+
extra_note = f"{label}, provider={provider}, auth={auth_kind}"
|
|
538
|
+
|
|
539
|
+
print()
|
|
540
|
+
print(f"Selected: {label}")
|
|
541
|
+
if not _yesno("Persist this preference to ~/.browserwright/global.md?"):
|
|
542
|
+
print("ok, leaving global memory untouched. You can run the wizard again later.")
|
|
543
|
+
return 0
|
|
544
|
+
|
|
545
|
+
# Direct write — install wizard *is* the user confirmation step, so we
|
|
546
|
+
# call set_preference with confirm=False intentionally.
|
|
547
|
+
mem = global_memory()
|
|
548
|
+
result = mem.set_preference("daemon.preferred_backend", backend, confirm=False)
|
|
549
|
+
mem.set_preference("daemon.notes", extra_note, confirm=False)
|
|
550
|
+
# v0.5: cloud-specific keys land under the same ``daemon:`` block so
|
|
551
|
+
# everything cloud-relevant is co-located in one frontmatter section.
|
|
552
|
+
for k, v in cloud_fields.items():
|
|
553
|
+
mem.set_preference(f"daemon.{k}", v, confirm=False)
|
|
554
|
+
print(f"wrote daemon.preferred_backend = {backend!r} to {mem.path}")
|
|
555
|
+
if cloud_fields:
|
|
556
|
+
# Daemon reads ``~/.config/browserwright-daemon/config.toml`` at
|
|
557
|
+
# daemon startup. The wizard is the canonical
|
|
558
|
+
# writer of the ``[backends.cloud]`` section.
|
|
559
|
+
try:
|
|
560
|
+
cfg_path = _write_daemon_cloud_config(
|
|
561
|
+
cloud_fields["cloud_provider_hint"],
|
|
562
|
+
cloud_fields["cloud_auth_kind"],
|
|
563
|
+
cloud_fields,
|
|
564
|
+
)
|
|
565
|
+
print(f"wrote [backends.cloud] + [backends.cloud.auth.*] sections to {cfg_path}")
|
|
566
|
+
except OSError as e:
|
|
567
|
+
# Best-effort — don't fail the wizard if we can't write the
|
|
568
|
+
# daemon config (e.g. read-only home, sandboxed test). Memory
|
|
569
|
+
# still got written so a re-run can retry.
|
|
570
|
+
print(f"warning: could not write daemon config: {e}",
|
|
571
|
+
file=sys.stderr)
|
|
572
|
+
if result.get("previous") and result["previous"] != backend:
|
|
573
|
+
print(f"(previous was {result['previous']!r}; kept in notes)")
|
|
574
|
+
print()
|
|
575
|
+
print("Next steps:")
|
|
576
|
+
if choice == "1":
|
|
577
|
+
print(" - Run `browserwright-daemon launch-chrome` to start the isolated profile.")
|
|
578
|
+
print(" - Then create a session: `browserwright session new --backend=rdp --create --name=TASK`.")
|
|
579
|
+
print(" (`--name` labels the isolated browser session; choose a short task label.)")
|
|
580
|
+
elif choice == "2":
|
|
581
|
+
print(" - Make sure your fingerprint browser is open on the chosen port.")
|
|
582
|
+
print(" - Then attach a session: `browserwright session new --backend=rdp --attach=PORT --name=TASK`.")
|
|
583
|
+
print(" (`--name` labels the attached browser session; choose a short task label.)")
|
|
584
|
+
elif choice == "3":
|
|
585
|
+
ext_dir = chrome_extension_path()
|
|
586
|
+
print(" 1. Install the unpacked Chrome extension:")
|
|
587
|
+
if ext_dir:
|
|
588
|
+
print(f" - chrome://extensions → toggle 'Developer mode'")
|
|
589
|
+
print(f" - click 'Load unpacked' → pick:")
|
|
590
|
+
print(f" {ext_dir}")
|
|
591
|
+
else:
|
|
592
|
+
print(" - chrome://extensions → toggle 'Developer mode'")
|
|
593
|
+
print(" - click 'Load unpacked' → pick the daemon's")
|
|
594
|
+
print(" `chrome-extension/` directory.")
|
|
595
|
+
print(" (Hint: `browserwright-daemon extension-path --json` prints it.)")
|
|
596
|
+
print(" 2. Start the single global daemon:")
|
|
597
|
+
print(" browserwright-daemon serve")
|
|
598
|
+
print(" (or install it once as a LaunchAgent: `browserwright-daemon install`.)")
|
|
599
|
+
print(" The daemon hosts the extension relay and still routes session")
|
|
600
|
+
print(" backends per session.")
|
|
601
|
+
print(" 3. In Chrome, click the extension icon → 'Attach this tab'.")
|
|
602
|
+
print(" Verify with: `browserwright-daemon doctor --json` →")
|
|
603
|
+
print(" look for `extension` backend `available=true` + `ws_url` set.")
|
|
604
|
+
print(" 4. Then create a session: `browserwright session new --backend=extension --name=TASK`.")
|
|
605
|
+
print(" (`--name` is the Chrome tab group title; choose a short task label.)")
|
|
606
|
+
elif choice == "4":
|
|
607
|
+
provider = cloud_fields["cloud_provider_hint"]
|
|
608
|
+
auth_kind = cloud_fields["cloud_auth_kind"]
|
|
609
|
+
print(" 1. Make sure `browserwright-daemon` v0.5+ (with cloud backend) is installed.")
|
|
610
|
+
print(f" 2. Provider hint: {provider}. Auth kind: {auth_kind}.")
|
|
611
|
+
if auth_kind == "bearer":
|
|
612
|
+
print(f" Export the token before starting the daemon:")
|
|
613
|
+
print(f" export {cloud_fields['cloud_token_env']}=<your-token>")
|
|
614
|
+
elif auth_kind == "basic":
|
|
615
|
+
print(f" Export the basic-auth env vars before starting the daemon:")
|
|
616
|
+
print(f" export {cloud_fields['cloud_username_env']}=<your-username>")
|
|
617
|
+
print(f" export {cloud_fields['cloud_password_env']}=<your-password>")
|
|
618
|
+
elif auth_kind == "mtls":
|
|
619
|
+
print(f" Cert / key paths (daemon reads these at serve startup):")
|
|
620
|
+
print(f" cert: {cloud_fields['cloud_cert_file']}")
|
|
621
|
+
print(f" key: {cloud_fields['cloud_key_file']}")
|
|
622
|
+
print(" 3. Start the single global daemon:")
|
|
623
|
+
print(" browserwright-daemon serve")
|
|
624
|
+
print(" The daemon reads the cloud provider/auth config written above")
|
|
625
|
+
print(" when cloud is selected as the shared upstream.")
|
|
626
|
+
print(" 4. Verify: `browserwright-daemon doctor --json` →")
|
|
627
|
+
print(" look for `cloud` backend `available=true` + `ws_url` set.")
|
|
628
|
+
return 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Three-tier memory (spec §C)."""
|
|
2
|
+
from .global_mem import ( # noqa: F401
|
|
3
|
+
GlobalMemory,
|
|
4
|
+
global_memory,
|
|
5
|
+
read_daemon_preferred_backend,
|
|
6
|
+
write_daemon_preferred_backend,
|
|
7
|
+
)
|
|
8
|
+
from .site_mem import ( # noqa: F401
|
|
9
|
+
SiteMemory,
|
|
10
|
+
bootstrap_site,
|
|
11
|
+
redact_check,
|
|
12
|
+
site_dir,
|
|
13
|
+
site_memory,
|
|
14
|
+
)
|
|
15
|
+
from .repl_mem import ReplMemory, repl_memory # noqa: F401
|