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.
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/PKG-INFO +31 -25
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/README.md +30 -24
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/pyproject.toml +1 -1
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/__init__.py +3 -1
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/_sync.py +3 -3
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/cdp.py +4 -4
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/client.py +102 -14
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/errors.py +15 -3
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/models.py +48 -29
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/models_control.py +2 -2
- scalebrowser-0.4.0/src/scalebrowser/models_identity.py +239 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/models_runs.py +2 -2
- scalebrowser-0.4.0/tests/test_prose_guard.py +106 -0
- scalebrowser-0.3.0/src/scalebrowser/models_identity.py +0 -130
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/.gitignore +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/LICENSE +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/examples/quickstart.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/examples/quickstart_sync.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/_http.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/_version.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/src/scalebrowser/events.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/__init__.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/conftest.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_cdp.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_client.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_coverage.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_e2e.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_errors.py +0 -0
- {scalebrowser-0.3.0 → scalebrowser-0.4.0}/tests/test_events.py +0 -0
- {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
|
+
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
|
|
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
|
|
92
|
+
- **Profiles**: `list_profiles`, `get_profile`, `create_profile`,
|
|
93
93
|
`update_profile`, `delete_profile`, `start_profile`, `stop_profile`
|
|
94
|
-
- **Bulk
|
|
94
|
+
- **Bulk**: `bulk_create_profiles`, `bulk_start`, `bulk_stop`, `bulk_delete`,
|
|
95
95
|
`bulk_assign_proxy`
|
|
96
|
-
- **Groups / Presets
|
|
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
|
|
102
|
+
with a 400, so read the valid regions from `get_persona_constraints()` rather than
|
|
103
103
|
hardcoding them.
|
|
104
|
-
- **Proxies
|
|
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
|
|
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
|
|
111
|
+
- **Credentials**: `list_credentials`, `put_credential`, `reveal_credential`
|
|
112
112
|
(needs the vault password), `export_credentials`, `import_credentials`
|
|
113
|
-
- **Cookies
|
|
113
|
+
- **Cookies**: `reveal_cookies`, the one route a cookie VALUE leaves through,
|
|
114
114
|
behind the same vault password
|
|
115
|
-
- **Sessions
|
|
116
|
-
- **Mailboxes
|
|
117
|
-
`get_inbox_bindings`, `bind_inbox`, `unbind_inbox
|
|
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
|
|
119
|
+
- **Passkeys**: `list_passkeys`, `delete_passkey`. Metadata only: the private
|
|
120
120
|
key has no field and no endpoint
|
|
121
|
-
- **Agent runs
|
|
121
|
+
- **Agent runs**: `list_runs`, `get_run`, `list_run_steps`, `get_run_shot`,
|
|
122
122
|
`get_activity`. Read-only, all of it
|
|
123
|
-
- **Interruptions
|
|
123
|
+
- **Interruptions**: `list_interruption_locks`, `set_interruption_lock`,
|
|
124
124
|
`list_interruption_rules`, `set_interruption_rule`,
|
|
125
|
-
`delete_interruption_rule
|
|
126
|
-
- **Artifacts
|
|
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
|
|
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–
|
|
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)
|
|
145
|
-
- `navigate(url)`, `evaluate(expr, isolated=False)
|
|
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()
|
|
149
|
-
- `humanize_move/click/type/scroll
|
|
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
|
|
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
|
|
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
|
|
63
|
+
- **Profiles**: `list_profiles`, `get_profile`, `create_profile`,
|
|
64
64
|
`update_profile`, `delete_profile`, `start_profile`, `stop_profile`
|
|
65
|
-
- **Bulk
|
|
65
|
+
- **Bulk**: `bulk_create_profiles`, `bulk_start`, `bulk_stop`, `bulk_delete`,
|
|
66
66
|
`bulk_assign_proxy`
|
|
67
|
-
- **Groups / Presets
|
|
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
|
|
73
|
+
with a 400, so read the valid regions from `get_persona_constraints()` rather than
|
|
74
74
|
hardcoding them.
|
|
75
|
-
- **Proxies
|
|
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
|
|
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
|
|
82
|
+
- **Credentials**: `list_credentials`, `put_credential`, `reveal_credential`
|
|
83
83
|
(needs the vault password), `export_credentials`, `import_credentials`
|
|
84
|
-
- **Cookies
|
|
84
|
+
- **Cookies**: `reveal_cookies`, the one route a cookie VALUE leaves through,
|
|
85
85
|
behind the same vault password
|
|
86
|
-
- **Sessions
|
|
87
|
-
- **Mailboxes
|
|
88
|
-
`get_inbox_bindings`, `bind_inbox`, `unbind_inbox
|
|
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
|
|
90
|
+
- **Passkeys**: `list_passkeys`, `delete_passkey`. Metadata only: the private
|
|
91
91
|
key has no field and no endpoint
|
|
92
|
-
- **Agent runs
|
|
92
|
+
- **Agent runs**: `list_runs`, `get_run`, `list_run_steps`, `get_run_shot`,
|
|
93
93
|
`get_activity`. Read-only, all of it
|
|
94
|
-
- **Interruptions
|
|
94
|
+
- **Interruptions**: `list_interruption_locks`, `set_interruption_lock`,
|
|
95
95
|
`list_interruption_rules`, `set_interruption_rule`,
|
|
96
|
-
`delete_interruption_rule
|
|
97
|
-
- **Artifacts
|
|
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
|
|
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–
|
|
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)
|
|
116
|
-
- `navigate(url)`, `evaluate(expr, isolated=False)
|
|
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()
|
|
120
|
-
- `humanize_move/click/type/scroll
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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)
|