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.
@@ -0,0 +1,17 @@
1
+ node_modules/
2
+ dist/
3
+ .npm-cache/
4
+ .env
5
+ .env.local
6
+ .env.test
7
+ *.log
8
+ .DS_Store
9
+
10
+ # Python SDK
11
+ sdk/python/__pycache__/
12
+ sdk/python/*.egg-info/
13
+ sdk/python/.venv/
14
+ sdk/python/dist/
15
+
16
+ # agentmb runtime data
17
+ ~/.agentmb/
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
@@ -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"