agentmb 0.1.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.
- agentmb-0.1.0/.gitignore +17 -0
- agentmb-0.1.0/PKG-INFO +127 -0
- agentmb-0.1.0/README.md +114 -0
- agentmb-0.1.0/agentmb/__init__.py +61 -0
- agentmb-0.1.0/agentmb/client.py +747 -0
- agentmb-0.1.0/agentmb/models.py +222 -0
- agentmb-0.1.0/pyproject.toml +27 -0
agentmb-0.1.0/.gitignore
ADDED
agentmb-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentmb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for agentmb — local Chromium runtime daemon for AI agents
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# agentmb Python SDK
|
|
15
|
+
|
|
16
|
+
Python client for the [agentmb](https://github.com/what552/agent-managed-browser) daemon — local Chromium runtime for AI agents.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install agentmb
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or from source (editable):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install -e sdk/python
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from agentmb import BrowserClient
|
|
34
|
+
|
|
35
|
+
with BrowserClient() as client:
|
|
36
|
+
with client.sessions.create(profile="myprofile") as sess:
|
|
37
|
+
sess.navigate("https://example.com")
|
|
38
|
+
shot = sess.screenshot()
|
|
39
|
+
shot.save("/tmp/out.png")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Async:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
from agentmb import AsyncBrowserClient
|
|
47
|
+
|
|
48
|
+
async def main():
|
|
49
|
+
async with AsyncBrowserClient() as client:
|
|
50
|
+
sess = await client.sessions.create(profile="demo")
|
|
51
|
+
async with sess:
|
|
52
|
+
await sess.navigate("https://example.com")
|
|
53
|
+
result = await sess.eval("document.title")
|
|
54
|
+
print(result.result)
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Action methods
|
|
60
|
+
|
|
61
|
+
| Method | Description |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `sess.navigate(url)` | Navigate to URL |
|
|
64
|
+
| `sess.screenshot()` | Capture screenshot → `ScreenshotResult` |
|
|
65
|
+
| `sess.eval(expr)` | Run JS → `EvalResult` |
|
|
66
|
+
| `sess.extract(selector)` | Extract text/attrs → `ExtractResult` |
|
|
67
|
+
| `sess.click(selector)` | Click element |
|
|
68
|
+
| `sess.fill(selector, value)` | Fill form field |
|
|
69
|
+
| `sess.type(selector, text)` | Type char-by-char |
|
|
70
|
+
| `sess.press(selector, key)` | Press key / combo (e.g. `"Enter"`, `"Control+a"`) |
|
|
71
|
+
| `sess.select(selector, values)` | Select `<option>` in a `<select>` |
|
|
72
|
+
| `sess.hover(selector)` | Hover over element |
|
|
73
|
+
| `sess.wait_for_selector(selector, state)` | Wait for element visibility state |
|
|
74
|
+
| `sess.wait_for_url(pattern)` | Wait for URL to match glob pattern |
|
|
75
|
+
| `sess.wait_for_response(url_pattern, trigger)` | Wait for a network response |
|
|
76
|
+
| `sess.upload(selector, file_path)` | Upload file to `<input type="file">` |
|
|
77
|
+
| `sess.download(selector)` | Click download link → `DownloadResult` |
|
|
78
|
+
| `sess.handoff_start()` | Switch to headed mode for human login |
|
|
79
|
+
| `sess.handoff_complete()` | Return to headless after login |
|
|
80
|
+
| `sess.cdp_send(method, params)` | Send raw CDP command |
|
|
81
|
+
| `sess.logs(tail)` | Fetch audit log entries |
|
|
82
|
+
|
|
83
|
+
### File upload / download
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# Upload
|
|
87
|
+
result = sess.upload("#file-input", "/path/to/file.csv", mime_type="text/csv")
|
|
88
|
+
print(result.filename, result.size_bytes)
|
|
89
|
+
|
|
90
|
+
# Download: triggers click, returns base64 file content
|
|
91
|
+
dl = sess.download("#download-link")
|
|
92
|
+
dl.save("/tmp/report.pdf")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Wait actions
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# Wait for element to appear
|
|
99
|
+
sess.wait_for_selector("#modal", state="visible", timeout_ms=3000)
|
|
100
|
+
|
|
101
|
+
# Wait for URL after SPA navigation
|
|
102
|
+
sess.wait_for_url("**/dashboard**", timeout_ms=5000)
|
|
103
|
+
|
|
104
|
+
# Wait for a specific network response (with navigate trigger)
|
|
105
|
+
resp = sess.wait_for_response(
|
|
106
|
+
url_pattern="/api/data",
|
|
107
|
+
timeout_ms=10000,
|
|
108
|
+
trigger={"type": "navigate", "url": "https://app.example.com"},
|
|
109
|
+
)
|
|
110
|
+
print(resp.status_code)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Requirements
|
|
114
|
+
|
|
115
|
+
- Python 3.9+
|
|
116
|
+
- agentmb daemon running (`agentmb start`)
|
|
117
|
+
|
|
118
|
+
## Environment variables
|
|
119
|
+
|
|
120
|
+
| Variable | Default | Description |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `AGENTMB_PORT` | `19315` | Daemon port |
|
|
123
|
+
| `AGENTMB_API_TOKEN` | (none) | API token if daemon started with one |
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT
|
agentmb-0.1.0/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# agentmb Python SDK
|
|
2
|
+
|
|
3
|
+
Python client for the [agentmb](https://github.com/what552/agent-managed-browser) daemon — local Chromium runtime for AI agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agentmb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or from source (editable):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e sdk/python
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from agentmb import BrowserClient
|
|
21
|
+
|
|
22
|
+
with BrowserClient() as client:
|
|
23
|
+
with client.sessions.create(profile="myprofile") as sess:
|
|
24
|
+
sess.navigate("https://example.com")
|
|
25
|
+
shot = sess.screenshot()
|
|
26
|
+
shot.save("/tmp/out.png")
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Async:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import asyncio
|
|
33
|
+
from agentmb import AsyncBrowserClient
|
|
34
|
+
|
|
35
|
+
async def main():
|
|
36
|
+
async with AsyncBrowserClient() as client:
|
|
37
|
+
sess = await client.sessions.create(profile="demo")
|
|
38
|
+
async with sess:
|
|
39
|
+
await sess.navigate("https://example.com")
|
|
40
|
+
result = await sess.eval("document.title")
|
|
41
|
+
print(result.result)
|
|
42
|
+
|
|
43
|
+
asyncio.run(main())
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Action methods
|
|
47
|
+
|
|
48
|
+
| Method | Description |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `sess.navigate(url)` | Navigate to URL |
|
|
51
|
+
| `sess.screenshot()` | Capture screenshot → `ScreenshotResult` |
|
|
52
|
+
| `sess.eval(expr)` | Run JS → `EvalResult` |
|
|
53
|
+
| `sess.extract(selector)` | Extract text/attrs → `ExtractResult` |
|
|
54
|
+
| `sess.click(selector)` | Click element |
|
|
55
|
+
| `sess.fill(selector, value)` | Fill form field |
|
|
56
|
+
| `sess.type(selector, text)` | Type char-by-char |
|
|
57
|
+
| `sess.press(selector, key)` | Press key / combo (e.g. `"Enter"`, `"Control+a"`) |
|
|
58
|
+
| `sess.select(selector, values)` | Select `<option>` in a `<select>` |
|
|
59
|
+
| `sess.hover(selector)` | Hover over element |
|
|
60
|
+
| `sess.wait_for_selector(selector, state)` | Wait for element visibility state |
|
|
61
|
+
| `sess.wait_for_url(pattern)` | Wait for URL to match glob pattern |
|
|
62
|
+
| `sess.wait_for_response(url_pattern, trigger)` | Wait for a network response |
|
|
63
|
+
| `sess.upload(selector, file_path)` | Upload file to `<input type="file">` |
|
|
64
|
+
| `sess.download(selector)` | Click download link → `DownloadResult` |
|
|
65
|
+
| `sess.handoff_start()` | Switch to headed mode for human login |
|
|
66
|
+
| `sess.handoff_complete()` | Return to headless after login |
|
|
67
|
+
| `sess.cdp_send(method, params)` | Send raw CDP command |
|
|
68
|
+
| `sess.logs(tail)` | Fetch audit log entries |
|
|
69
|
+
|
|
70
|
+
### File upload / download
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# Upload
|
|
74
|
+
result = sess.upload("#file-input", "/path/to/file.csv", mime_type="text/csv")
|
|
75
|
+
print(result.filename, result.size_bytes)
|
|
76
|
+
|
|
77
|
+
# Download: triggers click, returns base64 file content
|
|
78
|
+
dl = sess.download("#download-link")
|
|
79
|
+
dl.save("/tmp/report.pdf")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Wait actions
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# Wait for element to appear
|
|
86
|
+
sess.wait_for_selector("#modal", state="visible", timeout_ms=3000)
|
|
87
|
+
|
|
88
|
+
# Wait for URL after SPA navigation
|
|
89
|
+
sess.wait_for_url("**/dashboard**", timeout_ms=5000)
|
|
90
|
+
|
|
91
|
+
# Wait for a specific network response (with navigate trigger)
|
|
92
|
+
resp = sess.wait_for_response(
|
|
93
|
+
url_pattern="/api/data",
|
|
94
|
+
timeout_ms=10000,
|
|
95
|
+
trigger={"type": "navigate", "url": "https://app.example.com"},
|
|
96
|
+
)
|
|
97
|
+
print(resp.status_code)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Requirements
|
|
101
|
+
|
|
102
|
+
- Python 3.9+
|
|
103
|
+
- agentmb daemon running (`agentmb start`)
|
|
104
|
+
|
|
105
|
+
## Environment variables
|
|
106
|
+
|
|
107
|
+
| Variable | Default | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `AGENTMB_PORT` | `19315` | Daemon port |
|
|
110
|
+
| `AGENTMB_API_TOKEN` | (none) | API token if daemon started with one |
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""agentmb Python SDK"""
|
|
2
|
+
|
|
3
|
+
from .client import BrowserClient, AsyncBrowserClient
|
|
4
|
+
from .models import (
|
|
5
|
+
SessionInfo,
|
|
6
|
+
NavigateResult,
|
|
7
|
+
ScreenshotResult,
|
|
8
|
+
EvalResult,
|
|
9
|
+
ActionResult,
|
|
10
|
+
ExtractResult,
|
|
11
|
+
HandoffResult,
|
|
12
|
+
AuditEntry,
|
|
13
|
+
TypeResult,
|
|
14
|
+
PressResult,
|
|
15
|
+
SelectResult,
|
|
16
|
+
HoverResult,
|
|
17
|
+
WaitForSelectorResult,
|
|
18
|
+
WaitForUrlResult,
|
|
19
|
+
WaitForResponseResult,
|
|
20
|
+
UploadResult,
|
|
21
|
+
DownloadResult,
|
|
22
|
+
PageInfo,
|
|
23
|
+
PageListResult,
|
|
24
|
+
NewPageResult,
|
|
25
|
+
RouteMock,
|
|
26
|
+
RouteEntry,
|
|
27
|
+
RouteListResult,
|
|
28
|
+
TraceResult,
|
|
29
|
+
PolicyInfo,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
__all__ = [
|
|
34
|
+
"BrowserClient",
|
|
35
|
+
"AsyncBrowserClient",
|
|
36
|
+
"SessionInfo",
|
|
37
|
+
"NavigateResult",
|
|
38
|
+
"ScreenshotResult",
|
|
39
|
+
"EvalResult",
|
|
40
|
+
"ActionResult",
|
|
41
|
+
"ExtractResult",
|
|
42
|
+
"HandoffResult",
|
|
43
|
+
"AuditEntry",
|
|
44
|
+
"TypeResult",
|
|
45
|
+
"PressResult",
|
|
46
|
+
"SelectResult",
|
|
47
|
+
"HoverResult",
|
|
48
|
+
"WaitForSelectorResult",
|
|
49
|
+
"WaitForUrlResult",
|
|
50
|
+
"WaitForResponseResult",
|
|
51
|
+
"UploadResult",
|
|
52
|
+
"DownloadResult",
|
|
53
|
+
"PageInfo",
|
|
54
|
+
"PageListResult",
|
|
55
|
+
"NewPageResult",
|
|
56
|
+
"RouteMock",
|
|
57
|
+
"RouteEntry",
|
|
58
|
+
"RouteListResult",
|
|
59
|
+
"TraceResult",
|
|
60
|
+
"PolicyInfo",
|
|
61
|
+
]
|
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"""agentmb Python SDK — sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
7
|
+
from typing import AsyncGenerator, Generator, List, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .models import (
|
|
12
|
+
ActionResult,
|
|
13
|
+
AuditEntry,
|
|
14
|
+
DaemonStatus,
|
|
15
|
+
DownloadResult,
|
|
16
|
+
EvalResult,
|
|
17
|
+
ExtractResult,
|
|
18
|
+
HandoffResult,
|
|
19
|
+
HoverResult,
|
|
20
|
+
NavigateResult,
|
|
21
|
+
NewPageResult,
|
|
22
|
+
PageListResult,
|
|
23
|
+
PressResult,
|
|
24
|
+
ScreenshotResult,
|
|
25
|
+
SelectResult,
|
|
26
|
+
SessionInfo,
|
|
27
|
+
TypeResult,
|
|
28
|
+
UploadResult,
|
|
29
|
+
WaitForResponseResult,
|
|
30
|
+
WaitForSelectorResult,
|
|
31
|
+
WaitForUrlResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_DEFAULT_BASE_URL = "http://127.0.0.1:19315"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _base_url() -> str:
|
|
38
|
+
port = os.environ.get("AGENTMB_PORT", "19315")
|
|
39
|
+
return f"http://127.0.0.1:{port}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _base_headers(api_token: Optional[str], operator: Optional[str] = None) -> dict:
|
|
43
|
+
"""Headers that go on every request (no content-type — set per-method)."""
|
|
44
|
+
h: dict = {}
|
|
45
|
+
if api_token:
|
|
46
|
+
h["X-API-Token"] = api_token
|
|
47
|
+
if operator:
|
|
48
|
+
h["X-Operator"] = operator
|
|
49
|
+
return h
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _headers(api_token: Optional[str]) -> dict:
|
|
53
|
+
"""Legacy alias — not used for client default headers anymore."""
|
|
54
|
+
return _base_headers(api_token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Sync session handle
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
class Session:
|
|
62
|
+
"""Handle for a single browser session (synchronous)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, session_id: str, client: "BrowserClient") -> None:
|
|
65
|
+
self.id = session_id
|
|
66
|
+
self._client = client
|
|
67
|
+
|
|
68
|
+
def navigate(self, url: str, wait_until: str = "load", purpose: Optional[str] = None, operator: Optional[str] = None) -> NavigateResult:
|
|
69
|
+
body: dict = {"url": url, "wait_until": wait_until}
|
|
70
|
+
if purpose:
|
|
71
|
+
body["purpose"] = purpose
|
|
72
|
+
if operator:
|
|
73
|
+
body["operator"] = operator
|
|
74
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/navigate", body, NavigateResult)
|
|
75
|
+
|
|
76
|
+
def click(self, selector: str, timeout_ms: int = 5000, purpose: Optional[str] = None, operator: Optional[str] = None) -> ActionResult:
|
|
77
|
+
body: dict = {"selector": selector, "timeout_ms": timeout_ms}
|
|
78
|
+
if purpose:
|
|
79
|
+
body["purpose"] = purpose
|
|
80
|
+
if operator:
|
|
81
|
+
body["operator"] = operator
|
|
82
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/click", body, ActionResult)
|
|
83
|
+
|
|
84
|
+
def fill(self, selector: str, value: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> ActionResult:
|
|
85
|
+
body: dict = {"selector": selector, "value": value}
|
|
86
|
+
if purpose:
|
|
87
|
+
body["purpose"] = purpose
|
|
88
|
+
if operator:
|
|
89
|
+
body["operator"] = operator
|
|
90
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/fill", body, ActionResult)
|
|
91
|
+
|
|
92
|
+
def eval(self, expression: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> EvalResult:
|
|
93
|
+
body: dict = {"expression": expression}
|
|
94
|
+
if purpose:
|
|
95
|
+
body["purpose"] = purpose
|
|
96
|
+
if operator:
|
|
97
|
+
body["operator"] = operator
|
|
98
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/eval", body, EvalResult)
|
|
99
|
+
|
|
100
|
+
def extract(self, selector: str, attribute: Optional[str] = None, purpose: Optional[str] = None, operator: Optional[str] = None) -> ExtractResult:
|
|
101
|
+
body: dict = {"selector": selector}
|
|
102
|
+
if attribute:
|
|
103
|
+
body["attribute"] = attribute
|
|
104
|
+
if purpose:
|
|
105
|
+
body["purpose"] = purpose
|
|
106
|
+
if operator:
|
|
107
|
+
body["operator"] = operator
|
|
108
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/extract", body, ExtractResult)
|
|
109
|
+
|
|
110
|
+
def screenshot(self, format: str = "png", full_page: bool = False, purpose: Optional[str] = None, operator: Optional[str] = None) -> ScreenshotResult:
|
|
111
|
+
body: dict = {"format": format, "full_page": full_page}
|
|
112
|
+
if purpose:
|
|
113
|
+
body["purpose"] = purpose
|
|
114
|
+
if operator:
|
|
115
|
+
body["operator"] = operator
|
|
116
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/screenshot", body, ScreenshotResult)
|
|
117
|
+
|
|
118
|
+
def logs(self, tail: int = 20) -> List[AuditEntry]:
|
|
119
|
+
raw = self._client._get(f"/api/v1/sessions/{self.id}/logs?tail={tail}")
|
|
120
|
+
return [AuditEntry.model_validate(e) for e in raw]
|
|
121
|
+
|
|
122
|
+
def cdp_info(self) -> dict:
|
|
123
|
+
"""Return CDP target info for this session's page."""
|
|
124
|
+
return self._client._get(f"/api/v1/sessions/{self.id}/cdp")
|
|
125
|
+
|
|
126
|
+
def cdp_send(self, method: str, params: Optional[dict] = None) -> dict:
|
|
127
|
+
"""Send a single CDP command and return the result."""
|
|
128
|
+
return self._client._post(
|
|
129
|
+
f"/api/v1/sessions/{self.id}/cdp",
|
|
130
|
+
{"method": method, "params": params or {}},
|
|
131
|
+
dict,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def cdp_ws_url(self) -> dict:
|
|
135
|
+
"""Return the browser-level CDP WebSocket URL for native DevTools connection."""
|
|
136
|
+
return self._client._get(f"/api/v1/sessions/{self.id}/cdp/ws")
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Trace export (T08)
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def trace_start(self, screenshots: bool = True, snapshots: bool = True) -> dict:
|
|
143
|
+
"""Start Playwright trace recording for this session."""
|
|
144
|
+
return self._client._post(
|
|
145
|
+
f"/api/v1/sessions/{self.id}/trace/start",
|
|
146
|
+
{"screenshots": screenshots, "snapshots": snapshots},
|
|
147
|
+
dict,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def trace_stop(self) -> "TraceResult":
|
|
151
|
+
"""Stop trace recording and return the trace ZIP as base64."""
|
|
152
|
+
from .models import TraceResult as _T
|
|
153
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/trace/stop", {}, _T)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Network route mocks (T07)
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def routes(self) -> "RouteListResult":
|
|
160
|
+
"""List all active network route mocks for this session."""
|
|
161
|
+
from .models import RouteListResult as _R
|
|
162
|
+
return self._client._get(f"/api/v1/sessions/{self.id}/routes", _R)
|
|
163
|
+
|
|
164
|
+
def route(self, pattern: str, mock: Optional[dict] = None) -> dict:
|
|
165
|
+
"""Register a network route mock (intercept requests matching pattern)."""
|
|
166
|
+
return self._client._post(
|
|
167
|
+
f"/api/v1/sessions/{self.id}/route",
|
|
168
|
+
{"pattern": pattern, "mock": mock or {}},
|
|
169
|
+
dict,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def unroute(self, pattern: str) -> None:
|
|
173
|
+
"""Remove a network route mock."""
|
|
174
|
+
self._client._delete_with_body(f"/api/v1/sessions/{self.id}/route", {"pattern": pattern})
|
|
175
|
+
|
|
176
|
+
def type(self, selector: str, text: str, delay_ms: int = 0, purpose: Optional[str] = None, operator: Optional[str] = None) -> TypeResult:
|
|
177
|
+
body: dict = {"selector": selector, "text": text, "delay_ms": delay_ms}
|
|
178
|
+
if purpose: body["purpose"] = purpose
|
|
179
|
+
if operator: body["operator"] = operator
|
|
180
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/type", body, TypeResult)
|
|
181
|
+
|
|
182
|
+
def press(self, selector: str, key: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> PressResult:
|
|
183
|
+
body: dict = {"selector": selector, "key": key}
|
|
184
|
+
if purpose: body["purpose"] = purpose
|
|
185
|
+
if operator: body["operator"] = operator
|
|
186
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/press", body, PressResult)
|
|
187
|
+
|
|
188
|
+
def select(self, selector: str, values: List[str], purpose: Optional[str] = None, operator: Optional[str] = None) -> SelectResult:
|
|
189
|
+
body: dict = {"selector": selector, "values": values}
|
|
190
|
+
if purpose: body["purpose"] = purpose
|
|
191
|
+
if operator: body["operator"] = operator
|
|
192
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/select", body, SelectResult)
|
|
193
|
+
|
|
194
|
+
def hover(self, selector: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> HoverResult:
|
|
195
|
+
body: dict = {"selector": selector}
|
|
196
|
+
if purpose: body["purpose"] = purpose
|
|
197
|
+
if operator: body["operator"] = operator
|
|
198
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/hover", body, HoverResult)
|
|
199
|
+
|
|
200
|
+
def wait_for_selector(self, selector: str, state: str = "visible", timeout_ms: int = 5000, purpose: Optional[str] = None, operator: Optional[str] = None) -> WaitForSelectorResult:
|
|
201
|
+
body: dict = {"selector": selector, "state": state, "timeout_ms": timeout_ms}
|
|
202
|
+
if purpose: body["purpose"] = purpose
|
|
203
|
+
if operator: body["operator"] = operator
|
|
204
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/wait_for_selector", body, WaitForSelectorResult)
|
|
205
|
+
|
|
206
|
+
def wait_for_url(self, url_pattern: str, timeout_ms: int = 5000, purpose: Optional[str] = None, operator: Optional[str] = None) -> WaitForUrlResult:
|
|
207
|
+
body: dict = {"url_pattern": url_pattern, "timeout_ms": timeout_ms}
|
|
208
|
+
if purpose: body["purpose"] = purpose
|
|
209
|
+
if operator: body["operator"] = operator
|
|
210
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/wait_for_url", body, WaitForUrlResult)
|
|
211
|
+
|
|
212
|
+
def wait_for_response(self, url_pattern: str, timeout_ms: int = 10000, trigger: Optional[dict] = None, purpose: Optional[str] = None, operator: Optional[str] = None) -> WaitForResponseResult:
|
|
213
|
+
body: dict = {"url_pattern": url_pattern, "timeout_ms": timeout_ms}
|
|
214
|
+
if trigger: body["trigger"] = trigger
|
|
215
|
+
if purpose: body["purpose"] = purpose
|
|
216
|
+
if operator: body["operator"] = operator
|
|
217
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/wait_for_response", body, WaitForResponseResult)
|
|
218
|
+
|
|
219
|
+
def upload(self, selector: str, file_path: str, mime_type: str = "application/octet-stream", purpose: Optional[str] = None, operator: Optional[str] = None) -> UploadResult:
|
|
220
|
+
import base64 as _b64
|
|
221
|
+
import os as _os
|
|
222
|
+
with open(file_path, "rb") as f:
|
|
223
|
+
content = _b64.b64encode(f.read()).decode()
|
|
224
|
+
body: dict = {"selector": selector, "content": content, "filename": _os.path.basename(file_path), "mime_type": mime_type}
|
|
225
|
+
if purpose: body["purpose"] = purpose
|
|
226
|
+
if operator: body["operator"] = operator
|
|
227
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/upload", body, UploadResult)
|
|
228
|
+
|
|
229
|
+
def download(self, selector: str, timeout_ms: int = 30000, purpose: Optional[str] = None, operator: Optional[str] = None) -> DownloadResult:
|
|
230
|
+
body: dict = {"selector": selector, "timeout_ms": timeout_ms}
|
|
231
|
+
if purpose: body["purpose"] = purpose
|
|
232
|
+
if operator: body["operator"] = operator
|
|
233
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/download", body, DownloadResult)
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
# Multi-page management (T03)
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def pages(self) -> PageListResult:
|
|
240
|
+
"""List all open pages in this session."""
|
|
241
|
+
return self._client._get(f"/api/v1/sessions/{self.id}/pages", PageListResult)
|
|
242
|
+
|
|
243
|
+
def new_page(self) -> NewPageResult:
|
|
244
|
+
"""Open a new tab/page in this session."""
|
|
245
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/pages", {}, NewPageResult)
|
|
246
|
+
|
|
247
|
+
def switch_page(self, page_id: str) -> dict:
|
|
248
|
+
"""Make the given page_id the active target for actions."""
|
|
249
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/pages/switch", {"page_id": page_id}, dict)
|
|
250
|
+
|
|
251
|
+
def close_page(self, page_id: str) -> None:
|
|
252
|
+
"""Close a specific page by page_id."""
|
|
253
|
+
self._client._delete(f"/api/v1/sessions/{self.id}/pages/{page_id}")
|
|
254
|
+
|
|
255
|
+
def switch_mode(self, mode: str) -> None:
|
|
256
|
+
"""Switch between 'headless' and 'headed' mode."""
|
|
257
|
+
self._client._post(
|
|
258
|
+
f"/api/v1/sessions/{self.id}/mode",
|
|
259
|
+
{"mode": mode},
|
|
260
|
+
dict,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def handoff_start(self) -> HandoffResult:
|
|
264
|
+
"""Switch to headed mode for human login. Call handoff_complete() when done."""
|
|
265
|
+
return self._client._post(
|
|
266
|
+
f"/api/v1/sessions/{self.id}/handoff/start",
|
|
267
|
+
{},
|
|
268
|
+
HandoffResult,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def handoff_complete(self) -> HandoffResult:
|
|
272
|
+
"""Return session to headless mode after human login is complete."""
|
|
273
|
+
return self._client._post(
|
|
274
|
+
f"/api/v1/sessions/{self.id}/handoff/complete",
|
|
275
|
+
{},
|
|
276
|
+
HandoffResult,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def set_policy(self, profile: str, allow_sensitive_actions: Optional[bool] = None) -> "PolicyInfo":
|
|
280
|
+
"""Override the safety execution policy for this session (r06-c02).
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
profile: 'safe' | 'permissive' | 'disabled'
|
|
284
|
+
allow_sensitive_actions: Explicitly enable/disable sensitive action guardrail.
|
|
285
|
+
"""
|
|
286
|
+
from .models import PolicyInfo
|
|
287
|
+
body: dict = {"profile": profile}
|
|
288
|
+
if allow_sensitive_actions is not None:
|
|
289
|
+
body["allow_sensitive_actions"] = allow_sensitive_actions
|
|
290
|
+
return self._client._post(f"/api/v1/sessions/{self.id}/policy", body, PolicyInfo)
|
|
291
|
+
|
|
292
|
+
def get_policy(self) -> "PolicyInfo":
|
|
293
|
+
"""Get the current safety execution policy for this session."""
|
|
294
|
+
from .models import PolicyInfo
|
|
295
|
+
return self._client._get(f"/api/v1/sessions/{self.id}/policy", PolicyInfo)
|
|
296
|
+
|
|
297
|
+
def close(self) -> None:
|
|
298
|
+
self._client._delete(f"/api/v1/sessions/{self.id}")
|
|
299
|
+
|
|
300
|
+
def __enter__(self) -> "Session":
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
def __exit__(self, *_) -> None:
|
|
304
|
+
self.close()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Async session handle
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
class AsyncSession:
|
|
312
|
+
"""Handle for a single browser session (async)."""
|
|
313
|
+
|
|
314
|
+
def __init__(self, session_id: str, client: "AsyncBrowserClient") -> None:
|
|
315
|
+
self.id = session_id
|
|
316
|
+
self._client = client
|
|
317
|
+
|
|
318
|
+
async def navigate(self, url: str, wait_until: str = "load", purpose: Optional[str] = None, operator: Optional[str] = None) -> NavigateResult:
|
|
319
|
+
body: dict = {"url": url, "wait_until": wait_until}
|
|
320
|
+
if purpose:
|
|
321
|
+
body["purpose"] = purpose
|
|
322
|
+
if operator:
|
|
323
|
+
body["operator"] = operator
|
|
324
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/navigate", body, NavigateResult)
|
|
325
|
+
|
|
326
|
+
async def click(self, selector: str, timeout_ms: int = 5000, purpose: Optional[str] = None, operator: Optional[str] = None) -> ActionResult:
|
|
327
|
+
body: dict = {"selector": selector, "timeout_ms": timeout_ms}
|
|
328
|
+
if purpose:
|
|
329
|
+
body["purpose"] = purpose
|
|
330
|
+
if operator:
|
|
331
|
+
body["operator"] = operator
|
|
332
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/click", body, ActionResult)
|
|
333
|
+
|
|
334
|
+
async def fill(self, selector: str, value: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> ActionResult:
|
|
335
|
+
body: dict = {"selector": selector, "value": value}
|
|
336
|
+
if purpose:
|
|
337
|
+
body["purpose"] = purpose
|
|
338
|
+
if operator:
|
|
339
|
+
body["operator"] = operator
|
|
340
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/fill", body, ActionResult)
|
|
341
|
+
|
|
342
|
+
async def eval(self, expression: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> EvalResult:
|
|
343
|
+
body: dict = {"expression": expression}
|
|
344
|
+
if purpose:
|
|
345
|
+
body["purpose"] = purpose
|
|
346
|
+
if operator:
|
|
347
|
+
body["operator"] = operator
|
|
348
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/eval", body, EvalResult)
|
|
349
|
+
|
|
350
|
+
async def extract(self, selector: str, attribute: Optional[str] = None, purpose: Optional[str] = None, operator: Optional[str] = None) -> ExtractResult:
|
|
351
|
+
body: dict = {"selector": selector}
|
|
352
|
+
if attribute:
|
|
353
|
+
body["attribute"] = attribute
|
|
354
|
+
if purpose:
|
|
355
|
+
body["purpose"] = purpose
|
|
356
|
+
if operator:
|
|
357
|
+
body["operator"] = operator
|
|
358
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/extract", body, ExtractResult)
|
|
359
|
+
|
|
360
|
+
async def screenshot(self, format: str = "png", full_page: bool = False, purpose: Optional[str] = None, operator: Optional[str] = None) -> ScreenshotResult:
|
|
361
|
+
body: dict = {"format": format, "full_page": full_page}
|
|
362
|
+
if purpose:
|
|
363
|
+
body["purpose"] = purpose
|
|
364
|
+
if operator:
|
|
365
|
+
body["operator"] = operator
|
|
366
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/screenshot", body, ScreenshotResult)
|
|
367
|
+
|
|
368
|
+
async def logs(self, tail: int = 20) -> List[AuditEntry]:
|
|
369
|
+
raw = await self._client._get(f"/api/v1/sessions/{self.id}/logs?tail={tail}")
|
|
370
|
+
return [AuditEntry.model_validate(e) for e in raw]
|
|
371
|
+
|
|
372
|
+
async def cdp_info(self) -> dict:
|
|
373
|
+
"""Return CDP target info for this session's page."""
|
|
374
|
+
return await self._client._get(f"/api/v1/sessions/{self.id}/cdp")
|
|
375
|
+
|
|
376
|
+
async def cdp_send(self, method: str, params: Optional[dict] = None) -> dict:
|
|
377
|
+
"""Send a single CDP command and return the result."""
|
|
378
|
+
return await self._client._post(
|
|
379
|
+
f"/api/v1/sessions/{self.id}/cdp",
|
|
380
|
+
{"method": method, "params": params or {}},
|
|
381
|
+
dict,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def cdp_ws_url(self) -> dict:
|
|
385
|
+
"""Return the browser-level CDP WebSocket URL for native DevTools connection."""
|
|
386
|
+
return await self._client._get(f"/api/v1/sessions/{self.id}/cdp/ws")
|
|
387
|
+
|
|
388
|
+
# ------------------------------------------------------------------
|
|
389
|
+
# Trace export (T08)
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
async def trace_start(self, screenshots: bool = True, snapshots: bool = True) -> dict:
|
|
393
|
+
"""Start Playwright trace recording for this session."""
|
|
394
|
+
return await self._client._post(
|
|
395
|
+
f"/api/v1/sessions/{self.id}/trace/start",
|
|
396
|
+
{"screenshots": screenshots, "snapshots": snapshots},
|
|
397
|
+
dict,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
async def trace_stop(self) -> "TraceResult":
|
|
401
|
+
"""Stop trace recording and return the trace ZIP as base64."""
|
|
402
|
+
from .models import TraceResult as _T
|
|
403
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/trace/stop", {}, _T)
|
|
404
|
+
|
|
405
|
+
# ------------------------------------------------------------------
|
|
406
|
+
# Network route mocks (T07)
|
|
407
|
+
# ------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
async def routes(self) -> "RouteListResult":
|
|
410
|
+
"""List all active network route mocks for this session."""
|
|
411
|
+
from .models import RouteListResult as _R
|
|
412
|
+
return await self._client._get(f"/api/v1/sessions/{self.id}/routes", _R)
|
|
413
|
+
|
|
414
|
+
async def route(self, pattern: str, mock: Optional[dict] = None) -> dict:
|
|
415
|
+
"""Register a network route mock."""
|
|
416
|
+
return await self._client._post(
|
|
417
|
+
f"/api/v1/sessions/{self.id}/route",
|
|
418
|
+
{"pattern": pattern, "mock": mock or {}},
|
|
419
|
+
dict,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
async def unroute(self, pattern: str) -> None:
|
|
423
|
+
"""Remove a network route mock."""
|
|
424
|
+
await self._client._delete_with_body(f"/api/v1/sessions/{self.id}/route", {"pattern": pattern})
|
|
425
|
+
|
|
426
|
+
async def type(self, selector: str, text: str, delay_ms: int = 0, purpose: Optional[str] = None, operator: Optional[str] = None) -> TypeResult:
|
|
427
|
+
body: dict = {"selector": selector, "text": text, "delay_ms": delay_ms}
|
|
428
|
+
if purpose: body["purpose"] = purpose
|
|
429
|
+
if operator: body["operator"] = operator
|
|
430
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/type", body, TypeResult)
|
|
431
|
+
|
|
432
|
+
async def press(self, selector: str, key: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> PressResult:
|
|
433
|
+
body: dict = {"selector": selector, "key": key}
|
|
434
|
+
if purpose: body["purpose"] = purpose
|
|
435
|
+
if operator: body["operator"] = operator
|
|
436
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/press", body, PressResult)
|
|
437
|
+
|
|
438
|
+
async def select(self, selector: str, values: List[str], purpose: Optional[str] = None, operator: Optional[str] = None) -> SelectResult:
|
|
439
|
+
body: dict = {"selector": selector, "values": values}
|
|
440
|
+
if purpose: body["purpose"] = purpose
|
|
441
|
+
if operator: body["operator"] = operator
|
|
442
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/select", body, SelectResult)
|
|
443
|
+
|
|
444
|
+
async def hover(self, selector: str, purpose: Optional[str] = None, operator: Optional[str] = None) -> HoverResult:
|
|
445
|
+
body: dict = {"selector": selector}
|
|
446
|
+
if purpose: body["purpose"] = purpose
|
|
447
|
+
if operator: body["operator"] = operator
|
|
448
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/hover", body, HoverResult)
|
|
449
|
+
|
|
450
|
+
async def wait_for_selector(self, selector: str, state: str = "visible", timeout_ms: int = 5000, purpose: Optional[str] = None, operator: Optional[str] = None) -> WaitForSelectorResult:
|
|
451
|
+
body: dict = {"selector": selector, "state": state, "timeout_ms": timeout_ms}
|
|
452
|
+
if purpose: body["purpose"] = purpose
|
|
453
|
+
if operator: body["operator"] = operator
|
|
454
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/wait_for_selector", body, WaitForSelectorResult)
|
|
455
|
+
|
|
456
|
+
async def wait_for_url(self, url_pattern: str, timeout_ms: int = 5000, purpose: Optional[str] = None, operator: Optional[str] = None) -> WaitForUrlResult:
|
|
457
|
+
body: dict = {"url_pattern": url_pattern, "timeout_ms": timeout_ms}
|
|
458
|
+
if purpose: body["purpose"] = purpose
|
|
459
|
+
if operator: body["operator"] = operator
|
|
460
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/wait_for_url", body, WaitForUrlResult)
|
|
461
|
+
|
|
462
|
+
async def wait_for_response(self, url_pattern: str, timeout_ms: int = 10000, trigger: Optional[dict] = None, purpose: Optional[str] = None, operator: Optional[str] = None) -> WaitForResponseResult:
|
|
463
|
+
body: dict = {"url_pattern": url_pattern, "timeout_ms": timeout_ms}
|
|
464
|
+
if trigger: body["trigger"] = trigger
|
|
465
|
+
if purpose: body["purpose"] = purpose
|
|
466
|
+
if operator: body["operator"] = operator
|
|
467
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/wait_for_response", body, WaitForResponseResult)
|
|
468
|
+
|
|
469
|
+
async def upload(self, selector: str, file_path: str, mime_type: str = "application/octet-stream", purpose: Optional[str] = None, operator: Optional[str] = None) -> UploadResult:
|
|
470
|
+
import base64 as _b64
|
|
471
|
+
import os as _os
|
|
472
|
+
import asyncio as _asyncio
|
|
473
|
+
def _read() -> str:
|
|
474
|
+
with open(file_path, "rb") as f:
|
|
475
|
+
return _b64.b64encode(f.read()).decode()
|
|
476
|
+
content = await _asyncio.to_thread(_read)
|
|
477
|
+
body: dict = {"selector": selector, "content": content, "filename": _os.path.basename(file_path), "mime_type": mime_type}
|
|
478
|
+
if purpose: body["purpose"] = purpose
|
|
479
|
+
if operator: body["operator"] = operator
|
|
480
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/upload", body, UploadResult)
|
|
481
|
+
|
|
482
|
+
async def download(self, selector: str, timeout_ms: int = 30000, purpose: Optional[str] = None, operator: Optional[str] = None) -> DownloadResult:
|
|
483
|
+
body: dict = {"selector": selector, "timeout_ms": timeout_ms}
|
|
484
|
+
if purpose: body["purpose"] = purpose
|
|
485
|
+
if operator: body["operator"] = operator
|
|
486
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/download", body, DownloadResult)
|
|
487
|
+
|
|
488
|
+
# ------------------------------------------------------------------
|
|
489
|
+
# Multi-page management (T03)
|
|
490
|
+
# ------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
async def pages(self) -> PageListResult:
|
|
493
|
+
return await self._client._get(f"/api/v1/sessions/{self.id}/pages", PageListResult)
|
|
494
|
+
|
|
495
|
+
async def new_page(self) -> NewPageResult:
|
|
496
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/pages", {}, NewPageResult)
|
|
497
|
+
|
|
498
|
+
async def switch_page(self, page_id: str) -> dict:
|
|
499
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/pages/switch", {"page_id": page_id}, dict)
|
|
500
|
+
|
|
501
|
+
async def close_page(self, page_id: str) -> None:
|
|
502
|
+
await self._client._delete(f"/api/v1/sessions/{self.id}/pages/{page_id}")
|
|
503
|
+
|
|
504
|
+
async def handoff_start(self) -> HandoffResult:
|
|
505
|
+
"""Switch to headed mode for human login. Call handoff_complete() when done."""
|
|
506
|
+
return await self._client._post(
|
|
507
|
+
f"/api/v1/sessions/{self.id}/handoff/start",
|
|
508
|
+
{},
|
|
509
|
+
HandoffResult,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
async def handoff_complete(self) -> HandoffResult:
|
|
513
|
+
"""Return session to headless mode after human login is complete."""
|
|
514
|
+
return await self._client._post(
|
|
515
|
+
f"/api/v1/sessions/{self.id}/handoff/complete",
|
|
516
|
+
{},
|
|
517
|
+
HandoffResult,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
async def set_policy(self, profile: str, allow_sensitive_actions: Optional[bool] = None) -> "PolicyInfo":
|
|
521
|
+
"""Override the safety execution policy for this session."""
|
|
522
|
+
from .models import PolicyInfo
|
|
523
|
+
body: dict = {"profile": profile}
|
|
524
|
+
if allow_sensitive_actions is not None:
|
|
525
|
+
body["allow_sensitive_actions"] = allow_sensitive_actions
|
|
526
|
+
return await self._client._post(f"/api/v1/sessions/{self.id}/policy", body, PolicyInfo)
|
|
527
|
+
|
|
528
|
+
async def get_policy(self) -> "PolicyInfo":
|
|
529
|
+
"""Get the current safety execution policy for this session."""
|
|
530
|
+
from .models import PolicyInfo
|
|
531
|
+
return await self._client._get(f"/api/v1/sessions/{self.id}/policy", PolicyInfo)
|
|
532
|
+
|
|
533
|
+
async def close(self) -> None:
|
|
534
|
+
await self._client._delete(f"/api/v1/sessions/{self.id}")
|
|
535
|
+
|
|
536
|
+
async def __aenter__(self) -> "AsyncSession":
|
|
537
|
+
return self
|
|
538
|
+
|
|
539
|
+
async def __aexit__(self, *_) -> None:
|
|
540
|
+
await self.close()
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# Sync client
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
class BrowserClient:
|
|
548
|
+
"""Synchronous agentmb client.
|
|
549
|
+
|
|
550
|
+
Usage::
|
|
551
|
+
|
|
552
|
+
client = BrowserClient()
|
|
553
|
+
with client.sessions.create(profile="myprofile") as sess:
|
|
554
|
+
sess.navigate("https://example.com")
|
|
555
|
+
shot = sess.screenshot()
|
|
556
|
+
shot.save("/tmp/out.png")
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
def __init__(
|
|
560
|
+
self,
|
|
561
|
+
base_url: Optional[str] = None,
|
|
562
|
+
api_token: Optional[str] = None,
|
|
563
|
+
timeout: float = 30.0,
|
|
564
|
+
operator: Optional[str] = None,
|
|
565
|
+
) -> None:
|
|
566
|
+
self._base_url = base_url or _base_url()
|
|
567
|
+
self._api_token = api_token or os.environ.get("AGENTMB_API_TOKEN")
|
|
568
|
+
self._operator = operator or os.environ.get("AGENTMB_OPERATOR")
|
|
569
|
+
self._http = httpx.Client(
|
|
570
|
+
base_url=self._base_url,
|
|
571
|
+
headers=_base_headers(self._api_token, self._operator),
|
|
572
|
+
timeout=timeout,
|
|
573
|
+
)
|
|
574
|
+
self.sessions = _SyncSessionManager(self)
|
|
575
|
+
|
|
576
|
+
def health(self) -> DaemonStatus:
|
|
577
|
+
return self._get("/health", DaemonStatus)
|
|
578
|
+
|
|
579
|
+
def _post(self, path: str, body: dict, model=None):
|
|
580
|
+
resp = self._http.post(path, json=body, headers={"content-type": "application/json"})
|
|
581
|
+
resp.raise_for_status()
|
|
582
|
+
data = resp.json()
|
|
583
|
+
if model and model is not dict:
|
|
584
|
+
return model.model_validate(data)
|
|
585
|
+
return data
|
|
586
|
+
|
|
587
|
+
def _get(self, path: str, model=None):
|
|
588
|
+
resp = self._http.get(path)
|
|
589
|
+
resp.raise_for_status()
|
|
590
|
+
data = resp.json()
|
|
591
|
+
if model:
|
|
592
|
+
return model.model_validate(data)
|
|
593
|
+
return data
|
|
594
|
+
|
|
595
|
+
def _delete(self, path: str) -> None:
|
|
596
|
+
resp = self._http.delete(path)
|
|
597
|
+
if resp.status_code not in (200, 204, 404):
|
|
598
|
+
resp.raise_for_status()
|
|
599
|
+
|
|
600
|
+
def _delete_with_body(self, path: str, body: dict) -> None:
|
|
601
|
+
resp = self._http.request("DELETE", path, json=body, headers={"content-type": "application/json"})
|
|
602
|
+
if resp.status_code not in (200, 204, 404):
|
|
603
|
+
resp.raise_for_status()
|
|
604
|
+
|
|
605
|
+
def close(self) -> None:
|
|
606
|
+
self._http.close()
|
|
607
|
+
|
|
608
|
+
def __enter__(self) -> "BrowserClient":
|
|
609
|
+
return self
|
|
610
|
+
|
|
611
|
+
def __exit__(self, *_) -> None:
|
|
612
|
+
self.close()
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class _SyncSessionManager:
|
|
616
|
+
def __init__(self, client: BrowserClient) -> None:
|
|
617
|
+
self._client = client
|
|
618
|
+
|
|
619
|
+
def create(
|
|
620
|
+
self,
|
|
621
|
+
profile: str = "default",
|
|
622
|
+
headless: bool = True,
|
|
623
|
+
agent_id: Optional[str] = None,
|
|
624
|
+
accept_downloads: bool = False,
|
|
625
|
+
) -> Session:
|
|
626
|
+
info = self._client._post(
|
|
627
|
+
"/api/v1/sessions",
|
|
628
|
+
{"profile": profile, "headless": headless, "agent_id": agent_id, "accept_downloads": accept_downloads},
|
|
629
|
+
SessionInfo,
|
|
630
|
+
)
|
|
631
|
+
return Session(info.session_id, self._client)
|
|
632
|
+
|
|
633
|
+
def list(self) -> List[SessionInfo]:
|
|
634
|
+
raw = self._client._get("/api/v1/sessions")
|
|
635
|
+
return [SessionInfo.model_validate(s) for s in raw]
|
|
636
|
+
|
|
637
|
+
def get(self, session_id: str) -> SessionInfo:
|
|
638
|
+
return self._client._get(f"/api/v1/sessions/{session_id}", SessionInfo)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
# Async client
|
|
643
|
+
# ---------------------------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
class AsyncBrowserClient:
|
|
646
|
+
"""Async agentmb client (for use with asyncio / LangGraph).
|
|
647
|
+
|
|
648
|
+
Usage::
|
|
649
|
+
|
|
650
|
+
async with AsyncBrowserClient() as client:
|
|
651
|
+
async with client.sessions.create() as sess:
|
|
652
|
+
await sess.navigate("https://example.com")
|
|
653
|
+
result = await sess.eval("document.title")
|
|
654
|
+
"""
|
|
655
|
+
|
|
656
|
+
def __init__(
|
|
657
|
+
self,
|
|
658
|
+
base_url: Optional[str] = None,
|
|
659
|
+
api_token: Optional[str] = None,
|
|
660
|
+
timeout: float = 30.0,
|
|
661
|
+
operator: Optional[str] = None,
|
|
662
|
+
) -> None:
|
|
663
|
+
self._base_url = base_url or _base_url()
|
|
664
|
+
self._api_token = api_token or os.environ.get("AGENTMB_API_TOKEN")
|
|
665
|
+
self._operator = operator or os.environ.get("AGENTMB_OPERATOR")
|
|
666
|
+
self._timeout = timeout
|
|
667
|
+
self._http: Optional[httpx.AsyncClient] = None
|
|
668
|
+
self.sessions = _AsyncSessionManager(self)
|
|
669
|
+
|
|
670
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
671
|
+
if self._http is None:
|
|
672
|
+
self._http = httpx.AsyncClient(
|
|
673
|
+
base_url=self._base_url,
|
|
674
|
+
headers=_base_headers(self._api_token, self._operator),
|
|
675
|
+
timeout=self._timeout,
|
|
676
|
+
)
|
|
677
|
+
return self._http
|
|
678
|
+
|
|
679
|
+
async def health(self) -> DaemonStatus:
|
|
680
|
+
return await self._get("/health", DaemonStatus)
|
|
681
|
+
|
|
682
|
+
async def _post(self, path: str, body: dict, model=None):
|
|
683
|
+
client = await self._ensure_client()
|
|
684
|
+
resp = await client.post(path, json=body, headers={"content-type": "application/json"})
|
|
685
|
+
resp.raise_for_status()
|
|
686
|
+
data = resp.json()
|
|
687
|
+
if model and model is not dict:
|
|
688
|
+
return model.model_validate(data)
|
|
689
|
+
return data
|
|
690
|
+
|
|
691
|
+
async def _get(self, path: str, model=None):
|
|
692
|
+
client = await self._ensure_client()
|
|
693
|
+
resp = await client.get(path)
|
|
694
|
+
resp.raise_for_status()
|
|
695
|
+
data = resp.json()
|
|
696
|
+
if model:
|
|
697
|
+
return model.model_validate(data)
|
|
698
|
+
return data
|
|
699
|
+
|
|
700
|
+
async def _delete(self, path: str) -> None:
|
|
701
|
+
client = await self._ensure_client()
|
|
702
|
+
resp = await client.delete(path)
|
|
703
|
+
if resp.status_code not in (200, 204, 404):
|
|
704
|
+
resp.raise_for_status()
|
|
705
|
+
|
|
706
|
+
async def _delete_with_body(self, path: str, body: dict) -> None:
|
|
707
|
+
client = await self._ensure_client()
|
|
708
|
+
resp = await client.request("DELETE", path, json=body, headers={"content-type": "application/json"})
|
|
709
|
+
if resp.status_code not in (200, 204, 404):
|
|
710
|
+
resp.raise_for_status()
|
|
711
|
+
|
|
712
|
+
async def close(self) -> None:
|
|
713
|
+
if self._http:
|
|
714
|
+
await self._http.aclose()
|
|
715
|
+
self._http = None
|
|
716
|
+
|
|
717
|
+
async def __aenter__(self) -> "AsyncBrowserClient":
|
|
718
|
+
return self
|
|
719
|
+
|
|
720
|
+
async def __aexit__(self, *_) -> None:
|
|
721
|
+
await self.close()
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
class _AsyncSessionManager:
|
|
725
|
+
def __init__(self, client: AsyncBrowserClient) -> None:
|
|
726
|
+
self._client = client
|
|
727
|
+
|
|
728
|
+
async def create(
|
|
729
|
+
self,
|
|
730
|
+
profile: str = "default",
|
|
731
|
+
headless: bool = True,
|
|
732
|
+
agent_id: Optional[str] = None,
|
|
733
|
+
accept_downloads: bool = False,
|
|
734
|
+
) -> AsyncSession:
|
|
735
|
+
info = await self._client._post(
|
|
736
|
+
"/api/v1/sessions",
|
|
737
|
+
{"profile": profile, "headless": headless, "agent_id": agent_id, "accept_downloads": accept_downloads},
|
|
738
|
+
SessionInfo,
|
|
739
|
+
)
|
|
740
|
+
return AsyncSession(info.session_id, self._client)
|
|
741
|
+
|
|
742
|
+
async def list(self) -> List[SessionInfo]:
|
|
743
|
+
raw = await self._client._get("/api/v1/sessions")
|
|
744
|
+
return [SessionInfo.model_validate(s) for s in raw]
|
|
745
|
+
|
|
746
|
+
async def get(self, session_id: str) -> SessionInfo:
|
|
747
|
+
return await self._client._get(f"/api/v1/sessions/{session_id}", SessionInfo)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Pydantic v2 models for agentmb SDK responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionInfo(BaseModel):
|
|
12
|
+
session_id: str
|
|
13
|
+
profile: str
|
|
14
|
+
headless: bool
|
|
15
|
+
created_at: str
|
|
16
|
+
state: str = "live" # 'live' | 'zombie'
|
|
17
|
+
agent_id: Optional[str] = None
|
|
18
|
+
accept_downloads: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NavigateResult(BaseModel):
|
|
22
|
+
status: str
|
|
23
|
+
url: str
|
|
24
|
+
title: str
|
|
25
|
+
duration_ms: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ActionResult(BaseModel):
|
|
29
|
+
status: str
|
|
30
|
+
selector: Optional[str] = None
|
|
31
|
+
duration_ms: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EvalResult(BaseModel):
|
|
35
|
+
status: str
|
|
36
|
+
result: Any
|
|
37
|
+
duration_ms: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ScreenshotResult(BaseModel):
|
|
41
|
+
status: str
|
|
42
|
+
data: str # base64-encoded PNG or JPEG
|
|
43
|
+
format: str
|
|
44
|
+
duration_ms: int
|
|
45
|
+
|
|
46
|
+
def to_bytes(self) -> bytes:
|
|
47
|
+
"""Decode base64 data to raw bytes."""
|
|
48
|
+
return base64.b64decode(self.data)
|
|
49
|
+
|
|
50
|
+
def save(self, path: str) -> None:
|
|
51
|
+
"""Write screenshot bytes to a file."""
|
|
52
|
+
with open(path, "wb") as f:
|
|
53
|
+
f.write(self.to_bytes())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ExtractResult(BaseModel):
|
|
57
|
+
status: str
|
|
58
|
+
selector: str
|
|
59
|
+
items: List[Dict[str, Any]]
|
|
60
|
+
count: int
|
|
61
|
+
duration_ms: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TypeResult(BaseModel):
|
|
65
|
+
status: str
|
|
66
|
+
selector: str
|
|
67
|
+
duration_ms: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PressResult(BaseModel):
|
|
71
|
+
status: str
|
|
72
|
+
selector: str
|
|
73
|
+
key: str
|
|
74
|
+
duration_ms: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SelectResult(BaseModel):
|
|
78
|
+
status: str
|
|
79
|
+
selector: str
|
|
80
|
+
selected: List[str]
|
|
81
|
+
duration_ms: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HoverResult(BaseModel):
|
|
85
|
+
status: str
|
|
86
|
+
selector: str
|
|
87
|
+
duration_ms: int
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class WaitForSelectorResult(BaseModel):
|
|
91
|
+
status: str
|
|
92
|
+
selector: str
|
|
93
|
+
state: str
|
|
94
|
+
duration_ms: int
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class WaitForUrlResult(BaseModel):
|
|
98
|
+
status: str
|
|
99
|
+
url: str
|
|
100
|
+
duration_ms: int
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class WaitForResponseResult(BaseModel):
|
|
104
|
+
status: str
|
|
105
|
+
url: str
|
|
106
|
+
status_code: int
|
|
107
|
+
duration_ms: int
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class UploadResult(BaseModel):
|
|
111
|
+
status: str
|
|
112
|
+
selector: str
|
|
113
|
+
filename: str
|
|
114
|
+
size_bytes: int
|
|
115
|
+
duration_ms: int
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PageInfo(BaseModel):
|
|
119
|
+
page_id: str
|
|
120
|
+
url: str
|
|
121
|
+
active: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class PageListResult(BaseModel):
|
|
125
|
+
session_id: str
|
|
126
|
+
pages: List[PageInfo]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class NewPageResult(BaseModel):
|
|
130
|
+
session_id: str
|
|
131
|
+
page_id: str
|
|
132
|
+
url: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RouteMock(BaseModel):
|
|
136
|
+
status: Optional[int] = 200
|
|
137
|
+
headers: Optional[Dict[str, str]] = None
|
|
138
|
+
body: Optional[str] = None
|
|
139
|
+
content_type: Optional[str] = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class RouteEntry(BaseModel):
|
|
143
|
+
pattern: str
|
|
144
|
+
mock: RouteMock
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class RouteListResult(BaseModel):
|
|
148
|
+
session_id: str
|
|
149
|
+
routes: List[RouteEntry]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class DownloadResult(BaseModel):
|
|
153
|
+
status: str
|
|
154
|
+
filename: str
|
|
155
|
+
data: str # base64-encoded file content
|
|
156
|
+
size_bytes: int
|
|
157
|
+
duration_ms: int
|
|
158
|
+
|
|
159
|
+
def to_bytes(self) -> bytes:
|
|
160
|
+
"""Decode base64 data to raw bytes."""
|
|
161
|
+
return base64.b64decode(self.data)
|
|
162
|
+
|
|
163
|
+
def save(self, path: str) -> None:
|
|
164
|
+
"""Write downloaded file bytes to a file."""
|
|
165
|
+
with open(path, "wb") as f:
|
|
166
|
+
f.write(self.to_bytes())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TraceResult(BaseModel):
|
|
170
|
+
session_id: str
|
|
171
|
+
data: str # base64-encoded ZIP
|
|
172
|
+
format: str # always 'zip'
|
|
173
|
+
size_bytes: int
|
|
174
|
+
|
|
175
|
+
def to_bytes(self) -> bytes:
|
|
176
|
+
return base64.b64decode(self.data)
|
|
177
|
+
|
|
178
|
+
def save(self, path: str) -> None:
|
|
179
|
+
with open(path, "wb") as f:
|
|
180
|
+
f.write(self.to_bytes())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class AuditEntry(BaseModel):
|
|
184
|
+
ts: Optional[str] = None
|
|
185
|
+
v: Optional[int] = None
|
|
186
|
+
session_id: Optional[str] = None
|
|
187
|
+
action_id: Optional[str] = None
|
|
188
|
+
type: str
|
|
189
|
+
action: Optional[str] = None
|
|
190
|
+
url: Optional[str] = None
|
|
191
|
+
selector: Optional[str] = None
|
|
192
|
+
params: Optional[Dict[str, Any]] = None
|
|
193
|
+
result: Optional[Dict[str, Any]] = None
|
|
194
|
+
error: Optional[str] = None
|
|
195
|
+
purpose: Optional[str] = None # why this action is being taken
|
|
196
|
+
operator: Optional[str] = None # who/what is invoking
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class HandoffResult(BaseModel):
|
|
200
|
+
session_id: str
|
|
201
|
+
mode: str # 'headed' | 'headless'
|
|
202
|
+
message: str
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class DaemonStatus(BaseModel):
|
|
206
|
+
status: str
|
|
207
|
+
version: str
|
|
208
|
+
uptime_s: int
|
|
209
|
+
sessions_active: int
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class PolicyInfo(BaseModel):
|
|
214
|
+
"""Current safety policy for a session (r06-c02)."""
|
|
215
|
+
session_id: str
|
|
216
|
+
profile: str # 'safe' | 'permissive' | 'disabled'
|
|
217
|
+
domain_min_interval_ms: int
|
|
218
|
+
jitter_ms: List[int]
|
|
219
|
+
cooldown_after_error_ms: int
|
|
220
|
+
max_retries_per_domain: int
|
|
221
|
+
max_actions_per_minute: int
|
|
222
|
+
allow_sensitive_actions: bool
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentmb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for agentmb — local Chromium runtime daemon for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0",
|
|
20
|
+
"pytest-asyncio>=0.24",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["agentmb"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
asyncio_mode = "auto"
|