scalebrowser 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/PKG-INFO +31 -25
  2. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/README.md +30 -24
  3. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/pyproject.toml +1 -1
  4. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/__init__.py +3 -1
  5. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/_sync.py +3 -3
  6. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/cdp.py +4 -4
  7. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/client.py +102 -14
  8. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/errors.py +15 -3
  9. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/models.py +48 -29
  10. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/models_control.py +2 -2
  11. scalebrowser-0.4.0/src/scalebrowser/models_identity.py +239 -0
  12. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/models_runs.py +2 -2
  13. scalebrowser-0.4.0/tests/test_prose_guard.py +106 -0
  14. scalebrowser-0.3.0/src/scalebrowser/models_identity.py +0 -130
  15. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/.gitignore +0 -0
  16. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/LICENSE +0 -0
  17. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/examples/quickstart.py +0 -0
  18. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/examples/quickstart_sync.py +0 -0
  19. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/_http.py +0 -0
  20. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/_version.py +0 -0
  21. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/events.py +0 -0
  22. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/__init__.py +0 -0
  23. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/conftest.py +0 -0
  24. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_cdp.py +0 -0
  25. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_client.py +0 -0
  26. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_coverage.py +0 -0
  27. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_e2e.py +0 -0
  28. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_errors.py +0 -0
  29. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_events.py +0 -0
  30. {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_sync.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: scalebrowser
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Official Python SDK for the Scalebrowser daemon — typed REST client + direct-CDP driver (nodriver-style).
5
5
  Project-URL: Homepage, https://scalebrowser.net
6
6
  Project-URL: Documentation, https://scalebrowser.net
@@ -27,7 +27,7 @@ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
27
  Requires-Dist: pytest>=8; extra == 'dev'
28
28
  Description-Content-Type: text/markdown
29
29
 
30
- # scalebrowser — Python SDK
30
+ # scalebrowser: the Python SDK
31
31
 
32
32
  Official Python SDK for the [Scalebrowser](https://scalebrowser.net) daemon: a
33
33
  typed REST client **plus a direct-CDP driver** (nodriver-style) for the
@@ -89,43 +89,43 @@ asyncio.run(main())
89
89
  Every `/v1` endpoint is a typed method on the client, under the same name in
90
90
  both the sync and the async client:
91
91
 
92
- - **Profiles** — `list_profiles`, `get_profile`, `create_profile`,
92
+ - **Profiles**: `list_profiles`, `get_profile`, `create_profile`,
93
93
  `update_profile`, `delete_profile`, `start_profile`, `stop_profile`
94
- - **Bulk** — `bulk_create_profiles`, `bulk_start`, `bulk_stop`, `bulk_delete`,
94
+ - **Bulk**: `bulk_create_profiles`, `bulk_start`, `bulk_stop`, `bulk_delete`,
95
95
  `bulk_assign_proxy`
96
- - **Groups / Presets** — `list_groups`/`create_group`/`get_group`/`update_group`/`delete_group`,
96
+ - **Groups / Presets**: `list_groups`/`create_group`/`get_group`/`update_group`/`delete_group`,
97
97
  `list_presets`/`create_preset`/`get_preset`/`update_preset`/`delete_preset`,
98
98
  `get_persona_constraints`. A preset is `config` (what the profiles do:
99
99
  `geo_mode`, `proxy_id`, …) plus `constraints` (what they are: `country`, which
100
100
  pins the persona's language, timezone and voices). Both are typed
101
101
  (`PresetConfig` / `PresetConstraints`) and the daemon refuses an unknown key
102
- with a 400 — read the valid regions from `get_persona_constraints()` rather than
102
+ with a 400, so read the valid regions from `get_persona_constraints()` rather than
103
103
  hardcoding them.
104
- - **Proxies** — `list_proxies`/`create_proxy`/`get_proxy`/`update_proxy`/`delete_proxy`/`check_proxy`,
104
+ - **Proxies**: `list_proxies`/`create_proxy`/`get_proxy`/`update_proxy`/`delete_proxy`/`check_proxy`,
105
105
  plus `check_proxy_config` (probe a config before saving it; pass `id` to reuse
106
106
  an existing proxy's stored credentials)
107
- - **Extensions** — `list_extensions`, `attach_extension`, `detach_extension`,
107
+ - **Extensions**: `list_extensions`, `attach_extension`, `detach_extension`,
108
108
  plus the daemon-wide library (`upload_extension`, `get_library_extension`,
109
109
  `delete_library_extension`). An attached package IS loaded into the browser at
110
110
  launch, under the canonical Web-Store id its own key derives
111
- - **Credentials** — `list_credentials`, `put_credential`, `reveal_credential`
111
+ - **Credentials**: `list_credentials`, `put_credential`, `reveal_credential`
112
112
  (needs the vault password), `export_credentials`, `import_credentials`
113
- - **Cookies** — `reveal_cookies`, the one route a cookie VALUE leaves through,
113
+ - **Cookies**: `reveal_cookies`, the one route a cookie VALUE leaves through,
114
114
  behind the same vault password
115
- - **Sessions** — `export_session`, `import_session`
116
- - **Mailboxes** — `list_inboxes`, `create_inbox`, `update_inbox`, `delete_inbox`,
117
- `get_inbox_bindings`, `bind_inbox`, `unbind_inbox` — where a profile's
115
+ - **Sessions**: `export_session`, `import_session`
116
+ - **Mailboxes**: `list_inboxes`, `create_inbox`, `update_inbox`, `delete_inbox`,
117
+ `get_inbox_bindings`, `bind_inbox`, `unbind_inbox`, which is where a profile's
118
118
  confirmation codes arrive
119
- - **Passkeys** — `list_passkeys`, `delete_passkey`. Metadata only: the private
119
+ - **Passkeys**: `list_passkeys`, `delete_passkey`. Metadata only: the private
120
120
  key has no field and no endpoint
121
- - **Agent runs** — `list_runs`, `get_run`, `list_run_steps`, `get_run_shot`,
121
+ - **Agent runs**: `list_runs`, `get_run`, `list_run_steps`, `get_run_shot`,
122
122
  `get_activity`. Read-only, all of it
123
- - **Interruptions** — `list_interruption_locks`, `set_interruption_lock`,
123
+ - **Interruptions**: `list_interruption_locks`, `set_interruption_lock`,
124
124
  `list_interruption_rules`, `set_interruption_rule`,
125
- `delete_interruption_rule` — who may answer when the browser asks something
126
- - **Artifacts** — `put_artifact` (hand the daemon a file to upload later),
125
+ `delete_interruption_rule`: who may answer when the browser asks something
126
+ - **Artifacts**: `put_artifact` (hand the daemon a file to upload later),
127
127
  `get_artifact` (fetch a screenshot, download or saved PDF as bytes)
128
- - **Input / Metrics / Account / Events** — `send_input`, `get_metrics`,
128
+ - **Input / Metrics / Account / Events**: `send_input`, `get_metrics`,
129
129
  `get_account`, `health`, `ready`, `events()`
130
130
 
131
131
  ```python
@@ -134,19 +134,19 @@ async for event in sb_async.events(): # SSE lifecycle stream (Bearer-auth
134
134
  ```
135
135
 
136
136
  Errors map the daemon contract: `ApiError(status, code, message)` with codes
137
- `4001–4010` (`ApiError.is_auth_error` for 401 / 4010); `NetworkError` when the
137
+ `4001–4012` (`ApiError.is_auth_error` for 401 / 4010); `NetworkError` when the
138
138
  daemon is unreachable; `CdpError` for protocol-level failures.
139
139
 
140
140
  ## Direct-CDP driver
141
141
 
142
142
  `CdpSession` (async) / `SyncCdpSession` give you:
143
143
 
144
- - `send(method, params)` — any CDP command, awaited by `id`
145
- - `navigate(url)`, `evaluate(expr, isolated=False)` — **never** calls
144
+ - `send(method, params)`: any CDP command, awaited by `id`
145
+ - `navigate(url)`, `evaluate(expr, isolated=False)`, which **never** call
146
146
  `Runtime.enable` (a detection leak); isolated worlds via
147
147
  `create_isolated_world()`
148
- - `on(method, cb)` / `events()` — subscribe to CDP events
149
- - `humanize_move/click/type/scroll` — humanized OS-level input via the daemon
148
+ - `on(method, cb)` / `events()`: subscribe to CDP events
149
+ - `humanize_move/click/type/scroll`: humanized OS-level input via the daemon
150
150
 
151
151
  ## Tests
152
152
 
@@ -159,8 +159,14 @@ SCALEBROWSER_E2E=1 pytest tests/test_e2e.py # against a real daemon
159
159
  ## Contract assumptions
160
160
 
161
161
  - Default base URL `http://127.0.0.1:8787`; Bearer token always.
162
+ - **The CDP endpoint is guarded by the OS user, not by that token.** The engine's
163
+ DevTools port has no authentication of its own: it answers a bogus bearer with
164
+ `200`, measured, so the engine drops any connection whose peer process runs as
165
+ a different user. Nothing to configure and nothing to pass: your process is the
166
+ one that started the profile, so it is on the allowed side. A helper running as
167
+ another account will not get in, by design.
162
168
  - The trusted-input body beyond `{action, humanize}` (coordinates, `button`,
163
- `delta_x/y`, `text`) is an SDK convention — see `cdp.py`.
169
+ `delta_x/y`, `text`) is an SDK convention; see `cdp.py`.
164
170
  - Two endpoints are optional and answer 404 on a daemon without them, which the
165
171
  SDK treats as information rather than as an error: `get_metrics()` then derives
166
172
  running counts from profile state, and `get_account()` returns
@@ -1,4 +1,4 @@
1
- # scalebrowser — Python SDK
1
+ # scalebrowser: the Python SDK
2
2
 
3
3
  Official Python SDK for the [Scalebrowser](https://scalebrowser.net) daemon: a
4
4
  typed REST client **plus a direct-CDP driver** (nodriver-style) for the
@@ -60,43 +60,43 @@ asyncio.run(main())
60
60
  Every `/v1` endpoint is a typed method on the client, under the same name in
61
61
  both the sync and the async client:
62
62
 
63
- - **Profiles** — `list_profiles`, `get_profile`, `create_profile`,
63
+ - **Profiles**: `list_profiles`, `get_profile`, `create_profile`,
64
64
  `update_profile`, `delete_profile`, `start_profile`, `stop_profile`
65
- - **Bulk** — `bulk_create_profiles`, `bulk_start`, `bulk_stop`, `bulk_delete`,
65
+ - **Bulk**: `bulk_create_profiles`, `bulk_start`, `bulk_stop`, `bulk_delete`,
66
66
  `bulk_assign_proxy`
67
- - **Groups / Presets** — `list_groups`/`create_group`/`get_group`/`update_group`/`delete_group`,
67
+ - **Groups / Presets**: `list_groups`/`create_group`/`get_group`/`update_group`/`delete_group`,
68
68
  `list_presets`/`create_preset`/`get_preset`/`update_preset`/`delete_preset`,
69
69
  `get_persona_constraints`. A preset is `config` (what the profiles do:
70
70
  `geo_mode`, `proxy_id`, …) plus `constraints` (what they are: `country`, which
71
71
  pins the persona's language, timezone and voices). Both are typed
72
72
  (`PresetConfig` / `PresetConstraints`) and the daemon refuses an unknown key
73
- with a 400 — read the valid regions from `get_persona_constraints()` rather than
73
+ with a 400, so read the valid regions from `get_persona_constraints()` rather than
74
74
  hardcoding them.
75
- - **Proxies** — `list_proxies`/`create_proxy`/`get_proxy`/`update_proxy`/`delete_proxy`/`check_proxy`,
75
+ - **Proxies**: `list_proxies`/`create_proxy`/`get_proxy`/`update_proxy`/`delete_proxy`/`check_proxy`,
76
76
  plus `check_proxy_config` (probe a config before saving it; pass `id` to reuse
77
77
  an existing proxy's stored credentials)
78
- - **Extensions** — `list_extensions`, `attach_extension`, `detach_extension`,
78
+ - **Extensions**: `list_extensions`, `attach_extension`, `detach_extension`,
79
79
  plus the daemon-wide library (`upload_extension`, `get_library_extension`,
80
80
  `delete_library_extension`). An attached package IS loaded into the browser at
81
81
  launch, under the canonical Web-Store id its own key derives
82
- - **Credentials** — `list_credentials`, `put_credential`, `reveal_credential`
82
+ - **Credentials**: `list_credentials`, `put_credential`, `reveal_credential`
83
83
  (needs the vault password), `export_credentials`, `import_credentials`
84
- - **Cookies** — `reveal_cookies`, the one route a cookie VALUE leaves through,
84
+ - **Cookies**: `reveal_cookies`, the one route a cookie VALUE leaves through,
85
85
  behind the same vault password
86
- - **Sessions** — `export_session`, `import_session`
87
- - **Mailboxes** — `list_inboxes`, `create_inbox`, `update_inbox`, `delete_inbox`,
88
- `get_inbox_bindings`, `bind_inbox`, `unbind_inbox` — where a profile's
86
+ - **Sessions**: `export_session`, `import_session`
87
+ - **Mailboxes**: `list_inboxes`, `create_inbox`, `update_inbox`, `delete_inbox`,
88
+ `get_inbox_bindings`, `bind_inbox`, `unbind_inbox`, which is where a profile's
89
89
  confirmation codes arrive
90
- - **Passkeys** — `list_passkeys`, `delete_passkey`. Metadata only: the private
90
+ - **Passkeys**: `list_passkeys`, `delete_passkey`. Metadata only: the private
91
91
  key has no field and no endpoint
92
- - **Agent runs** — `list_runs`, `get_run`, `list_run_steps`, `get_run_shot`,
92
+ - **Agent runs**: `list_runs`, `get_run`, `list_run_steps`, `get_run_shot`,
93
93
  `get_activity`. Read-only, all of it
94
- - **Interruptions** — `list_interruption_locks`, `set_interruption_lock`,
94
+ - **Interruptions**: `list_interruption_locks`, `set_interruption_lock`,
95
95
  `list_interruption_rules`, `set_interruption_rule`,
96
- `delete_interruption_rule` — who may answer when the browser asks something
97
- - **Artifacts** — `put_artifact` (hand the daemon a file to upload later),
96
+ `delete_interruption_rule`: who may answer when the browser asks something
97
+ - **Artifacts**: `put_artifact` (hand the daemon a file to upload later),
98
98
  `get_artifact` (fetch a screenshot, download or saved PDF as bytes)
99
- - **Input / Metrics / Account / Events** — `send_input`, `get_metrics`,
99
+ - **Input / Metrics / Account / Events**: `send_input`, `get_metrics`,
100
100
  `get_account`, `health`, `ready`, `events()`
101
101
 
102
102
  ```python
@@ -105,19 +105,19 @@ async for event in sb_async.events(): # SSE lifecycle stream (Bearer-auth
105
105
  ```
106
106
 
107
107
  Errors map the daemon contract: `ApiError(status, code, message)` with codes
108
- `4001–4010` (`ApiError.is_auth_error` for 401 / 4010); `NetworkError` when the
108
+ `4001–4012` (`ApiError.is_auth_error` for 401 / 4010); `NetworkError` when the
109
109
  daemon is unreachable; `CdpError` for protocol-level failures.
110
110
 
111
111
  ## Direct-CDP driver
112
112
 
113
113
  `CdpSession` (async) / `SyncCdpSession` give you:
114
114
 
115
- - `send(method, params)` — any CDP command, awaited by `id`
116
- - `navigate(url)`, `evaluate(expr, isolated=False)` — **never** calls
115
+ - `send(method, params)`: any CDP command, awaited by `id`
116
+ - `navigate(url)`, `evaluate(expr, isolated=False)`, which **never** call
117
117
  `Runtime.enable` (a detection leak); isolated worlds via
118
118
  `create_isolated_world()`
119
- - `on(method, cb)` / `events()` — subscribe to CDP events
120
- - `humanize_move/click/type/scroll` — humanized OS-level input via the daemon
119
+ - `on(method, cb)` / `events()`: subscribe to CDP events
120
+ - `humanize_move/click/type/scroll`: humanized OS-level input via the daemon
121
121
 
122
122
  ## Tests
123
123
 
@@ -130,8 +130,14 @@ SCALEBROWSER_E2E=1 pytest tests/test_e2e.py # against a real daemon
130
130
  ## Contract assumptions
131
131
 
132
132
  - Default base URL `http://127.0.0.1:8787`; Bearer token always.
133
+ - **The CDP endpoint is guarded by the OS user, not by that token.** The engine's
134
+ DevTools port has no authentication of its own: it answers a bogus bearer with
135
+ `200`, measured, so the engine drops any connection whose peer process runs as
136
+ a different user. Nothing to configure and nothing to pass: your process is the
137
+ one that started the profile, so it is on the allowed side. A helper running as
138
+ another account will not get in, by design.
133
139
  - The trusted-input body beyond `{action, humanize}` (coordinates, `button`,
134
- `delta_x/y`, `text`) is an SDK convention — see `cdp.py`.
140
+ `delta_x/y`, `text`) is an SDK convention; see `cdp.py`.
135
141
  - Two endpoints are optional and answer 404 on a daemon without them, which the
136
142
  SDK treats as information rather than as an error: `get_metrics()` then derives
137
143
  running counts from profile state, and `get_account()` returns
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "scalebrowser"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Official Python SDK for the Scalebrowser daemon — typed REST client + direct-CDP driver (nodriver-style)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,4 +1,4 @@
1
- """Scalebrowser — official Python SDK.
1
+ """Scalebrowser: the official Python SDK.
2
2
 
3
3
  Typed REST client + a direct-CDP driver (nodriver-style, **not** Playwright) for
4
4
  the self-hosted Scalebrowser daemon.
@@ -111,6 +111,7 @@ from .models import (
111
111
  StartProfileBody,
112
112
  StartProfileResult,
113
113
  StopProfileResult,
114
+ TakeoverResult,
114
115
  UpdateProfileBody,
115
116
  UpdateProxyBody,
116
117
  WebGl,
@@ -207,6 +208,7 @@ __all__ = [
207
208
  "StartProfileBody",
208
209
  "StartProfileResult",
209
210
  "StopProfileResult",
211
+ "TakeoverResult",
210
212
  "BulkCreateBody",
211
213
  "BulkIdsBody",
212
214
  "BulkAssignProxyBody",
@@ -2,7 +2,7 @@
2
2
 
3
3
  A single background daemon thread runs an asyncio loop; the sync client and CDP
4
4
  session drive the async implementations through it via
5
- ``run_coroutine_threadsafe``. There is no duplicated endpoint logic — every call
5
+ ``run_coroutine_threadsafe``. There is no duplicated endpoint logic: every call
6
6
  forwards to :class:`AsyncScalebrowserClient` / :class:`CdpSession`.
7
7
  """
8
8
 
@@ -161,7 +161,7 @@ class SyncCdpSession:
161
161
 
162
162
 
163
163
  class ScalebrowserClient:
164
- """Synchronous client — mirrors :class:`AsyncScalebrowserClient` one-to-one."""
164
+ """Synchronous client; mirrors :class:`AsyncScalebrowserClient` one-to-one."""
165
165
 
166
166
  def __init__(
167
167
  self,
@@ -229,7 +229,7 @@ class ScalebrowserClient:
229
229
  sort: Optional[str] = None,
230
230
  order: Optional[str] = None,
231
231
  ) -> dict[str, Any]:
232
- """Every id matching the filters, unpaged — what "act on all matches" needs."""
232
+ """Every id matching the filters, unpaged: what "act on all matches" needs."""
233
233
  return self._portal.run(
234
234
  self._async.list_profile_ids(group=group, state=state, q=q, sort=sort, order=order)
235
235
  )
@@ -1,10 +1,10 @@
1
- """Direct-CDP driver (nodriver-style) over websockets — **not** a Playwright /
1
+ """Direct-CDP driver (nodriver-style) over websockets, and **not** a Playwright /
2
2
  Puppeteer control plane (DEC-006 / AC-FP-005).
3
3
 
4
4
  ``start`` returns a ``cdp_ws`` endpoint; we connect with our own minimal CDP
5
5
  client: send/await commands by ``id`` over a single multiplexed socket, demux
6
6
  events, attach to page targets (``flatten``), and create isolated worlds. We
7
- deliberately never call ``Runtime.enable`` (a documented detection leak — the
7
+ deliberately never call ``Runtime.enable`` (a documented detection leak: the
8
8
  svebaa ``console.*`` + proxy-chain bypass); ``Runtime.evaluate`` works without it.
9
9
 
10
10
  The ``humanize_*`` helpers route to the daemon trusted-input endpoint
@@ -140,7 +140,7 @@ class _Connection:
140
140
  for callback in (*self._listeners.get(event["method"], ()), *self._listeners.get("*", ())):
141
141
  try:
142
142
  callback(event)
143
- except Exception: # noqa: BLE001 — a bad listener must not kill the reader
143
+ except Exception: # noqa: BLE001, a bad listener must not kill the reader
144
144
  pass
145
145
  for queue in self._queues:
146
146
  queue.put_nowait(event)
@@ -410,7 +410,7 @@ async def connect_cdp(
410
410
  headers["Authorization"] = f"Bearer {token}"
411
411
  try:
412
412
  ws = await ws_connect(cdp_ws, additional_headers=headers, max_size=None)
413
- except Exception as exc: # noqa: BLE001 — normalize handshake/transport failures
413
+ except Exception as exc: # noqa: BLE001, normalize handshake/transport failures
414
414
  raise NetworkError(f"Could not connect to CDP endpoint {cdp_ws}.", exc) from exc
415
415
 
416
416
  connection = _Connection(ws)
@@ -1,4 +1,4 @@
1
- """The async Scalebrowser client — typed REST surface + a direct-CDP entry point.
1
+ """The async Scalebrowser client: typed REST surface plus a direct-CDP entry point.
2
2
 
3
3
  Every endpoint path is defined here exactly once, so endpoint drift is a
4
4
  one-file change (the transport in ``_http.py`` is the only other HTTP-aware
@@ -49,6 +49,7 @@ from .models import (
49
49
  SessionImportBody,
50
50
  StartProfileResult,
51
51
  StopProfileResult,
52
+ TakeoverResult,
52
53
  UpdatePresetBody,
53
54
  UpdateProfileBody,
54
55
  UpdateProxyBody,
@@ -73,12 +74,19 @@ from .models_identity import (
73
74
  PutInboxBody,
74
75
  RevealCookiesBody,
75
76
  RevealCookiesResult,
77
+ SecretDeleteResult,
78
+ SecretListResult,
79
+ SecretPatchBody,
80
+ SecretRow,
81
+ SecretRunBody,
82
+ SecretRunResult,
83
+ SecretScrubResult,
76
84
  )
77
85
  from .models_runs import ActivitySnapshot, AgentRun
78
86
 
79
87
  DEFAULT_BASE_URL = "http://127.0.0.1:8787"
80
88
 
81
- #: Mirrors ``CapacityConfig::default()`` — used by the metrics fallback.
89
+ #: Mirrors ``CapacityConfig::default()``; used by the metrics fallback.
82
90
  DEFAULT_CAPACITY_MAX_CONCURRENT = 64
83
91
  DEFAULT_CAPACITY_RAM_BUDGET_MB = 24_576
84
92
 
@@ -145,7 +153,7 @@ class AsyncScalebrowserClient:
145
153
 
146
154
  ``sort`` (``created_at`` | ``name`` | ``runtime_state`` | ``last_open_at``)
147
155
  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
156
+ filtered set, because sorting a fetched page would order a minority of a paged
149
157
  fleet while looking authoritative.
150
158
  """
151
159
  data = await self._t.get(
@@ -188,6 +196,16 @@ class AsyncScalebrowserClient:
188
196
  data = await self._t.post(f"/v1/profiles/{_q(profile_id)}/stop", {})
189
197
  return StopProfileResult.model_validate(data)
190
198
 
199
+ async def takeover_profile(self, profile_id: str) -> TakeoverResult:
200
+ """Ask for a profile that is open on another machine of this account.
201
+
202
+ Closes nothing here and nothing there: the control plane revokes the
203
+ holder's right, and the holder closes its own browser at its next
204
+ heartbeat. Start only after this answered ``free``.
205
+ """
206
+ data = await self._t.post(f"/v1/profiles/{_q(profile_id)}/takeover", {})
207
+ return TakeoverResult.model_validate(data)
208
+
191
209
  # ── bulk (PRD §D2) ───────────────────────────────────────────────────────
192
210
 
193
211
  async def bulk_create_profiles(
@@ -213,7 +231,7 @@ class AsyncScalebrowserClient:
213
231
  sort: Optional[str] = None,
214
232
  order: Optional[str] = None,
215
233
  ) -> dict[str, Any]:
216
- """Every id matching the filters, unpaged — what "act on all matches" needs.
234
+ """Every id matching the filters, unpaged: what "act on all matches" needs.
217
235
 
218
236
  ``list_profiles`` is paged, so acting on "everything" from it only ever
219
237
  covers the page that was fetched. This costs one request and carries no
@@ -342,7 +360,7 @@ class AsyncScalebrowserClient:
342
360
  async def check_proxy_config(
343
361
  self, body: Union[CheckProxyConfigBody, Mapping[str, Any]]
344
362
  ) -> ProxyCheckResult:
345
- """Probe a proxy config WITHOUT persisting it — validate before committing."""
363
+ """Probe a proxy config WITHOUT persisting it: validate before committing."""
346
364
  data = await self._t.post("/v1/proxies/check", _payload(body))
347
365
  return ProxyCheckResult.model_validate(data)
348
366
 
@@ -367,12 +385,12 @@ class AsyncScalebrowserClient:
367
385
  # Stored logins, so an agent can sign a profile into a platform WITHOUT ever
368
386
  # holding the password: the daemon decrypts and types it. Only
369
387
  # :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
388
+ # them need the vault password; the bearer token alone is not enough, because
371
389
  # the MCP agent layer authenticates with exactly the same one.
372
390
  #
373
391
  # ``platform`` is whatever you have: the service's domain ("discord.com"),
374
392
  # 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
393
+ # resolves all of them to ONE key (the registrable domain), and every
376
394
  # response echoes that key, so a later :meth:`delete_credential` addresses
377
395
  # the same row. Pass a whole sign-in URL only to the MCP tools: here the
378
396
  # platform is a path segment, and a "/" in it is refused rather than read
@@ -392,20 +410,30 @@ class AsyncScalebrowserClient:
392
410
  password: str | None = None,
393
411
  totp_secret: str | None = None,
394
412
  login_url: str | None = None,
413
+ binding_mode: str | None = None,
414
+ binding_value: str | None = None,
415
+ field_bound: bool | None = None,
395
416
  ) -> CredentialMeta:
396
417
  """Store or update one login.
397
418
 
398
- An omitted secret keeps its stored value rather than clearing it — an edit
419
+ An omitted secret keeps its stored value rather than clearing it, because an edit
399
420
  form never saw the password in the clear, so it cannot resend one. Delete
400
421
  the row to remove a credential. ``totp_secret`` may be pasted exactly as a
401
422
  site presents it (grouped, lower case); it is normalised and test-decoded
402
423
  by the daemon, so a broken key is refused here rather than mid-login.
424
+
425
+ The three binding arguments say WHERE this login may be typed. Omitting
426
+ them leaves the binding as it is, so changing a password never silently
427
+ widens one.
403
428
  """
404
429
  body = {
405
430
  "username": username,
406
431
  "password": password,
407
432
  "totp_secret": totp_secret,
408
433
  "login_url": login_url,
434
+ "binding_mode": binding_mode,
435
+ "binding_value": binding_value,
436
+ "field_bound": field_bound,
409
437
  }
410
438
  data = await self._t.request(
411
439
  "PUT",
@@ -435,7 +463,7 @@ class AsyncScalebrowserClient:
435
463
 
436
464
  ``password`` is deliberately separate from the vault password: a backup
437
465
  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
466
+ this daemon. It is the answer to a lost master key: a daemon-generated
439
467
  password exists nowhere else.
440
468
  """
441
469
  data = await self._t.post(
@@ -472,7 +500,7 @@ class AsyncScalebrowserClient:
472
500
  #
473
501
  # Upload a ``.crx``; the daemon lifts its public key out of the CRX header and
474
502
  # 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
503
+ # package under its CANONICAL Web-Store id. A key-less package is refused, since it
476
504
  # would take a path-derived id, identical across the fleet.
477
505
 
478
506
  async def list_library_extensions(self) -> list[Extension]:
@@ -501,7 +529,7 @@ class AsyncScalebrowserClient:
501
529
  async def delete_library_extension_version(self, ext_id: str, version: str) -> None:
502
530
  """Remove ONE stocked version.
503
531
 
504
- Assignments survive — they name the extension, not the version — unless
532
+ Assignments survive, because they name the extension and not the version, unless
505
533
  this was the last version, in which case they go with it.
506
534
  """
507
535
  await self._t.delete(f"/v1/extensions/{_q(ext_id)}/{_q(version)}")
@@ -607,7 +635,7 @@ class AsyncScalebrowserClient:
607
635
  async def list_run_steps(
608
636
  self, run_id: str, *, limit: Optional[int] = None, offset: Optional[int] = None
609
637
  ) -> list[RunStep]:
610
- """A run's steps, oldest first — the order they happened in."""
638
+ """A run's steps, oldest first: the order they happened in."""
611
639
  data = await self._t.get(
612
640
  f"/v1/runs/{_q(run_id)}/steps", params={"limit": limit, "offset": offset}
613
641
  )
@@ -676,6 +704,66 @@ class AsyncScalebrowserClient:
676
704
  """
677
705
  await self._t.delete(f"/v1/profiles/{_q(profile_id)}/passkeys/{_q(credential_id)}")
678
706
 
707
+ # ── secret layer ─────────────────────────────────────────────────────────
708
+ #
709
+ # Values a profile holds that no agent ever reads. Nothing here returns a
710
+ # value, and that is not an oversight: ``run_with_secrets`` starts the
711
+ # command INSIDE the daemon precisely so that no route has to hand plaintext
712
+ # out.
713
+
714
+ async def list_secrets(self, profile_id: str) -> SecretListResult:
715
+ """The entries this profile keeps, with the placeholder for each."""
716
+ return SecretListResult.model_validate(
717
+ await self._t.get(f"/v1/profiles/{_q(profile_id)}/secrets")
718
+ )
719
+
720
+ async def update_secret(
721
+ self, profile_id: str, secret_id: str, body: Union[SecretPatchBody, Mapping[str, Any]]
722
+ ) -> SecretRow:
723
+ """Change a title, a binding, or the state. Never the label."""
724
+ return SecretRow.model_validate(
725
+ await self._t.patch(
726
+ f"/v1/profiles/{_q(profile_id)}/secrets/{_q(secret_id)}", _payload(body)
727
+ )
728
+ )
729
+
730
+ async def delete_secret(self, profile_id: str, secret_id: str) -> SecretDeleteResult:
731
+ """Delete an entry. The reply names the runs it appeared in."""
732
+ return SecretDeleteResult.model_validate(
733
+ await self._t.delete(f"/v1/profiles/{_q(profile_id)}/secrets/{_q(secret_id)}")
734
+ )
735
+
736
+ async def scrub_text(self, profile_id: str, text: str) -> SecretScrubResult:
737
+ """Filter a text you hold against this profile's values.
738
+
739
+ The call exists because censoring only makes sense where a MODEL is
740
+ reading: your own code is not, and a placeholder in the middle of it
741
+ would break parsing. So the daemon offers the filter rather than
742
+ applying it to a channel it does not own.
743
+ """
744
+ return SecretScrubResult.model_validate(
745
+ await self._t.post(f"/v1/profiles/{_q(profile_id)}/secrets/scrub", {"text": text})
746
+ )
747
+
748
+ async def run_with_secrets(
749
+ self, profile_id: str, body: Union[SecretRunBody, Mapping[str, Any]]
750
+ ) -> SecretRunResult:
751
+ """Run a command with named values in its environment.
752
+
753
+ The daemon starts the child, so no value crosses a socket.
754
+ """
755
+ return SecretRunResult.model_validate(
756
+ await self._t.post(f"/v1/profiles/{_q(profile_id)}/secrets/run", _payload(body))
757
+ )
758
+
759
+ async def get_secret_layer(self) -> dict[str, Any]:
760
+ """Is the secret layer on? It is by default."""
761
+ return await self._t.get("/v1/secret-layer") or {}
762
+
763
+ async def set_secret_layer(self, enabled: bool) -> dict[str, Any]:
764
+ """Turn the secret layer on or off for the whole daemon."""
765
+ return await self._t.post("/v1/secret-layer", {"enabled": enabled}) or {}
766
+
679
767
  # ── interruptions ────────────────────────────────────────────────────────
680
768
 
681
769
  async def list_interruption_locks(self) -> InterruptionLockView:
@@ -726,7 +814,7 @@ class AsyncScalebrowserClient:
726
814
  return ArtifactPutResult.model_validate(result)
727
815
 
728
816
  async def get_artifact(self, artifact_id: str) -> ArtifactBytes:
729
- """Fetch one artifact's bytes — a screenshot, a download, a saved PDF.
817
+ """Fetch one artifact's bytes: a screenshot, a download, a saved PDF.
730
818
 
731
819
  A miss is a plain 404 whether the id never existed, expired, or belonged
732
820
  to a released lease: the caller's next move is the same in all three.
@@ -772,7 +860,7 @@ class AsyncScalebrowserClient:
772
860
  return HealthStatus.model_validate(await self._t.get("/health"))
773
861
 
774
862
  async def ready(self) -> ReadyStatus:
775
- """"Can it serve?" Local facts only — the control plane is not consulted."""
863
+ """"Can it serve?" Local facts only; the control plane is not consulted."""
776
864
  return ReadyStatus.model_validate(await self._t.get("/health/ready"))
777
865
 
778
866
  # ── events (SSE) ─────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  """Typed SDK errors and the daemon's stable numeric error codes.
2
2
 
3
- The daemon returns error bodies shaped ``{ "code": <4001-4010>, "message": "…" }``
3
+ The daemon returns error bodies shaped ``{ "code": <4001-4012>, "message": "…" }``
4
4
  with the HTTP status from ``core::Error::http_status()`` (docs/features/api.md, SPEC §5).
5
5
  """
6
6
 
@@ -26,6 +26,16 @@ class ErrorCode:
26
26
  # is to stop a running browser anywhere or upgrade the plan.
27
27
  CONCURRENCY_LIMIT: Final = 4008
28
28
  UNAUTHORIZED: Final = 4010
29
+ # No account is connected to this machine, so nothing may run (DEC-014).
30
+ # Distinct from UNAUTHORIZED (4010 = the daemon's own bearer token is wrong, so
31
+ # re-read the token): 4011 means the machine needs to be signed in, and no token
32
+ # a client holds can substitute for that. Connect it in the desktop app.
33
+ NOT_SIGNED_IN: Final = 4011
34
+ # This ONE profile is open on another machine of the same account (DEC-015).
35
+ # Distinct from CONCURRENCY_LIMIT (4008 = the plan is full, buy more or free any
36
+ # browser): the caller's move here costs nothing. Close it on that machine, or
37
+ # take it over from the app running there.
38
+ PROFILE_IN_USE: Final = 4012
29
39
 
30
40
 
31
41
  _CODE_LABELS: Final[dict[int, str]] = {
@@ -37,6 +47,8 @@ _CODE_LABELS: Final[dict[int, str]] = {
37
47
  4006: "Profile already running",
38
48
  4008: "Concurrent-browser limit reached (subscription-wide)",
39
49
  4010: "Unauthorized",
50
+ 4011: "No account connected to this machine",
51
+ 4012: "This profile is open on another machine",
40
52
  }
41
53
 
42
54
 
@@ -53,7 +65,7 @@ class ApiError(ScalebrowserError):
53
65
  """A transport/contract error from the daemon REST API.
54
66
 
55
67
  Carries the HTTP ``status`` and the daemon's numeric ``code`` so callers can
56
- branch on auth failures or specific business errors (4001-4010).
68
+ branch on auth failures or specific business errors (4001-4012).
57
69
  """
58
70
 
59
71
  def __init__(
@@ -81,7 +93,7 @@ class ApiError(ScalebrowserError):
81
93
 
82
94
 
83
95
  class NetworkError(ScalebrowserError):
84
- """A network-level failure — the daemon was unreachable (no HTTP response)."""
96
+ """A network-level failure: the daemon was unreachable (no HTTP response)."""
85
97
 
86
98
  def __init__(self, message: str, cause: Optional[BaseException] = None) -> None:
87
99
  super().__init__(message)