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/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()