scalebrowser 0.2.0__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.
- scalebrowser/__init__.py +234 -0
- scalebrowser/_http.py +180 -0
- scalebrowser/_sync.py +604 -0
- scalebrowser/_version.py +3 -0
- scalebrowser/cdp.py +429 -0
- scalebrowser/client.py +825 -0
- scalebrowser/errors.py +97 -0
- scalebrowser/events.py +52 -0
- scalebrowser/models.py +861 -0
- scalebrowser/models_control.py +128 -0
- scalebrowser/models_identity.py +130 -0
- scalebrowser/models_runs.py +56 -0
- scalebrowser-0.2.0.dist-info/METADATA +170 -0
- scalebrowser-0.2.0.dist-info/RECORD +16 -0
- scalebrowser-0.2.0.dist-info/WHEEL +4 -0
- scalebrowser-0.2.0.dist-info/licenses/LICENSE +21 -0
scalebrowser/client.py
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"""The async Scalebrowser client — typed REST surface + a direct-CDP entry point.
|
|
2
|
+
|
|
3
|
+
Every endpoint path is defined here exactly once, so endpoint drift is a
|
|
4
|
+
one-file change (the transport in ``_http.py`` is the only other HTTP-aware
|
|
5
|
+
module). The driver plane is direct-CDP (``connect_cdp`` / ``launch``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from typing import Any, AsyncIterator, Mapping, Optional, Sequence, Union
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from ._http import AsyncTransport
|
|
18
|
+
from .cdp import CdpSession, connect_cdp
|
|
19
|
+
from .errors import ApiError, ErrorCode
|
|
20
|
+
from .events import iter_events
|
|
21
|
+
from .models import (
|
|
22
|
+
RunStep,
|
|
23
|
+
AuditStatus,
|
|
24
|
+
BulkAssignProxyBody,
|
|
25
|
+
BulkCreateBody,
|
|
26
|
+
BulkIdsBody,
|
|
27
|
+
CheckProxyConfigBody,
|
|
28
|
+
CredentialBundle,
|
|
29
|
+
CredentialImportResult,
|
|
30
|
+
CredentialMeta,
|
|
31
|
+
CreateGroupBody,
|
|
32
|
+
CreatePresetBody,
|
|
33
|
+
CreateProfileBody,
|
|
34
|
+
CreateProxyBody,
|
|
35
|
+
Event,
|
|
36
|
+
Extension,
|
|
37
|
+
ExtensionsResult,
|
|
38
|
+
Group,
|
|
39
|
+
Metrics,
|
|
40
|
+
MetricsAvailability,
|
|
41
|
+
PersonaConstraintOptions,
|
|
42
|
+
Preset,
|
|
43
|
+
Profile,
|
|
44
|
+
Proxy,
|
|
45
|
+
ProxyCheckResult,
|
|
46
|
+
RevealedCredential,
|
|
47
|
+
SessionExportBody,
|
|
48
|
+
SessionExportResult,
|
|
49
|
+
SessionImportBody,
|
|
50
|
+
StartProfileResult,
|
|
51
|
+
StopProfileResult,
|
|
52
|
+
UpdatePresetBody,
|
|
53
|
+
UpdateProfileBody,
|
|
54
|
+
UpdateProxyBody,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
from .models_control import (
|
|
58
|
+
Account,
|
|
59
|
+
ArtifactBytes,
|
|
60
|
+
ArtifactPutResult,
|
|
61
|
+
HealthStatus,
|
|
62
|
+
InterruptionLockView,
|
|
63
|
+
InterruptionRuleRow,
|
|
64
|
+
ReadyStatus,
|
|
65
|
+
SetInterruptionLockBody,
|
|
66
|
+
SetInterruptionRuleBody,
|
|
67
|
+
)
|
|
68
|
+
from .models_identity import (
|
|
69
|
+
BindInboxBody,
|
|
70
|
+
Inbox,
|
|
71
|
+
InboxBindings,
|
|
72
|
+
PasskeyRow,
|
|
73
|
+
PutInboxBody,
|
|
74
|
+
RevealCookiesBody,
|
|
75
|
+
RevealCookiesResult,
|
|
76
|
+
)
|
|
77
|
+
from .models_runs import ActivitySnapshot, AgentRun
|
|
78
|
+
|
|
79
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8787"
|
|
80
|
+
|
|
81
|
+
#: Mirrors ``CapacityConfig::default()`` — used by the metrics fallback.
|
|
82
|
+
DEFAULT_CAPACITY_MAX_CONCURRENT = 64
|
|
83
|
+
DEFAULT_CAPACITY_RAM_BUDGET_MB = 24_576
|
|
84
|
+
|
|
85
|
+
BodyLike = Union[BaseModel, Mapping[str, Any], None]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _payload(body: BodyLike) -> Optional[dict[str, Any]]:
|
|
89
|
+
"""Normalize a pydantic model / mapping request body to a JSON dict."""
|
|
90
|
+
if body is None:
|
|
91
|
+
return None
|
|
92
|
+
if isinstance(body, BaseModel):
|
|
93
|
+
return body.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
94
|
+
return dict(body)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _q(value: str) -> str:
|
|
98
|
+
return quote(value, safe="")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AsyncScalebrowserClient:
|
|
102
|
+
"""Async client for the Scalebrowser daemon REST + direct-CDP surfaces.
|
|
103
|
+
|
|
104
|
+
>>> async with AsyncScalebrowserClient(token="…") as sb:
|
|
105
|
+
... started = await sb.start_profile(profile_id, headless=True)
|
|
106
|
+
... async with await sb.connect_cdp(started, profile_id) as cdp:
|
|
107
|
+
... await cdp.navigate("https://example.com")
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
113
|
+
token: Optional[str] = None,
|
|
114
|
+
*,
|
|
115
|
+
timeout: float = 30.0,
|
|
116
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
117
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self._t = AsyncTransport(
|
|
120
|
+
base_url, token, timeout=timeout, transport=transport, http_client=http_client
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def transport(self) -> AsyncTransport:
|
|
125
|
+
return self._t
|
|
126
|
+
|
|
127
|
+
def __repr__(self) -> str:
|
|
128
|
+
masked = "***" if self._t.token else None
|
|
129
|
+
return f"AsyncScalebrowserClient(base_url={self._t.base_url!r}, token={masked!r})"
|
|
130
|
+
|
|
131
|
+
# ── profiles ──────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async def list_profiles(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
group: Optional[str] = None,
|
|
137
|
+
state: Optional[str] = None,
|
|
138
|
+
q: Optional[str] = None,
|
|
139
|
+
limit: Optional[int] = None,
|
|
140
|
+
offset: Optional[int] = None,
|
|
141
|
+
sort: Optional[str] = None,
|
|
142
|
+
order: Optional[str] = None,
|
|
143
|
+
) -> list[Profile]:
|
|
144
|
+
"""List profiles.
|
|
145
|
+
|
|
146
|
+
``sort`` (``created_at`` | ``name`` | ``runtime_state`` | ``last_open_at``)
|
|
147
|
+
and ``order`` (``asc`` | ``desc``) are applied by the DAEMON over the whole
|
|
148
|
+
filtered set — sorting a fetched page would order a minority of a paged
|
|
149
|
+
fleet while looking authoritative.
|
|
150
|
+
"""
|
|
151
|
+
data = await self._t.get(
|
|
152
|
+
"/v1/profiles",
|
|
153
|
+
params={
|
|
154
|
+
"group": group,
|
|
155
|
+
"state": state,
|
|
156
|
+
"q": q,
|
|
157
|
+
"limit": limit,
|
|
158
|
+
"offset": offset,
|
|
159
|
+
"sort": sort,
|
|
160
|
+
"order": order,
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
return [Profile.model_validate(item) for item in data]
|
|
164
|
+
|
|
165
|
+
async def get_profile(self, profile_id: str) -> Profile:
|
|
166
|
+
data = await self._t.get(f"/v1/profiles/{_q(profile_id)}")
|
|
167
|
+
return Profile.model_validate(data)
|
|
168
|
+
|
|
169
|
+
async def create_profile(self, body: Union[CreateProfileBody, Mapping[str, Any]]) -> Profile:
|
|
170
|
+
data = await self._t.post("/v1/profiles", _payload(body))
|
|
171
|
+
return Profile.model_validate(data)
|
|
172
|
+
|
|
173
|
+
async def update_profile(
|
|
174
|
+
self, profile_id: str, body: Union[UpdateProfileBody, Mapping[str, Any]]
|
|
175
|
+
) -> Profile:
|
|
176
|
+
data = await self._t.patch(f"/v1/profiles/{_q(profile_id)}", _payload(body))
|
|
177
|
+
return Profile.model_validate(data)
|
|
178
|
+
|
|
179
|
+
async def delete_profile(self, profile_id: str) -> None:
|
|
180
|
+
await self._t.delete(f"/v1/profiles/{_q(profile_id)}")
|
|
181
|
+
|
|
182
|
+
async def start_profile(self, profile_id: str, *, headless: Optional[bool] = None) -> StartProfileResult:
|
|
183
|
+
body: dict[str, Any] = {} if headless is None else {"headless": headless}
|
|
184
|
+
data = await self._t.post(f"/v1/profiles/{_q(profile_id)}/start", body)
|
|
185
|
+
return StartProfileResult.model_validate(data)
|
|
186
|
+
|
|
187
|
+
async def stop_profile(self, profile_id: str) -> StopProfileResult:
|
|
188
|
+
data = await self._t.post(f"/v1/profiles/{_q(profile_id)}/stop", {})
|
|
189
|
+
return StopProfileResult.model_validate(data)
|
|
190
|
+
|
|
191
|
+
# ── bulk (PRD §D2) ───────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
async def bulk_create_profiles(
|
|
194
|
+
self,
|
|
195
|
+
preset_id: str,
|
|
196
|
+
count: int,
|
|
197
|
+
*,
|
|
198
|
+
name_prefix: Optional[str] = None,
|
|
199
|
+
group_id: Optional[str] = None,
|
|
200
|
+
) -> list[Profile]:
|
|
201
|
+
body = BulkCreateBody(
|
|
202
|
+
preset_id=preset_id, count=count, name_prefix=name_prefix, group_id=group_id
|
|
203
|
+
)
|
|
204
|
+
data = await self._t.post("/v1/bulk/profiles", _payload(body))
|
|
205
|
+
return [Profile.model_validate(item) for item in data]
|
|
206
|
+
|
|
207
|
+
async def list_profile_ids(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
group: Optional[str] = None,
|
|
211
|
+
state: Optional[str] = None,
|
|
212
|
+
q: Optional[str] = None,
|
|
213
|
+
sort: Optional[str] = None,
|
|
214
|
+
order: Optional[str] = None,
|
|
215
|
+
) -> dict[str, Any]:
|
|
216
|
+
"""Every id matching the filters, unpaged — what "act on all matches" needs.
|
|
217
|
+
|
|
218
|
+
``list_profiles`` is paged, so acting on "everything" from it only ever
|
|
219
|
+
covers the page that was fetched. This costs one request and carries no
|
|
220
|
+
persona payloads.
|
|
221
|
+
"""
|
|
222
|
+
return await self._t.get(
|
|
223
|
+
"/v1/profiles/ids",
|
|
224
|
+
params={"group": group, "state": state, "q": q, "sort": sort, "order": order},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async def bulk_start(self, ids: Sequence[str], *, headless: Optional[bool] = None) -> Any:
|
|
228
|
+
"""Start a batch.
|
|
229
|
+
|
|
230
|
+
``headless`` is the ONE visibility decision for the run; omitted means
|
|
231
|
+
headless, the right default for an unattended client. The daemon stops at
|
|
232
|
+
the machine's capacity limit and reports ``started`` / ``remaining`` /
|
|
233
|
+
``stopped_reason`` instead of failing every remaining id.
|
|
234
|
+
"""
|
|
235
|
+
return await self._t.post("/v1/bulk/start", {"ids": list(ids), "headless": headless})
|
|
236
|
+
|
|
237
|
+
async def bulk_stop(self, ids: Sequence[str]) -> Any:
|
|
238
|
+
return await self._t.post("/v1/bulk/stop", _payload(BulkIdsBody(ids=list(ids))))
|
|
239
|
+
|
|
240
|
+
async def bulk_delete(self, ids: Sequence[str]) -> Any:
|
|
241
|
+
return await self._t.post("/v1/bulk/delete", _payload(BulkIdsBody(ids=list(ids))))
|
|
242
|
+
|
|
243
|
+
async def bulk_assign_proxy(self, ids: Sequence[str], proxy_id: str) -> Any:
|
|
244
|
+
body = BulkAssignProxyBody(ids=list(ids), proxy_id=proxy_id)
|
|
245
|
+
return await self._t.post("/v1/bulk/assign-proxy", _payload(body))
|
|
246
|
+
|
|
247
|
+
async def bulk_assign_extensions(
|
|
248
|
+
self, ids: Sequence[str], ext_refs: Sequence[str]
|
|
249
|
+
) -> Any:
|
|
250
|
+
"""Give every id exactly this set of extensions.
|
|
251
|
+
|
|
252
|
+
REPLACES rather than adds, the same shape as assigning a proxy. An empty
|
|
253
|
+
list is a legitimate "none"; an id the library does not hold refuses the
|
|
254
|
+
whole call.
|
|
255
|
+
"""
|
|
256
|
+
return await self._t.post(
|
|
257
|
+
"/v1/bulk/assign-extensions",
|
|
258
|
+
{"ids": list(ids), "ext_refs": list(ext_refs)},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# ── groups ────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
async def list_groups(self) -> list[Group]:
|
|
264
|
+
data = await self._t.get("/v1/groups")
|
|
265
|
+
return [Group.model_validate(item) for item in data]
|
|
266
|
+
|
|
267
|
+
async def create_group(self, body: Union[CreateGroupBody, Mapping[str, Any]]) -> Group:
|
|
268
|
+
data = await self._t.post("/v1/groups", _payload(body))
|
|
269
|
+
return Group.model_validate(data)
|
|
270
|
+
|
|
271
|
+
async def get_group(self, group_id: str) -> Group:
|
|
272
|
+
data = await self._t.get(f"/v1/groups/{_q(group_id)}")
|
|
273
|
+
return Group.model_validate(data)
|
|
274
|
+
|
|
275
|
+
async def update_group(self, group_id: str, body: Mapping[str, Any]) -> Group:
|
|
276
|
+
data = await self._t.patch(f"/v1/groups/{_q(group_id)}", _payload(body))
|
|
277
|
+
return Group.model_validate(data)
|
|
278
|
+
|
|
279
|
+
async def delete_group(self, group_id: str) -> None:
|
|
280
|
+
await self._t.delete(f"/v1/groups/{_q(group_id)}")
|
|
281
|
+
|
|
282
|
+
# ── presets ───────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
async def list_presets(self) -> list[Preset]:
|
|
285
|
+
data = await self._t.get("/v1/presets")
|
|
286
|
+
return [Preset.model_validate(item) for item in data]
|
|
287
|
+
|
|
288
|
+
async def create_preset(self, body: Union[CreatePresetBody, Mapping[str, Any]]) -> Preset:
|
|
289
|
+
data = await self._t.post("/v1/presets", _payload(body))
|
|
290
|
+
return Preset.model_validate(data)
|
|
291
|
+
|
|
292
|
+
async def get_preset(self, preset_id: str) -> Preset:
|
|
293
|
+
data = await self._t.get(f"/v1/presets/{_q(preset_id)}")
|
|
294
|
+
return Preset.model_validate(data)
|
|
295
|
+
|
|
296
|
+
async def update_preset(
|
|
297
|
+
self, preset_id: str, body: Union[UpdatePresetBody, Mapping[str, Any]]
|
|
298
|
+
) -> Preset:
|
|
299
|
+
data = await self._t.patch(f"/v1/presets/{_q(preset_id)}", _payload(body))
|
|
300
|
+
return Preset.model_validate(data)
|
|
301
|
+
|
|
302
|
+
async def delete_preset(self, preset_id: str) -> None:
|
|
303
|
+
await self._t.delete(f"/v1/presets/{_q(preset_id)}")
|
|
304
|
+
|
|
305
|
+
async def get_persona_constraints(self) -> PersonaConstraintOptions:
|
|
306
|
+
"""The values a preset's ``constraints`` may take.
|
|
307
|
+
|
|
308
|
+
Ask rather than hardcode: the region list comes from the daemon's
|
|
309
|
+
embedded persona model, so a pinned copy drifts the moment that model
|
|
310
|
+
changes and every create returns a 400.
|
|
311
|
+
"""
|
|
312
|
+
data = await self._t.get("/v1/persona/constraints")
|
|
313
|
+
return PersonaConstraintOptions.model_validate(data)
|
|
314
|
+
|
|
315
|
+
# ── proxies ───────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
async def list_proxies(self) -> list[Proxy]:
|
|
318
|
+
data = await self._t.get("/v1/proxies")
|
|
319
|
+
return [Proxy.model_validate(item) for item in data]
|
|
320
|
+
|
|
321
|
+
async def create_proxy(self, body: Union[CreateProxyBody, Mapping[str, Any]]) -> Proxy:
|
|
322
|
+
data = await self._t.post("/v1/proxies", _payload(body))
|
|
323
|
+
return Proxy.model_validate(data)
|
|
324
|
+
|
|
325
|
+
async def get_proxy(self, proxy_id: str) -> Proxy:
|
|
326
|
+
data = await self._t.get(f"/v1/proxies/{_q(proxy_id)}")
|
|
327
|
+
return Proxy.model_validate(data)
|
|
328
|
+
|
|
329
|
+
async def update_proxy(
|
|
330
|
+
self, proxy_id: str, body: Union[UpdateProxyBody, Mapping[str, Any]]
|
|
331
|
+
) -> Proxy:
|
|
332
|
+
data = await self._t.patch(f"/v1/proxies/{_q(proxy_id)}", _payload(body))
|
|
333
|
+
return Proxy.model_validate(data)
|
|
334
|
+
|
|
335
|
+
async def delete_proxy(self, proxy_id: str) -> None:
|
|
336
|
+
await self._t.delete(f"/v1/proxies/{_q(proxy_id)}")
|
|
337
|
+
|
|
338
|
+
async def check_proxy(self, proxy_id: str) -> ProxyCheckResult:
|
|
339
|
+
data = await self._t.post(f"/v1/proxies/{_q(proxy_id)}/check", {})
|
|
340
|
+
return ProxyCheckResult.model_validate(data)
|
|
341
|
+
|
|
342
|
+
async def check_proxy_config(
|
|
343
|
+
self, body: Union[CheckProxyConfigBody, Mapping[str, Any]]
|
|
344
|
+
) -> ProxyCheckResult:
|
|
345
|
+
"""Probe a proxy config WITHOUT persisting it — validate before committing."""
|
|
346
|
+
data = await self._t.post("/v1/proxies/check", _payload(body))
|
|
347
|
+
return ProxyCheckResult.model_validate(data)
|
|
348
|
+
|
|
349
|
+
# ── live detector audit ──────────────────────────────────────────────────
|
|
350
|
+
#
|
|
351
|
+
# Drives the profile's OWN running browser through ~15 third-party fingerprint
|
|
352
|
+
# sites and keeps what each one saw. The profile must be RUNNING; the run takes
|
|
353
|
+
# minutes, so ``start_audit`` returns immediately and you poll ``get_audit``.
|
|
354
|
+
|
|
355
|
+
async def start_audit(self, profile_id: str) -> AuditStatus:
|
|
356
|
+
"""Queue a run. ``409`` if the profile is not running, or one is in flight."""
|
|
357
|
+
data = await self._t.post(f"/v1/profiles/{_q(profile_id)}/audit", {})
|
|
358
|
+
return AuditStatus.model_validate(data)
|
|
359
|
+
|
|
360
|
+
async def get_audit(self, profile_id: str) -> AuditStatus:
|
|
361
|
+
"""State plus the last stored report."""
|
|
362
|
+
data = await self._t.get(f"/v1/profiles/{_q(profile_id)}/audit")
|
|
363
|
+
return AuditStatus.model_validate(data)
|
|
364
|
+
|
|
365
|
+
# ── platform credentials ─────────────────────────────────────────────────
|
|
366
|
+
#
|
|
367
|
+
# Stored logins, so an agent can sign a profile into a platform WITHOUT ever
|
|
368
|
+
# holding the password: the daemon decrypts and types it. Only
|
|
369
|
+
# :meth:`reveal_credential` and the bundle calls return plaintext, and all of
|
|
370
|
+
# them need the vault password — the bearer token alone is not enough, because
|
|
371
|
+
# the MCP agent layer authenticates with exactly the same one.
|
|
372
|
+
#
|
|
373
|
+
# ``platform`` is whatever you have: the service's domain ("discord.com"),
|
|
374
|
+
# its name ("Discord") or a host under it ("www.discord.com"). The daemon
|
|
375
|
+
# resolves all of them to ONE key — the registrable domain — and every
|
|
376
|
+
# response echoes that key, so a later :meth:`delete_credential` addresses
|
|
377
|
+
# the same row. Pass a whole sign-in URL only to the MCP tools: here the
|
|
378
|
+
# platform is a path segment, and a "/" in it is refused rather than read
|
|
379
|
+
# as a path.
|
|
380
|
+
|
|
381
|
+
async def list_credentials(self, profile_id: str) -> list[CredentialMeta]:
|
|
382
|
+
"""Which platforms this profile can log into. Never a secret."""
|
|
383
|
+
data = await self._t.get(f"/v1/profiles/{_q(profile_id)}/credentials")
|
|
384
|
+
return [CredentialMeta.model_validate(c) for c in (data or [])]
|
|
385
|
+
|
|
386
|
+
async def put_credential(
|
|
387
|
+
self,
|
|
388
|
+
profile_id: str,
|
|
389
|
+
platform: str,
|
|
390
|
+
*,
|
|
391
|
+
username: str | None = None,
|
|
392
|
+
password: str | None = None,
|
|
393
|
+
totp_secret: str | None = None,
|
|
394
|
+
login_url: str | None = None,
|
|
395
|
+
) -> CredentialMeta:
|
|
396
|
+
"""Store or update one login.
|
|
397
|
+
|
|
398
|
+
An omitted secret keeps its stored value rather than clearing it — an edit
|
|
399
|
+
form never saw the password in the clear, so it cannot resend one. Delete
|
|
400
|
+
the row to remove a credential. ``totp_secret`` may be pasted exactly as a
|
|
401
|
+
site presents it (grouped, lower case); it is normalised and test-decoded
|
|
402
|
+
by the daemon, so a broken key is refused here rather than mid-login.
|
|
403
|
+
"""
|
|
404
|
+
body = {
|
|
405
|
+
"username": username,
|
|
406
|
+
"password": password,
|
|
407
|
+
"totp_secret": totp_secret,
|
|
408
|
+
"login_url": login_url,
|
|
409
|
+
}
|
|
410
|
+
data = await self._t.request(
|
|
411
|
+
"PUT",
|
|
412
|
+
f"/v1/profiles/{_q(profile_id)}/credentials/{_q(platform)}",
|
|
413
|
+
json={k: v for k, v in body.items() if v is not None},
|
|
414
|
+
)
|
|
415
|
+
return CredentialMeta.model_validate(data)
|
|
416
|
+
|
|
417
|
+
async def delete_credential(self, profile_id: str, platform: str) -> None:
|
|
418
|
+
"""Forget one login. The account itself is untouched."""
|
|
419
|
+
await self._t.delete(f"/v1/profiles/{_q(profile_id)}/credentials/{_q(platform)}")
|
|
420
|
+
|
|
421
|
+
async def reveal_credential(
|
|
422
|
+
self, profile_id: str, platform: str, vault_password: str
|
|
423
|
+
) -> RevealedCredential:
|
|
424
|
+
"""Read one login back in the clear. Needs the vault password."""
|
|
425
|
+
data = await self._t.post(
|
|
426
|
+
f"/v1/profiles/{_q(profile_id)}/credentials/{_q(platform)}/reveal",
|
|
427
|
+
{"vault_password": vault_password},
|
|
428
|
+
)
|
|
429
|
+
return RevealedCredential.model_validate(data)
|
|
430
|
+
|
|
431
|
+
async def export_credentials(
|
|
432
|
+
self, profile_id: str, vault_password: str, password: str
|
|
433
|
+
) -> CredentialBundle:
|
|
434
|
+
"""Pack this profile's logins into a bundle sealed with ``password``.
|
|
435
|
+
|
|
436
|
+
``password`` is deliberately separate from the vault password: a backup
|
|
437
|
+
gets handed to another machine, and that must not also hand over access to
|
|
438
|
+
this daemon. It is the answer to a lost master key — a daemon-generated
|
|
439
|
+
password exists nowhere else.
|
|
440
|
+
"""
|
|
441
|
+
data = await self._t.post(
|
|
442
|
+
f"/v1/profiles/{_q(profile_id)}/credential-bundle/export",
|
|
443
|
+
{"vault_password": vault_password, "password": password},
|
|
444
|
+
)
|
|
445
|
+
return CredentialBundle.model_validate(data)
|
|
446
|
+
|
|
447
|
+
async def import_credentials(
|
|
448
|
+
self, profile_id: str, vault_password: str, password: str, bundle: str
|
|
449
|
+
) -> CredentialImportResult:
|
|
450
|
+
"""Restore a bundle into ``profile_id``."""
|
|
451
|
+
data = await self._t.post(
|
|
452
|
+
f"/v1/profiles/{_q(profile_id)}/credential-bundle/import",
|
|
453
|
+
{"vault_password": vault_password, "password": password, "bundle": bundle},
|
|
454
|
+
)
|
|
455
|
+
return CredentialImportResult.model_validate(data)
|
|
456
|
+
|
|
457
|
+
async def vault_status(self) -> VaultStatus:
|
|
458
|
+
"""Has a vault password been set on this daemon?"""
|
|
459
|
+
data = await self._t.get("/v1/vault")
|
|
460
|
+
return VaultStatus.model_validate(data)
|
|
461
|
+
|
|
462
|
+
async def set_vault_password(
|
|
463
|
+
self, new_password: str, current_password: str | None = None
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Set the vault password, or change it (then ``current_password`` is required)."""
|
|
466
|
+
body: dict[str, str] = {"new_password": new_password}
|
|
467
|
+
if current_password is not None:
|
|
468
|
+
body["current_password"] = current_password
|
|
469
|
+
await self._t.request("PUT", "/v1/vault", json=body)
|
|
470
|
+
|
|
471
|
+
# ── extension library (daemon-wide) ──────────────────────────────────────
|
|
472
|
+
#
|
|
473
|
+
# Upload a ``.crx``; the daemon lifts its public key out of the CRX header and
|
|
474
|
+
# writes it into the unpacked ``manifest.json``, so every profile loads the
|
|
475
|
+
# package under its CANONICAL Web-Store id. A key-less package is refused — it
|
|
476
|
+
# would take a path-derived id, identical across the fleet.
|
|
477
|
+
|
|
478
|
+
async def list_library_extensions(self) -> list[Extension]:
|
|
479
|
+
"""The library, newest first."""
|
|
480
|
+
data = await self._t.get("/v1/extensions")
|
|
481
|
+
return [Extension.model_validate(e) for e in (data or [])]
|
|
482
|
+
|
|
483
|
+
async def upload_extension(self, crx: bytes) -> Extension:
|
|
484
|
+
"""Add a package from raw ``.crx`` bytes.
|
|
485
|
+
|
|
486
|
+
A new VERSION is stocked alongside the existing ones (profiles draw their
|
|
487
|
+
own); re-uploading the same version replaces it.
|
|
488
|
+
"""
|
|
489
|
+
data = await self._t.request("POST", "/v1/extensions", content=crx)
|
|
490
|
+
return Extension.model_validate(data)
|
|
491
|
+
|
|
492
|
+
async def get_library_extension(self, ext_id: str) -> Extension:
|
|
493
|
+
"""One library entry."""
|
|
494
|
+
data = await self._t.get(f"/v1/extensions/{_q(ext_id)}")
|
|
495
|
+
return Extension.model_validate(data)
|
|
496
|
+
|
|
497
|
+
async def delete_library_extension(self, ext_id: str) -> None:
|
|
498
|
+
"""Remove EVERY version of a package, its files and every assignment of it."""
|
|
499
|
+
await self._t.delete(f"/v1/extensions/{_q(ext_id)}")
|
|
500
|
+
|
|
501
|
+
async def delete_library_extension_version(self, ext_id: str, version: str) -> None:
|
|
502
|
+
"""Remove ONE stocked version.
|
|
503
|
+
|
|
504
|
+
Assignments survive — they name the extension, not the version — unless
|
|
505
|
+
this was the last version, in which case they go with it.
|
|
506
|
+
"""
|
|
507
|
+
await self._t.delete(f"/v1/extensions/{_q(ext_id)}/{_q(version)}")
|
|
508
|
+
|
|
509
|
+
# ── extensions per profile ───────────────────────────────────────────────
|
|
510
|
+
#
|
|
511
|
+
# ``ext_ref`` is a LIBRARY ID, not a path. The launch copies each assigned
|
|
512
|
+
# package into the profile's own tree and loads that, so files and state stay
|
|
513
|
+
# per-profile while the id stays shared (the camouflage, not the leak).
|
|
514
|
+
|
|
515
|
+
async def list_extensions(self, profile_id: str) -> ExtensionsResult:
|
|
516
|
+
"""The profile's assigned set, resolved, + the launch policy."""
|
|
517
|
+
data = await self._t.get(f"/v1/profiles/{_q(profile_id)}/extensions")
|
|
518
|
+
return ExtensionsResult.model_validate(data)
|
|
519
|
+
|
|
520
|
+
async def attach_extension(self, profile_id: str, ext_ref: str) -> ExtensionsResult:
|
|
521
|
+
"""Assign a library id (idempotent) → the updated set + policy. 404 if unknown."""
|
|
522
|
+
data = await self._t.post(
|
|
523
|
+
f"/v1/profiles/{_q(profile_id)}/extensions", {"ext_ref": ext_ref}
|
|
524
|
+
)
|
|
525
|
+
return ExtensionsResult.model_validate(data)
|
|
526
|
+
|
|
527
|
+
async def detach_extension(self, profile_id: str, ext_ref: str) -> ExtensionsResult:
|
|
528
|
+
"""Unassign a library id (idempotent) → the updated set + policy."""
|
|
529
|
+
data = await self._t.delete(
|
|
530
|
+
f"/v1/profiles/{_q(profile_id)}/extensions", {"ext_ref": ext_ref}
|
|
531
|
+
)
|
|
532
|
+
return ExtensionsResult.model_validate(data)
|
|
533
|
+
|
|
534
|
+
# ── session bundles (AC-ST-002/003) ──────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
async def export_session(
|
|
537
|
+
self, profile_id: str, password: str, *, kinds: Optional[Sequence[str]] = None
|
|
538
|
+
) -> SessionExportResult:
|
|
539
|
+
body = SessionExportBody(password=password, kinds=list(kinds) if kinds else None)
|
|
540
|
+
data = await self._t.post(f"/v1/profiles/{_q(profile_id)}/session/export", _payload(body))
|
|
541
|
+
return SessionExportResult.model_validate(data)
|
|
542
|
+
|
|
543
|
+
async def import_session(self, profile_id: str, password: str, bundle: str) -> Any:
|
|
544
|
+
body = SessionImportBody(password=password, bundle=bundle)
|
|
545
|
+
return await self._t.post(f"/v1/profiles/{_q(profile_id)}/session/import", _payload(body))
|
|
546
|
+
|
|
547
|
+
# ── trusted input (G8) ───────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
async def send_input(self, profile_id: str, body: Mapping[str, Any]) -> Any:
|
|
550
|
+
return await self._t.post(f"/v1/profiles/{_q(profile_id)}/input", dict(body))
|
|
551
|
+
|
|
552
|
+
# ── metrics (with fallback) ──────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
async def get_metrics(self) -> Metrics:
|
|
555
|
+
try:
|
|
556
|
+
data = await self._t.get("/v1/metrics")
|
|
557
|
+
except ApiError as err:
|
|
558
|
+
if err.status == 404 or err.code == ErrorCode.NOT_FOUND:
|
|
559
|
+
return self._derive_metrics(await self.list_profiles(limit=10_000))
|
|
560
|
+
raise
|
|
561
|
+
return Metrics.model_validate(data)
|
|
562
|
+
|
|
563
|
+
@staticmethod
|
|
564
|
+
def _derive_metrics(profiles: Sequence[Profile]) -> Metrics:
|
|
565
|
+
running = sum(1 for p in profiles if p.runtime_state == "running")
|
|
566
|
+
unavailable = MetricsAvailability(
|
|
567
|
+
ram="unavailable",
|
|
568
|
+
cpu="unavailable",
|
|
569
|
+
gpu="unavailable",
|
|
570
|
+
vram="unavailable",
|
|
571
|
+
per_profile="unavailable",
|
|
572
|
+
)
|
|
573
|
+
return Metrics(
|
|
574
|
+
running=running,
|
|
575
|
+
capacity_max_concurrent=DEFAULT_CAPACITY_MAX_CONCURRENT,
|
|
576
|
+
ram_used_mb=None,
|
|
577
|
+
ram_budget_mb=DEFAULT_CAPACITY_RAM_BUDGET_MB,
|
|
578
|
+
vram_used_mb=None,
|
|
579
|
+
vram_budget_mb=None,
|
|
580
|
+
cpu_pct=None,
|
|
581
|
+
gpu_pct=None,
|
|
582
|
+
source="derived",
|
|
583
|
+
sampled_at=None,
|
|
584
|
+
availability=unavailable,
|
|
585
|
+
profiles=[],
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
# ── agent runs ───────────────────────────────────────────────────────────
|
|
589
|
+
|
|
590
|
+
async def list_runs(
|
|
591
|
+
self,
|
|
592
|
+
*,
|
|
593
|
+
profile_id: Optional[str] = None,
|
|
594
|
+
limit: Optional[int] = None,
|
|
595
|
+
offset: Optional[int] = None,
|
|
596
|
+
) -> list[AgentRun]:
|
|
597
|
+
"""Runs, newest first. Read-only, all of it."""
|
|
598
|
+
data = await self._t.get(
|
|
599
|
+
"/v1/runs", params={"profile_id": profile_id, "limit": limit, "offset": offset}
|
|
600
|
+
)
|
|
601
|
+
return [AgentRun.model_validate(row) for row in data or []]
|
|
602
|
+
|
|
603
|
+
async def get_run(self, run_id: str) -> AgentRun:
|
|
604
|
+
data = await self._t.get(f"/v1/runs/{_q(run_id)}")
|
|
605
|
+
return AgentRun.model_validate(data)
|
|
606
|
+
|
|
607
|
+
async def list_run_steps(
|
|
608
|
+
self, run_id: str, *, limit: Optional[int] = None, offset: Optional[int] = None
|
|
609
|
+
) -> list[RunStep]:
|
|
610
|
+
"""A run's steps, oldest first — the order they happened in."""
|
|
611
|
+
data = await self._t.get(
|
|
612
|
+
f"/v1/runs/{_q(run_id)}/steps", params={"limit": limit, "offset": offset}
|
|
613
|
+
)
|
|
614
|
+
return [RunStep.model_validate(row) for row in data or []]
|
|
615
|
+
|
|
616
|
+
async def get_run_shot(self, run_id: str, seq: int) -> ArtifactBytes:
|
|
617
|
+
"""The still taken at one step, as JPEG bytes.
|
|
618
|
+
|
|
619
|
+
The picture shows a customer's account, so it is decrypted only on the
|
|
620
|
+
way out and never cached anywhere shared.
|
|
621
|
+
"""
|
|
622
|
+
content, content_type, filename = await self._t.get_bytes(
|
|
623
|
+
f"/v1/runs/{_q(run_id)}/shots/{seq}"
|
|
624
|
+
)
|
|
625
|
+
return ArtifactBytes(data=content, content_type=content_type, filename=filename)
|
|
626
|
+
|
|
627
|
+
async def get_activity(self) -> ActivitySnapshot:
|
|
628
|
+
"""What every running profile currently shows."""
|
|
629
|
+
return ActivitySnapshot.model_validate(await self._t.get("/v1/activity"))
|
|
630
|
+
|
|
631
|
+
# ── mailboxes ────────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
async def list_inboxes(self) -> list[Inbox]:
|
|
634
|
+
"""Mailboxes; passwords are never returned."""
|
|
635
|
+
data = await self._t.get("/v1/inboxes")
|
|
636
|
+
return [Inbox.model_validate(row) for row in data or []]
|
|
637
|
+
|
|
638
|
+
async def create_inbox(self, body: Union[PutInboxBody, Mapping[str, Any]]) -> Inbox:
|
|
639
|
+
return Inbox.model_validate(await self._t.post("/v1/inboxes", _payload(body)))
|
|
640
|
+
|
|
641
|
+
async def update_inbox(
|
|
642
|
+
self, inbox_id: str, body: Union[PutInboxBody, Mapping[str, Any]]
|
|
643
|
+
) -> Inbox:
|
|
644
|
+
"""An omitted ``password`` leaves the stored one unchanged."""
|
|
645
|
+
return Inbox.model_validate(await self._t.put(f"/v1/inboxes/{_q(inbox_id)}", _payload(body)))
|
|
646
|
+
|
|
647
|
+
async def delete_inbox(self, inbox_id: str) -> None:
|
|
648
|
+
await self._t.delete(f"/v1/inboxes/{_q(inbox_id)}")
|
|
649
|
+
|
|
650
|
+
async def get_inbox_bindings(self, profile_id: str) -> InboxBindings:
|
|
651
|
+
"""Which mailbox this profile's confirmation codes arrive in, per channel."""
|
|
652
|
+
data = await self._t.get(f"/v1/profiles/{_q(profile_id)}/inbox")
|
|
653
|
+
return InboxBindings.model_validate(data)
|
|
654
|
+
|
|
655
|
+
async def bind_inbox(
|
|
656
|
+
self, profile_id: str, body: Union[BindInboxBody, Mapping[str, Any]]
|
|
657
|
+
) -> InboxBindings:
|
|
658
|
+
data = await self._t.put(f"/v1/profiles/{_q(profile_id)}/inbox", _payload(body))
|
|
659
|
+
return InboxBindings.model_validate(data)
|
|
660
|
+
|
|
661
|
+
async def unbind_inbox(self, profile_id: str, channel: str) -> None:
|
|
662
|
+
await self._t.delete(f"/v1/profiles/{_q(profile_id)}/inbox/{_q(channel)}")
|
|
663
|
+
|
|
664
|
+
# ── passkeys ─────────────────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
async def list_passkeys(self, profile_id: str) -> list[PasskeyRow]:
|
|
667
|
+
"""What this profile can sign in to without a password. Metadata only."""
|
|
668
|
+
data = await self._t.get(f"/v1/profiles/{_q(profile_id)}/passkeys")
|
|
669
|
+
return [PasskeyRow.model_validate(row) for row in data or []]
|
|
670
|
+
|
|
671
|
+
async def delete_passkey(self, profile_id: str, credential_id: str) -> None:
|
|
672
|
+
"""Retire a passkey.
|
|
673
|
+
|
|
674
|
+
Calling it twice is meaningful: the first call retires a live key and
|
|
675
|
+
leaves a headstone, the second clears the headstone for good.
|
|
676
|
+
"""
|
|
677
|
+
await self._t.delete(f"/v1/profiles/{_q(profile_id)}/passkeys/{_q(credential_id)}")
|
|
678
|
+
|
|
679
|
+
# ── interruptions ────────────────────────────────────────────────────────
|
|
680
|
+
|
|
681
|
+
async def list_interruption_locks(self) -> InterruptionLockView:
|
|
682
|
+
"""Which browser surfaces are locked shut, and what actually applies."""
|
|
683
|
+
return InterruptionLockView.model_validate(await self._t.get("/v1/interruptions/locks"))
|
|
684
|
+
|
|
685
|
+
async def set_interruption_lock(
|
|
686
|
+
self, body: Union[SetInterruptionLockBody, Mapping[str, Any]]
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Omit ``profile_id`` to set the account-wide default."""
|
|
689
|
+
await self._t.put("/v1/interruptions/locks", _payload(body))
|
|
690
|
+
|
|
691
|
+
async def list_interruption_rules(self) -> list[InterruptionRuleRow]:
|
|
692
|
+
"""Standing answers per origin and kind."""
|
|
693
|
+
data = await self._t.get("/v1/interruptions/rules")
|
|
694
|
+
return [InterruptionRuleRow.model_validate(row) for row in data or []]
|
|
695
|
+
|
|
696
|
+
async def set_interruption_rule(
|
|
697
|
+
self, body: Union[SetInterruptionRuleBody, Mapping[str, Any]]
|
|
698
|
+
) -> None:
|
|
699
|
+
await self._t.put("/v1/interruptions/rules", _payload(body))
|
|
700
|
+
|
|
701
|
+
async def delete_interruption_rule(
|
|
702
|
+
self, origin: str, kind: str, profile_id: Optional[str] = None
|
|
703
|
+
) -> None:
|
|
704
|
+
await self._t.request(
|
|
705
|
+
"DELETE",
|
|
706
|
+
"/v1/interruptions/rules",
|
|
707
|
+
params={"origin": origin, "kind": kind, "profile_id": profile_id},
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# ── artifacts ────────────────────────────────────────────────────────────
|
|
711
|
+
|
|
712
|
+
async def put_artifact(
|
|
713
|
+
self, profile_id: str, data: bytes, name: Optional[str] = None
|
|
714
|
+
) -> ArtifactPutResult:
|
|
715
|
+
"""Hand the daemon a file so ``interact action=upload`` has something to attach.
|
|
716
|
+
|
|
717
|
+
The bytes are bound to the PROFILE, not to a reservation: set a file up
|
|
718
|
+
before leasing, and a lease released mid-task does not destroy it.
|
|
719
|
+
"""
|
|
720
|
+
result = await self._t.request(
|
|
721
|
+
"POST",
|
|
722
|
+
f"/v1/profiles/{_q(profile_id)}/artifacts",
|
|
723
|
+
content=data,
|
|
724
|
+
params={"name": name},
|
|
725
|
+
)
|
|
726
|
+
return ArtifactPutResult.model_validate(result)
|
|
727
|
+
|
|
728
|
+
async def get_artifact(self, artifact_id: str) -> ArtifactBytes:
|
|
729
|
+
"""Fetch one artifact's bytes — a screenshot, a download, a saved PDF.
|
|
730
|
+
|
|
731
|
+
A miss is a plain 404 whether the id never existed, expired, or belonged
|
|
732
|
+
to a released lease: the caller's next move is the same in all three.
|
|
733
|
+
"""
|
|
734
|
+
content, content_type, filename = await self._t.get_bytes(
|
|
735
|
+
f"/v1/artifacts/{_q(artifact_id)}"
|
|
736
|
+
)
|
|
737
|
+
return ArtifactBytes(data=content, content_type=content_type, filename=filename)
|
|
738
|
+
|
|
739
|
+
# ── cookies ──────────────────────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
async def reveal_cookies(
|
|
742
|
+
self, profile_id: str, body: Union[RevealCookiesBody, Mapping[str, Any]]
|
|
743
|
+
) -> RevealCookiesResult:
|
|
744
|
+
"""The one door a cookie VALUE leaves through, and it needs the vault password.
|
|
745
|
+
|
|
746
|
+
A session cookie skips both the password and the second factor, which is
|
|
747
|
+
why every other cookie surface answers with metadata only.
|
|
748
|
+
"""
|
|
749
|
+
data = await self._t.post(f"/v1/profiles/{_q(profile_id)}/cookies/reveal", _payload(body))
|
|
750
|
+
return RevealCookiesResult.model_validate(data)
|
|
751
|
+
|
|
752
|
+
# ── account + health ─────────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
async def get_account(self) -> Account:
|
|
755
|
+
"""Licence state for display. The launch gate stays the authority on starts.
|
|
756
|
+
|
|
757
|
+
A daemon predating the route answers 404, and that is not an error worth
|
|
758
|
+
raising: it means "self-hosted, no control plane", which is exactly what
|
|
759
|
+
``licensed=False`` says. Same tolerance as :meth:`get_metrics`.
|
|
760
|
+
"""
|
|
761
|
+
try:
|
|
762
|
+
data = await self._t.get("/v1/account")
|
|
763
|
+
except ApiError as err:
|
|
764
|
+
if err.status == 404:
|
|
765
|
+
return Account()
|
|
766
|
+
raise
|
|
767
|
+
return Account.model_validate(data)
|
|
768
|
+
|
|
769
|
+
async def health(self) -> HealthStatus:
|
|
770
|
+
""""Is the daemon alive?" Public, needs no token."""
|
|
771
|
+
return HealthStatus.model_validate(await self._t.get("/health"))
|
|
772
|
+
|
|
773
|
+
async def ready(self) -> ReadyStatus:
|
|
774
|
+
""""Can it serve?" Local facts only — the control plane is not consulted."""
|
|
775
|
+
return ReadyStatus.model_validate(await self._t.get("/health/ready"))
|
|
776
|
+
|
|
777
|
+
# ── events (SSE) ─────────────────────────────────────────────────────────
|
|
778
|
+
|
|
779
|
+
async def events(self) -> AsyncIterator[Event]:
|
|
780
|
+
"""Async-iterate the daemon lifecycle event stream (``GET /v1/events``)."""
|
|
781
|
+
async for event in iter_events(self._t):
|
|
782
|
+
yield event
|
|
783
|
+
|
|
784
|
+
# ── direct-CDP driver plane ──────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
async def connect_cdp(
|
|
787
|
+
self,
|
|
788
|
+
target: Union[StartProfileResult, str],
|
|
789
|
+
profile_id: Optional[str] = None,
|
|
790
|
+
*,
|
|
791
|
+
attach: bool = True,
|
|
792
|
+
) -> CdpSession:
|
|
793
|
+
"""Open a direct-CDP session for a started profile.
|
|
794
|
+
|
|
795
|
+
``target`` is the :class:`StartProfileResult` from :meth:`start_profile`
|
|
796
|
+
(or a raw ``cdp_ws`` URL). Pass ``profile_id`` to enable ``humanize_*``.
|
|
797
|
+
"""
|
|
798
|
+
cdp_ws = target.cdp_ws if isinstance(target, StartProfileResult) else target
|
|
799
|
+
return await connect_cdp(
|
|
800
|
+
cdp_ws, token=self._t.token, transport=self._t, profile_id=profile_id, attach=attach
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
@asynccontextmanager
|
|
804
|
+
async def launch(self, profile_id: str, *, headless: bool = True) -> AsyncIterator[CdpSession]:
|
|
805
|
+
"""Start a profile, yield a connected CDP session, and stop it on exit."""
|
|
806
|
+
result = await self.start_profile(profile_id, headless=headless)
|
|
807
|
+
cdp = await self.connect_cdp(result, profile_id=profile_id)
|
|
808
|
+
try:
|
|
809
|
+
yield cdp
|
|
810
|
+
finally:
|
|
811
|
+
try:
|
|
812
|
+
await cdp.close()
|
|
813
|
+
finally:
|
|
814
|
+
await self.stop_profile(profile_id)
|
|
815
|
+
|
|
816
|
+
# ── lifecycle ─────────────────────────────────────────────────────────────
|
|
817
|
+
|
|
818
|
+
async def aclose(self) -> None:
|
|
819
|
+
await self._t.aclose()
|
|
820
|
+
|
|
821
|
+
async def __aenter__(self) -> "AsyncScalebrowserClient":
|
|
822
|
+
return self
|
|
823
|
+
|
|
824
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
825
|
+
await self.aclose()
|