bobby-browser 0.17.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,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: bobby-browser
3
+ Version: 0.17.0
4
+ Summary: Typed client for the authenticated Bobby Browser runtime interface
5
+ Author: Sasan Sotoodehfar
6
+ License: MIT
7
+ Project-URL: Homepage, https://cavi-ai.xyz/docs/bobby-browser
8
+ Project-URL: Repository, https://github.com/cavi-ai/bobby-browser
9
+ Project-URL: Issues, https://github.com/cavi-ai/bobby-browser/issues
10
+ Keywords: browser-automation,cdp,mcp,playwright,puppeteer,bobby-browser
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+
14
+ # bobby-browser (Python SDK)
15
+
16
+ Typed HTTP client for the authenticated Bobby Browser `/v1` runtime
17
+ interface. Stdlib only (`urllib`, `json`, `dataclasses`, `typing`) -- no
18
+ third-party HTTP client dependency. Python >= 3.10.
19
+
20
+ Mirrors `@cavi-ai/bobby-browser` (TypeScript,
21
+ `packages/typescript-sdk`) and `bobby-browser-client` (Rust,
22
+ `crates/bobby-browser-client`): same auth header contract, idempotency-key
23
+ passthrough, and `CommandOutcome` status discriminator.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install bobby-browser
29
+ ```
30
+
31
+ From a bobby-browser checkout: `pip install -e packages/python-sdk`.
32
+
33
+ `bobby install --skill-hermes` copies `skill/hermes/SKILL.md` into
34
+ `$HERMES_HOME/skills/bobby-browser/` (else `~/.hermes/skills/`).
35
+
36
+ ## Use
37
+
38
+ ```python
39
+ import os
40
+ from bobby_browser import BrowserRuntimeClient
41
+
42
+ client = BrowserRuntimeClient("http://127.0.0.1:7777", os.environ["AUTOMATION_RUNTIME_TOKEN"])
43
+ ```
44
+
45
+ Full method catalog, headers, and error shape:
46
+ [docs/bobby-browser/source/pages/surfaces/python-sdk.md](../../docs/bobby-browser/source/pages/surfaces/python-sdk.md).
47
+
48
+ ## Test
49
+
50
+ ```bash
51
+ python3 -m unittest discover -s tests
52
+ ```
53
+
54
+ `tests/test_live_runtime.py` additionally starts the real `bobby` runtime
55
+ from this worktree's release build and skips cleanly when
56
+ `BOBBY_CHROME_EXECUTABLE` is unset.
@@ -0,0 +1,43 @@
1
+ # bobby-browser (Python SDK)
2
+
3
+ Typed HTTP client for the authenticated Bobby Browser `/v1` runtime
4
+ interface. Stdlib only (`urllib`, `json`, `dataclasses`, `typing`) -- no
5
+ third-party HTTP client dependency. Python >= 3.10.
6
+
7
+ Mirrors `@cavi-ai/bobby-browser` (TypeScript,
8
+ `packages/typescript-sdk`) and `bobby-browser-client` (Rust,
9
+ `crates/bobby-browser-client`): same auth header contract, idempotency-key
10
+ passthrough, and `CommandOutcome` status discriminator.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install bobby-browser
16
+ ```
17
+
18
+ From a bobby-browser checkout: `pip install -e packages/python-sdk`.
19
+
20
+ `bobby install --skill-hermes` copies `skill/hermes/SKILL.md` into
21
+ `$HERMES_HOME/skills/bobby-browser/` (else `~/.hermes/skills/`).
22
+
23
+ ## Use
24
+
25
+ ```python
26
+ import os
27
+ from bobby_browser import BrowserRuntimeClient
28
+
29
+ client = BrowserRuntimeClient("http://127.0.0.1:7777", os.environ["AUTOMATION_RUNTIME_TOKEN"])
30
+ ```
31
+
32
+ Full method catalog, headers, and error shape:
33
+ [docs/bobby-browser/source/pages/surfaces/python-sdk.md](../../docs/bobby-browser/source/pages/surfaces/python-sdk.md).
34
+
35
+ ## Test
36
+
37
+ ```bash
38
+ python3 -m unittest discover -s tests
39
+ ```
40
+
41
+ `tests/test_live_runtime.py` additionally starts the real `bobby` runtime
42
+ from this worktree's release build and skips cleanly when
43
+ `BOBBY_CHROME_EXECUTABLE` is unset.
@@ -0,0 +1,20 @@
1
+ """``bobby-browser`` -- typed HTTP client for a Bobby Browser runtime
2
+ (``bobby serve``) speaking the authenticated ``/v1`` interface.
3
+
4
+ Pair with ``@cavi-ai/bobby-browser`` (TypeScript) or ``bobby-browser-client``
5
+ (Rust) for the same surface from other callers. Auth headers on every
6
+ request: ``Authorization: Bearer ...``, ``x-interface-version``,
7
+ ``x-correlation-id``, and ``x-deadline``.
8
+ """
9
+
10
+ from .client import INTERFACE_VERSION, BrowserRuntimeClient, RequestOptions
11
+ from .errors import RuntimeClientError
12
+
13
+ __all__ = [
14
+ "BrowserRuntimeClient",
15
+ "RequestOptions",
16
+ "RuntimeClientError",
17
+ "INTERFACE_VERSION",
18
+ ]
19
+
20
+ __version__ = "0.17.0"
@@ -0,0 +1,470 @@
1
+ """HTTP client for the Bobby Browser ``/v1`` runtime interface.
2
+
3
+ Mirrors ``packages/typescript-sdk/src/client.ts``: every request sends
4
+ ``Authorization``, ``x-interface-version``, ``x-correlation-id``, and
5
+ ``x-deadline``; mutating calls accept an idempotency key; failures raise
6
+ :class:`~bobby_browser.errors.RuntimeClientError`. Stdlib only
7
+ (``urllib``, ``json``, ``dataclasses``, ``typing``) -- no third-party HTTP
8
+ client.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import re
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ import uuid
20
+ from dataclasses import dataclass
21
+ from datetime import datetime, timedelta, timezone
22
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
23
+
24
+ from .errors import RuntimeClientError
25
+
26
+ # Interface version negotiated via the `x-interface-version` request header.
27
+ # Keep aligned with packages/typescript-sdk/src/contracts.ts INTERFACE_VERSION.
28
+ INTERFACE_VERSION = "2026-08-19"
29
+
30
+ _DEFAULT_TIMEOUT_MS = 30_000
31
+ _JSON_CONTENT_TYPE = re.compile(r"^application/json(?:\s*;|$)", re.IGNORECASE)
32
+
33
+ # CommandOutcome.status -> expected HTTP status, mirroring client.ts's
34
+ # commandStatus().
35
+ _COMMAND_STATUS_HTTP: Dict[str, int] = {
36
+ "completed": 200,
37
+ "restarted": 200,
38
+ "retryableFailure": 503,
39
+ "needsReconciliation": 409,
40
+ "policyDenied": 403,
41
+ "resourceExhausted": 429,
42
+ }
43
+
44
+ # RecoveryDecision.status -> expected HTTP status, mirroring client.ts's
45
+ # `recover()` mapping.
46
+ _RECOVERY_NEEDS_RECONCILIATION_STATUS = "needsReconciliation"
47
+
48
+
49
+ @dataclass
50
+ class RequestOptions:
51
+ """Per-call overrides. All fields are optional.
52
+
53
+ Attributes:
54
+ timeout_ms: Relative timeout in milliseconds (default 30_000).
55
+ deadline: Absolute RFC3339 deadline string; combined with
56
+ ``timeout_ms`` as the earlier of the two, same as the TS client.
57
+ correlation_id: Value for ``x-correlation-id`` (a UUID4 is
58
+ generated when omitted).
59
+ idempotency_key: Value for ``idempotency-key`` on mutating POSTs.
60
+ """
61
+
62
+ timeout_ms: Optional[int] = None
63
+ deadline: Optional[str] = None
64
+ correlation_id: Optional[str] = None
65
+ idempotency_key: Optional[str] = None
66
+
67
+
68
+ def _uuid4() -> str:
69
+ return str(uuid.uuid4())
70
+
71
+
72
+ def _deadline_header(options: Optional[RequestOptions], default_timeout_ms: int) -> str:
73
+ if options is not None and options.deadline:
74
+ return options.deadline
75
+ timeout_ms = default_timeout_ms
76
+ if options is not None and options.timeout_ms is not None:
77
+ timeout_ms = options.timeout_ms
78
+ deadline = datetime.now(timezone.utc) + timedelta(milliseconds=timeout_ms)
79
+ return deadline.strftime("%Y-%m-%dT%H:%M:%S.") + f"{deadline.microsecond // 1000:03d}Z"
80
+
81
+
82
+ def _header_get(headers: Mapping[str, str], name: str) -> Optional[str]:
83
+ lowered = name.lower()
84
+ for key, value in headers.items():
85
+ if key.lower() == lowered:
86
+ return value
87
+ return None
88
+
89
+
90
+ def _content_type(headers: Mapping[str, str]) -> str:
91
+ raw = _header_get(headers, "content-type") or ""
92
+ return raw.split(";", 1)[0].strip().lower()
93
+
94
+
95
+ def _media_type_essence(value: str) -> Optional[str]:
96
+ essence = value.split(";", 1)[0].strip().lower()
97
+ if essence and re.match(r"^[!#$%&'*+.^_`|~0-9a-z-]+/[!#$%&'*+.^_`|~0-9a-z-]+$", essence):
98
+ return essence
99
+ return None
100
+
101
+
102
+ class BrowserRuntimeClient:
103
+ """Authenticated HTTP client for a Bobby Browser runtime (``bobby serve``).
104
+
105
+ Args:
106
+ base_url: Runtime origin. A trailing slash and a trailing ``/v1``
107
+ are stripped, so either ``http://127.0.0.1:7777`` or
108
+ ``http://127.0.0.1:7777/v1`` works.
109
+ bearer_token: Bearer credential for ``Authorization``. Never sent
110
+ anywhere but that header, never logged, never put in a URL.
111
+ timeout_ms: Default relative timeout for calls that do not pass
112
+ ``options`` (default 30_000).
113
+ opener: Override ``urllib.request`` opener (tests only).
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ base_url: str,
119
+ bearer_token: str,
120
+ *,
121
+ timeout_ms: int = _DEFAULT_TIMEOUT_MS,
122
+ opener: Optional[urllib.request.OpenerDirector] = None,
123
+ ) -> None:
124
+ if not base_url or not bearer_token:
125
+ raise ValueError("base_url and bearer_token are required")
126
+ stripped = base_url.rstrip("/")
127
+ if stripped.endswith("/v1"):
128
+ stripped = stripped[: -len("/v1")]
129
+ self._base_url = stripped
130
+ self._bearer_token = bearer_token
131
+ self._timeout_ms = timeout_ms
132
+ self._opener = opener or urllib.request.build_opener()
133
+
134
+ def __repr__(self) -> str: # never print the bearer token
135
+ return "BrowserRuntimeClient(bearer_token=[redacted])"
136
+
137
+ # ---- sessions ---------------------------------------------------
138
+
139
+ def runtime_info(self, options: Optional[RequestOptions] = None) -> Dict[str, Any]:
140
+ """``GET /v1/runtime`` -- version, capabilities, and load counters."""
141
+ return self._json("GET", "/v1/runtime", None, options, expected_status=200)
142
+
143
+ def create_session(
144
+ self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
145
+ ) -> Dict[str, Any]:
146
+ """``POST /v1/sessions`` -- create a browser session."""
147
+ return self._json("POST", "/v1/sessions", input, options, expected_status=200)
148
+
149
+ def read_session(
150
+ self, session_id: Optional[str] = None, options: Optional[RequestOptions] = None
151
+ ) -> Any:
152
+ """Read session state.
153
+
154
+ There is no ``GET /v1/sessions/{id}`` on the wire, only
155
+ ``GET /v1/sessions`` (full array, no pagination). With
156
+ ``session_id`` omitted this returns that full list; with it set,
157
+ this filters client-side and returns the one matching
158
+ :class:`SessionState`, raising :class:`RuntimeClientError` with
159
+ ``kind="protocol"`` if no session with that id is active.
160
+ """
161
+ sessions = self._json("GET", "/v1/sessions", None, options, expected_status=200)
162
+ if not isinstance(sessions, list):
163
+ raise self._protocol("sessions response has an unexpected shape")
164
+ if session_id is None:
165
+ return sessions
166
+ for session in sessions:
167
+ if isinstance(session, dict) and session.get("id") == session_id:
168
+ return session
169
+ raise self._protocol(f"no active session with id {session_id!r}", 200)
170
+
171
+ def delete_session(self, session_id: str, options: Optional[RequestOptions] = None) -> None:
172
+ """``DELETE /v1/sessions/{id}`` -- tear down a session (204 on success)."""
173
+ self._empty("DELETE", f"/v1/sessions/{urllib.parse.quote(session_id)}", options)
174
+
175
+ # ---- pages --------------------------------------------------------
176
+
177
+ def open_page(
178
+ self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
179
+ ) -> Dict[str, Any]:
180
+ """``POST /v1/pages`` -- open a page in a session."""
181
+ return self._json("POST", "/v1/pages", input, options, expected_status=200)
182
+
183
+ def read_page(
184
+ self,
185
+ session_id: str,
186
+ page_id: str,
187
+ *,
188
+ max_controls: Optional[int] = None,
189
+ options: Optional[RequestOptions] = None,
190
+ ) -> Dict[str, Any]:
191
+ """``GET /v1/sessions/{session}/pages/{page}/forms`` -- read-only
192
+ ``FormSnapshot`` (the PageRead HTTP surface; same contract as MCP
193
+ ``form_snapshot``). Page-derived controls carry ``pageDerived: true``.
194
+ ``max_controls`` is optional, 1 through 512.
195
+ """
196
+ if max_controls is not None and not (1 <= max_controls <= 512):
197
+ raise self._protocol("max_controls must be between 1 and 512")
198
+ query = f"?maxControls={max_controls}" if max_controls is not None else ""
199
+ path = (
200
+ f"/v1/sessions/{urllib.parse.quote(session_id)}"
201
+ f"/pages/{urllib.parse.quote(page_id)}/forms{query}"
202
+ )
203
+ return self._json("GET", path, None, options, expected_status=200)
204
+
205
+ # ---- commands -------------------------------------------------------
206
+
207
+ def submit_command(
208
+ self, envelope: Mapping[str, Any], options: Optional[RequestOptions] = None
209
+ ) -> Dict[str, Any]:
210
+ """``POST /v1/commands`` -- submit a raw ``CommandEnvelope``.
211
+
212
+ Returns the ``CommandOutcome`` body unmodified (its ``status``
213
+ discriminator field is preserved exactly as the server sent it --
214
+ ``completed``, ``retryableFailure``, ``needsReconciliation``,
215
+ ``policyDenied``, ``resourceExhausted``, ``restarted``, or
216
+ ``failed``) after checking the HTTP status matches the documented
217
+ mapping for that status.
218
+ Page-derived inspection, accessibility, form, and extraction evidence
219
+ retains its ``pageDerived: true`` marker in the returned dictionary.
220
+ """
221
+ status, payload = self._request("POST", "/v1/commands", envelope, options)
222
+ if not isinstance(payload, dict) or "status" not in payload:
223
+ raise self._response_error(status, payload)
224
+ outcome_status = payload["status"]
225
+ expected = _command_outcome_http_status(payload)
226
+ if expected is None:
227
+ raise self._protocol(f"unknown command outcome status: {outcome_status!r}", status)
228
+ if expected != status:
229
+ raise self._protocol(
230
+ "command outcome status does not match HTTP mapping", status
231
+ )
232
+ return payload
233
+
234
+ # ---- checkpoints / recovery ------------------------------------------
235
+
236
+ def create_checkpoint(
237
+ self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
238
+ ) -> Dict[str, Any]:
239
+ """``POST /v1/checkpoints`` -- persist a workflow checkpoint."""
240
+ return self._json("POST", "/v1/checkpoints", input, options, expected_status=200)
241
+
242
+ def recovery_status(
243
+ self, workflow_id: str, options: Optional[RequestOptions] = None
244
+ ) -> Dict[str, Any]:
245
+ """``GET /v1/recovery/{workflowId}`` -- current recovery status."""
246
+ path = f"/v1/recovery/{urllib.parse.quote(workflow_id)}"
247
+ return self._json("GET", path, None, options, expected_status=200)
248
+
249
+ def recover_workflow(
250
+ self, workflow_id: str, options: Optional[RequestOptions] = None
251
+ ) -> Dict[str, Any]:
252
+ """``POST /v1/recovery/{workflowId}`` -- resume, reconcile, or
253
+ restart a workflow. ``needsReconciliation`` maps to HTTP 409;
254
+ every other decision maps to 200.
255
+ """
256
+ path = f"/v1/recovery/{urllib.parse.quote(workflow_id)}"
257
+ status, payload = self._request("POST", path, None, options)
258
+ if not isinstance(payload, dict) or "status" not in payload:
259
+ raise self._response_error(status, payload)
260
+ expected = 409 if payload["status"] == _RECOVERY_NEEDS_RECONCILIATION_STATUS else 200
261
+ if expected != status:
262
+ raise self._protocol(
263
+ "recovery decision status does not match HTTP mapping", status
264
+ )
265
+ return payload
266
+
267
+ # ---- context --------------------------------------------------------
268
+
269
+ def context_ask(
270
+ self,
271
+ session_id: str,
272
+ page_id: str,
273
+ description: str,
274
+ options: Optional[RequestOptions] = None,
275
+ ) -> Dict[str, Any]:
276
+ """``GET /v1/context/ask`` -- remembered target for a description.
277
+
278
+ The result carries ``pageDerived: true`` on either a hit or miss.
279
+ """
280
+ encoded = len(description.encode("utf-8"))
281
+ if not (1 <= encoded <= 256):
282
+ raise self._protocol("description must contain between 1 and 256 bytes")
283
+ query = urllib.parse.urlencode(
284
+ {"sessionId": session_id, "pageId": page_id, "description": description}
285
+ )
286
+ return self._json("GET", f"/v1/context/ask?{query}", None, options, expected_status=200)
287
+
288
+ def context_site(
289
+ self, site_key: str, options: Optional[RequestOptions] = None
290
+ ) -> Dict[str, Any]:
291
+ """``GET /v1/context/site/{key}`` -- durable per-site context view."""
292
+ if not site_key:
293
+ raise self._protocol("site key must not be empty")
294
+ path = f"/v1/context/site/{urllib.parse.quote(site_key)}"
295
+ return self._json("GET", path, None, options, expected_status=200)
296
+
297
+ # ---- artifacts --------------------------------------------------------
298
+
299
+ def read_artifact(
300
+ self, reference: Mapping[str, Any], options: Optional[RequestOptions] = None
301
+ ) -> bytes:
302
+ """``GET /v1/artifacts/{artifactId}`` -- verified artifact bytes.
303
+
304
+ ``reference`` is an ``ArtifactReference``: ``artifactId``,
305
+ ``sha256``, ``bytes``, and ``mediaType``. Checks ``Content-Type``
306
+ and ``Content-Length`` against the reference, then verifies
307
+ SHA-256 before returning anything -- no bytes are handed back on a
308
+ mismatch.
309
+ """
310
+ artifact_id = reference.get("artifactId")
311
+ if not artifact_id or not isinstance(artifact_id, str):
312
+ raise self._protocol("artifact reference is missing artifactId")
313
+ path = f"/v1/artifacts/{urllib.parse.quote(artifact_id)}"
314
+ status, content_type, headers, raw = self._request_raw("GET", path, None, options)
315
+ if status != 200:
316
+ raise self._response_error(status, self._decode_json_or_none(raw, content_type))
317
+ expected_media_type = _media_type_essence(str(reference.get("mediaType", "")))
318
+ if expected_media_type is None or content_type != expected_media_type:
319
+ raise self._protocol("artifact media type does not match its reference", status)
320
+ expected_bytes = reference.get("bytes")
321
+ content_length = _header_get(headers, "content-length")
322
+ if (
323
+ content_length is None
324
+ or not content_length.isdigit()
325
+ or int(content_length) != expected_bytes
326
+ or len(raw) != expected_bytes
327
+ ):
328
+ raise self._protocol("artifact content length does not match its reference", status)
329
+ digest = hashlib.sha256(raw).hexdigest()
330
+ expected_sha256 = str(reference.get("sha256", "")).lower()
331
+ if digest != expected_sha256:
332
+ raise self._protocol("artifact digest does not match its reference", status)
333
+ return raw
334
+
335
+ # ---- jobs --------------------------------------------------------
336
+
337
+ def submit_job(
338
+ self, input: Mapping[str, Any], options: Optional[RequestOptions] = None
339
+ ) -> Dict[str, Any]:
340
+ """``POST /v1/jobs`` -- submit a bounded runtime job."""
341
+ return self._json("POST", "/v1/jobs", input, options, expected_status=201)
342
+
343
+ def job_status(self, job_id: str, options: Optional[RequestOptions] = None) -> Dict[str, Any]:
344
+ """``GET /v1/jobs/{jobId}`` -- read the authenticated principal's job."""
345
+ path = f"/v1/jobs/{urllib.parse.quote(job_id)}"
346
+ return self._json("GET", path, None, options, expected_status=200)
347
+
348
+ def cancel_job(self, job_id: str, options: Optional[RequestOptions] = None) -> None:
349
+ """``DELETE /v1/jobs/{jobId}`` -- cancel the authenticated principal's job."""
350
+ self._empty("DELETE", f"/v1/jobs/{urllib.parse.quote(job_id)}", options)
351
+
352
+ # ---- transport --------------------------------------------------------
353
+
354
+ def _headers(self, options: Optional[RequestOptions], has_body: bool) -> Dict[str, str]:
355
+ correlation_id = (options.correlation_id if options else None) or _uuid4()
356
+ headers = {
357
+ "Authorization": f"Bearer {self._bearer_token}",
358
+ "x-interface-version": INTERFACE_VERSION,
359
+ "x-correlation-id": correlation_id,
360
+ "x-deadline": _deadline_header(options, self._timeout_ms),
361
+ }
362
+ if options is not None and options.idempotency_key:
363
+ headers["idempotency-key"] = options.idempotency_key
364
+ if has_body:
365
+ headers["content-type"] = "application/json"
366
+ return headers
367
+
368
+ def _request_raw(
369
+ self,
370
+ method: str,
371
+ path: str,
372
+ body: Optional[Mapping[str, Any]],
373
+ options: Optional[RequestOptions],
374
+ ) -> tuple:
375
+ """Returns (status, content_type, headers, raw_bytes)."""
376
+ url = f"{self._base_url}{path}"
377
+ headers = self._headers(options, body is not None)
378
+ data = json.dumps(body).encode("utf-8") if body is not None else None
379
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
380
+ timeout_ms = self._timeout_ms
381
+ if options is not None and options.timeout_ms is not None:
382
+ timeout_ms = options.timeout_ms
383
+ try:
384
+ response = self._opener.open(request, timeout=timeout_ms / 1000)
385
+ try:
386
+ status = response.status
387
+ response_headers = dict(response.headers.items())
388
+ raw = response.read()
389
+ finally:
390
+ response.close()
391
+ except urllib.error.HTTPError as error:
392
+ status = error.code
393
+ response_headers = dict(error.headers.items()) if error.headers else {}
394
+ raw = error.read()
395
+ error.close()
396
+ except TimeoutError as error:
397
+ raise RuntimeClientError("deadline", message="Request deadline exceeded") from error
398
+ except urllib.error.URLError as error:
399
+ raise RuntimeClientError(
400
+ "transport", message=f"Runtime transport request failed: {error.reason}"
401
+ ) from error
402
+ content_type = _content_type(response_headers)
403
+ return status, content_type, response_headers, raw
404
+
405
+ def _decode_json_or_none(self, raw: bytes, content_type: str) -> Any:
406
+ if not _JSON_CONTENT_TYPE.match(content_type):
407
+ return None
408
+ try:
409
+ return json.loads(raw.decode("utf-8"))
410
+ except (UnicodeDecodeError, json.JSONDecodeError):
411
+ return None
412
+
413
+ def _request(
414
+ self,
415
+ method: str,
416
+ path: str,
417
+ body: Optional[Mapping[str, Any]],
418
+ options: Optional[RequestOptions],
419
+ ) -> tuple:
420
+ """Returns (status, parsed_json_payload)."""
421
+ status, content_type, _headers, raw = self._request_raw(method, path, body, options)
422
+ if not _JSON_CONTENT_TYPE.match(content_type):
423
+ raise self._protocol("response content type must be application/json", status)
424
+ try:
425
+ payload = json.loads(raw.decode("utf-8"))
426
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
427
+ raise self._protocol("response body is not valid JSON", status) from error
428
+ return status, payload
429
+
430
+ def _json(
431
+ self,
432
+ method: str,
433
+ path: str,
434
+ body: Optional[Mapping[str, Any]],
435
+ options: Optional[RequestOptions],
436
+ *,
437
+ expected_status: int,
438
+ ) -> Any:
439
+ status, payload = self._request(method, path, body, options)
440
+ if status != expected_status:
441
+ raise self._response_error(status, payload)
442
+ return payload
443
+
444
+ def _empty(self, method: str, path: str, options: Optional[RequestOptions]) -> None:
445
+ status, content_type, _headers, raw = self._request_raw(method, path, None, options)
446
+ if status == 204:
447
+ return
448
+ payload = self._decode_json_or_none(raw, content_type)
449
+ raise self._response_error(status, payload)
450
+
451
+ def _response_error(self, status: int, payload: Any) -> RuntimeClientError:
452
+ if isinstance(payload, dict) and set(payload.keys()) == {"error"} and isinstance(
453
+ payload["error"], dict
454
+ ):
455
+ return RuntimeClientError.from_interface_error("http", status, payload["error"])
456
+ return self._protocol("response has an unexpected status or shape", status)
457
+
458
+ def _protocol(self, message: str, status: Optional[int] = None) -> RuntimeClientError:
459
+ return RuntimeClientError("protocol", status=status, message=message)
460
+
461
+
462
+ def _command_outcome_http_status(outcome: Mapping[str, Any]) -> Optional[int]:
463
+ """Mirrors client.ts's commandStatus(): CommandOutcome.status -> HTTP status."""
464
+ status = outcome.get("status")
465
+ if status in _COMMAND_STATUS_HTTP:
466
+ return _COMMAND_STATUS_HTTP[status]
467
+ if status == "failed":
468
+ error = outcome.get("error") or {}
469
+ return 422 if error.get("code") == "invalidRequest" else 500
470
+ return None
@@ -0,0 +1,101 @@
1
+ """Client-side error type for the Bobby Browser Python SDK.
2
+
3
+ Mirrors ``RuntimeClientError`` from the TypeScript SDK
4
+ (``packages/typescript-sdk/src/errors.ts``): a single exception class
5
+ classified by ``kind``, carrying the fields a caller needs to decide whether
6
+ to retry, without ever retaining the bearer token.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Mapping, Optional
12
+
13
+ # Classification of RuntimeClientError, matching the TypeScript
14
+ # RuntimeClientErrorKind union exactly.
15
+ RUNTIME_CLIENT_ERROR_KINDS = ("transport", "protocol", "http", "aborted", "deadline")
16
+
17
+
18
+ class RuntimeClientError(Exception):
19
+ """Raised for every failure the client cannot recover from itself.
20
+
21
+ Attributes:
22
+ kind: One of ``"transport"``, ``"protocol"``, ``"http"``,
23
+ ``"aborted"``, ``"deadline"``.
24
+ status: HTTP status code, when the failure followed a response.
25
+ code: Wire ``InterfaceErrorCode``, when the server returned one.
26
+ correlation_id: The request's ``x-correlation-id``, when known.
27
+ command_id: The command id an interface error referenced, if any.
28
+ retryable: Whether the server marked the failure retryable.
29
+ retry_after_ms: Minimum backoff before retrying, when the server
30
+ supplied one (notably on HTTP 429).
31
+ reconciliation_required: Whether the caller must reconcile a
32
+ workflow's checkpoint before retrying.
33
+ required_capability: The capability the caller was missing, if any.
34
+ event_gap: The ``EventGap`` payload, for a 409 on ``events()``.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ kind: str,
40
+ *,
41
+ status: Optional[int] = None,
42
+ code: Optional[str] = None,
43
+ message: Optional[str] = None,
44
+ correlation_id: Optional[str] = None,
45
+ command_id: Optional[Any] = None,
46
+ retryable: Optional[bool] = None,
47
+ retry_after_ms: Optional[int] = None,
48
+ reconciliation_required: Optional[bool] = None,
49
+ required_capability: Optional[Any] = None,
50
+ event_gap: Optional[Mapping[str, Any]] = None,
51
+ ) -> None:
52
+ if kind not in RUNTIME_CLIENT_ERROR_KINDS:
53
+ raise ValueError(f"unknown RuntimeClientError kind: {kind!r}")
54
+ resolved_message = message or f"Runtime client {kind} failure"
55
+ super().__init__(resolved_message)
56
+ self.kind = kind
57
+ self.status = status
58
+ self.code = code
59
+ self.correlation_id = correlation_id
60
+ self.command_id = command_id
61
+ self.retryable = retryable
62
+ self.retry_after_ms = retry_after_ms
63
+ self.reconciliation_required = reconciliation_required
64
+ self.required_capability = required_capability
65
+ self.event_gap = dict(event_gap) if event_gap is not None else None
66
+
67
+ @classmethod
68
+ def from_interface_error(
69
+ cls, kind: str, status: int, error: Mapping[str, Any]
70
+ ) -> "RuntimeClientError":
71
+ """Build from a wire ``InterfaceError`` (``{"error": {...}}`` body)."""
72
+ return cls(
73
+ kind,
74
+ status=status,
75
+ code=error.get("code"),
76
+ message=f"Runtime request failed: {status} {error.get('code')}",
77
+ correlation_id=error.get("correlationId"),
78
+ command_id=error.get("commandId"),
79
+ retryable=error.get("retryable"),
80
+ retry_after_ms=error.get("retryAfterMs"),
81
+ reconciliation_required=error.get("reconciliationRequired"),
82
+ required_capability=error.get("requiredCapability"),
83
+ )
84
+
85
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
86
+ return f"RuntimeClientError(kind={self.kind!r}, status={self.status!r}, code={self.code!r})"
87
+
88
+ def to_dict(self) -> dict:
89
+ """JSON-safe projection for logging."""
90
+ return {
91
+ "kind": self.kind,
92
+ "status": self.status,
93
+ "code": self.code,
94
+ "correlationId": self.correlation_id,
95
+ "commandId": self.command_id,
96
+ "retryable": self.retryable,
97
+ "retryAfterMs": self.retry_after_ms,
98
+ "reconciliationRequired": self.reconciliation_required,
99
+ "requiredCapability": self.required_capability,
100
+ "eventGap": self.event_gap,
101
+ }
File without changes
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: bobby-browser
3
+ Version: 0.17.0
4
+ Summary: Typed client for the authenticated Bobby Browser runtime interface
5
+ Author: Sasan Sotoodehfar
6
+ License: MIT
7
+ Project-URL: Homepage, https://cavi-ai.xyz/docs/bobby-browser
8
+ Project-URL: Repository, https://github.com/cavi-ai/bobby-browser
9
+ Project-URL: Issues, https://github.com/cavi-ai/bobby-browser/issues
10
+ Keywords: browser-automation,cdp,mcp,playwright,puppeteer,bobby-browser
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+
14
+ # bobby-browser (Python SDK)
15
+
16
+ Typed HTTP client for the authenticated Bobby Browser `/v1` runtime
17
+ interface. Stdlib only (`urllib`, `json`, `dataclasses`, `typing`) -- no
18
+ third-party HTTP client dependency. Python >= 3.10.
19
+
20
+ Mirrors `@cavi-ai/bobby-browser` (TypeScript,
21
+ `packages/typescript-sdk`) and `bobby-browser-client` (Rust,
22
+ `crates/bobby-browser-client`): same auth header contract, idempotency-key
23
+ passthrough, and `CommandOutcome` status discriminator.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install bobby-browser
29
+ ```
30
+
31
+ From a bobby-browser checkout: `pip install -e packages/python-sdk`.
32
+
33
+ `bobby install --skill-hermes` copies `skill/hermes/SKILL.md` into
34
+ `$HERMES_HOME/skills/bobby-browser/` (else `~/.hermes/skills/`).
35
+
36
+ ## Use
37
+
38
+ ```python
39
+ import os
40
+ from bobby_browser import BrowserRuntimeClient
41
+
42
+ client = BrowserRuntimeClient("http://127.0.0.1:7777", os.environ["AUTOMATION_RUNTIME_TOKEN"])
43
+ ```
44
+
45
+ Full method catalog, headers, and error shape:
46
+ [docs/bobby-browser/source/pages/surfaces/python-sdk.md](../../docs/bobby-browser/source/pages/surfaces/python-sdk.md).
47
+
48
+ ## Test
49
+
50
+ ```bash
51
+ python3 -m unittest discover -s tests
52
+ ```
53
+
54
+ `tests/test_live_runtime.py` additionally starts the real `bobby` runtime
55
+ from this worktree's release build and skips cleanly when
56
+ `BOBBY_CHROME_EXECUTABLE` is unset.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ bobby_browser/__init__.py
4
+ bobby_browser/client.py
5
+ bobby_browser/errors.py
6
+ bobby_browser/py.typed
7
+ bobby_browser.egg-info/PKG-INFO
8
+ bobby_browser.egg-info/SOURCES.txt
9
+ bobby_browser.egg-info/dependency_links.txt
10
+ bobby_browser.egg-info/top_level.txt
11
+ tests/test_client.py
12
+ tests/test_live_runtime.py
@@ -0,0 +1 @@
1
+ bobby_browser
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bobby-browser"
7
+ version = "0.17.0"
8
+ description = "Typed client for the authenticated Bobby Browser runtime interface"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Sasan Sotoodehfar" }]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ keywords = ["browser-automation", "cdp", "mcp", "playwright", "puppeteer", "bobby-browser"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://cavi-ai.xyz/docs/bobby-browser"
18
+ Repository = "https://github.com/cavi-ai/bobby-browser"
19
+ Issues = "https://github.com/cavi-ai/bobby-browser/issues"
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["bobby_browser*"]
23
+
24
+ [tool.setuptools.package-data]
25
+ bobby_browser = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,342 @@
1
+ """Unit tests for bobby_browser.client against a local fake HTTP server.
2
+
3
+ No real bobby-browser runtime involved -- see test_live_runtime.py for the
4
+ one test that starts the real thing. These cover: the auth header contract,
5
+ idempotency-key passthrough, the 429 retryAfterMs surface, the 409
6
+ needsReconciliation surface on submit_command, and that the CommandOutcome
7
+ status discriminator survives the round trip unmodified.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import sys
15
+ import threading
16
+ import unittest
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+ from pathlib import Path
19
+ from typing import Callable
20
+
21
+ # `python3 -m unittest discover -s packages/python-sdk/tests` (the CI /
22
+ # check-version-agreement.py-adjacent gate) runs straight from a checkout
23
+ # with no `pip install` step first. `packages/python-sdk` (this file's
24
+ # grandparent) is the source package root; put it on sys.path so
25
+ # `bobby_browser` resolves to the checkout's own source.
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
27
+
28
+ from bobby_browser import BrowserRuntimeClient, RequestOptions, RuntimeClientError # noqa: E402
29
+
30
+
31
+ class _Handler(BaseHTTPRequestHandler):
32
+ def log_message(self, format, *args): # noqa: A002 - stdlib signature
33
+ pass # keep test output quiet
34
+
35
+ def _dispatch(self) -> None:
36
+ self.server.route_handler(self) # type: ignore[attr-defined]
37
+
38
+ def do_GET(self) -> None: # noqa: N802 - stdlib naming
39
+ self._dispatch()
40
+
41
+ def do_POST(self) -> None: # noqa: N802
42
+ self._dispatch()
43
+
44
+ def do_DELETE(self) -> None: # noqa: N802
45
+ self._dispatch()
46
+
47
+ def read_body(self) -> bytes:
48
+ length = int(self.headers.get("Content-Length", "0") or "0")
49
+ return self.rfile.read(length) if length else b""
50
+
51
+ def reply_json(self, status: int, payload) -> None:
52
+ body = json.dumps(payload).encode("utf-8")
53
+ self.send_response(status)
54
+ self.send_header("Content-Type", "application/json")
55
+ self.send_header("Content-Length", str(len(body)))
56
+ self.end_headers()
57
+ self.wfile.write(body)
58
+
59
+
60
+ class _FakeServer:
61
+ def __init__(self, handler: Callable[[_Handler], None]) -> None:
62
+ self.httpd = HTTPServer(("127.0.0.1", 0), _Handler)
63
+ self.httpd.route_handler = handler # type: ignore[attr-defined]
64
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
65
+ self.thread.start()
66
+
67
+ @property
68
+ def base_url(self) -> str:
69
+ return f"http://127.0.0.1:{self.httpd.server_port}"
70
+
71
+ def close(self) -> None:
72
+ self.httpd.shutdown()
73
+ self.httpd.server_close()
74
+ self.thread.join(timeout=5)
75
+
76
+
77
+ class ClientUnitTests(unittest.TestCase):
78
+ def _serve(self, handler: Callable[[_Handler], None]) -> _FakeServer:
79
+ server = _FakeServer(handler)
80
+ self.addCleanup(server.close)
81
+ return server
82
+
83
+ # ---- auth header contract ------------------------------------------
84
+
85
+ def test_every_request_carries_the_documented_headers(self) -> None:
86
+ seen = {}
87
+
88
+ def handler(req: _Handler) -> None:
89
+ req.read_body()
90
+ seen["authorization"] = req.headers.get("Authorization")
91
+ seen["interface_version"] = req.headers.get("x-interface-version")
92
+ seen["correlation_id"] = req.headers.get("x-correlation-id")
93
+ seen["deadline"] = req.headers.get("x-deadline")
94
+ req.reply_json(200, {"version": "1.2.3", "capabilities": [], "active_sessions": 0, "queued_jobs": 0, "uptime_ms": 1})
95
+
96
+ server = self._serve(handler)
97
+ client = BrowserRuntimeClient(server.base_url, "top-secret-bearer")
98
+ info = client.runtime_info()
99
+
100
+ self.assertEqual(info["version"], "1.2.3")
101
+ self.assertEqual(seen["authorization"], "Bearer top-secret-bearer")
102
+ self.assertEqual(seen["interface_version"], "2026-08-19")
103
+ self.assertTrue(seen["correlation_id"])
104
+ self.assertTrue(seen["deadline"] and seen["deadline"].endswith("Z"))
105
+ # Never a credential in the URL.
106
+ self.assertNotIn("top-secret-bearer", server.base_url)
107
+
108
+ def test_bearer_token_never_appears_in_repr(self) -> None:
109
+ client = BrowserRuntimeClient("http://127.0.0.1:1", "super-secret")
110
+ self.assertNotIn("super-secret", repr(client))
111
+
112
+ # ---- idempotency-key passthrough -------------------------------------
113
+
114
+ def test_idempotency_key_passthrough_on_mutating_post(self) -> None:
115
+ seen = {}
116
+
117
+ def handler(req: _Handler) -> None:
118
+ body = json.loads(req.read_body() or b"{}")
119
+ seen["idempotency_key"] = req.headers.get("idempotency-key")
120
+ seen["profile"] = body.get("profile")
121
+ req.reply_json(
122
+ 200,
123
+ {
124
+ "id": "session-1",
125
+ "profile": body.get("profile"),
126
+ "proxy": None,
127
+ "page_ids": [],
128
+ "created_at": "2026-09-23T00:00:00.000Z",
129
+ "last_used_at": "2026-09-23T00:00:00.000Z",
130
+ "execution_policy": {
131
+ "javascriptEvaluation": False,
132
+ "visionAssist": False,
133
+ "fingerprint": False,
134
+ "humanize": False,
135
+ },
136
+ },
137
+ )
138
+
139
+ server = self._serve(handler)
140
+ client = BrowserRuntimeClient(server.base_url, "token")
141
+ session = client.create_session(
142
+ {"profile": "default", "proxy": None},
143
+ RequestOptions(idempotency_key="create-session-1"),
144
+ )
145
+
146
+ self.assertEqual(session["id"], "session-1")
147
+ self.assertEqual(seen["idempotency_key"], "create-session-1")
148
+ self.assertEqual(seen["profile"], "default")
149
+
150
+ def test_idempotency_key_absent_when_not_supplied(self) -> None:
151
+ seen = {}
152
+
153
+ def handler(req: _Handler) -> None:
154
+ req.read_body()
155
+ seen["idempotency_key"] = req.headers.get("idempotency-key")
156
+ req.send_response(204)
157
+ req.end_headers()
158
+
159
+ server = self._serve(handler)
160
+ client = BrowserRuntimeClient(server.base_url, "token")
161
+ client.delete_session("11111111-1111-4111-8111-111111111111")
162
+
163
+ self.assertIsNone(seen["idempotency_key"])
164
+
165
+ # ---- 429 retryAfterMs ------------------------------------------------
166
+
167
+ def test_429_surfaces_retry_after_ms_from_interface_error(self) -> None:
168
+ def handler(req: _Handler) -> None:
169
+ req.read_body()
170
+ req.reply_json(
171
+ 429,
172
+ {
173
+ "error": {
174
+ "code": "resourceExhausted",
175
+ "layer": "interface",
176
+ "message": "too many requests",
177
+ "correlationId": "corr-1",
178
+ "commandId": None,
179
+ "retryable": True,
180
+ "retryAfterMs": 1500,
181
+ "reconciliationRequired": False,
182
+ "requiredCapability": None,
183
+ }
184
+ },
185
+ )
186
+
187
+ server = self._serve(handler)
188
+ client = BrowserRuntimeClient(server.base_url, "token")
189
+
190
+ with self.assertRaises(RuntimeClientError) as caught:
191
+ client.runtime_info()
192
+
193
+ error = caught.exception
194
+ self.assertEqual(error.kind, "http")
195
+ self.assertEqual(error.status, 429)
196
+ self.assertEqual(error.code, "resourceExhausted")
197
+ self.assertEqual(error.retry_after_ms, 1500)
198
+ self.assertTrue(error.retryable)
199
+
200
+ # ---- 409 needsReconciliation on submit_command ------------------------
201
+
202
+ def test_submit_command_409_needs_reconciliation_is_returned_not_raised(self) -> None:
203
+ def handler(req: _Handler) -> None:
204
+ req.read_body()
205
+ req.reply_json(
206
+ 409,
207
+ {
208
+ "status": "needsReconciliation",
209
+ "commandId": "cmd-1",
210
+ "error": {
211
+ "code": "verificationFailed",
212
+ "message": "page state diverged",
213
+ "layer": "page",
214
+ "retryable": False,
215
+ },
216
+ "evidence": [],
217
+ },
218
+ )
219
+
220
+ server = self._serve(handler)
221
+ client = BrowserRuntimeClient(server.base_url, "token")
222
+ outcome = client.submit_command({"schemaVersion": 1, "commandId": "cmd-1"})
223
+
224
+ self.assertEqual(outcome["status"], "needsReconciliation")
225
+ self.assertEqual(outcome["commandId"], "cmd-1")
226
+
227
+ def test_submit_command_mismatched_http_mapping_raises_protocol_error(self) -> None:
228
+ def handler(req: _Handler) -> None:
229
+ req.read_body()
230
+ # needsReconciliation must map to 409; sending 200 is a protocol violation.
231
+ req.reply_json(200, {"status": "needsReconciliation", "commandId": "cmd-2", "error": {}, "evidence": []})
232
+
233
+ server = self._serve(handler)
234
+ client = BrowserRuntimeClient(server.base_url, "token")
235
+
236
+ with self.assertRaises(RuntimeClientError) as caught:
237
+ client.submit_command({"schemaVersion": 1, "commandId": "cmd-2"})
238
+
239
+ self.assertEqual(caught.exception.kind, "protocol")
240
+
241
+ # ---- status discriminator preserved -----------------------------------
242
+
243
+ def test_submit_command_status_discriminator_preserved(self) -> None:
244
+ for status, http_status, extra in (
245
+ ("completed", 200, {"evidence": []}),
246
+ ("policyDenied", 403, {"error": {"code": "policyDenied", "message": "x", "layer": "interface", "retryable": False}}),
247
+ (
248
+ "resourceExhausted",
249
+ 429,
250
+ {
251
+ "error": {"code": "resourceExhausted", "message": "x", "layer": "interface", "retryable": True},
252
+ "retryAfterMs": 250,
253
+ },
254
+ ),
255
+ ):
256
+ with self.subTest(status=status):
257
+ payload = {"status": status, "commandId": "cmd-x", **extra}
258
+
259
+ def handler(req: _Handler, payload=payload, http_status=http_status) -> None:
260
+ req.read_body()
261
+ req.reply_json(http_status, payload)
262
+
263
+ server = self._serve(handler)
264
+ client = BrowserRuntimeClient(server.base_url, "token")
265
+ outcome = client.submit_command({"schemaVersion": 1, "commandId": "cmd-x"})
266
+ self.assertEqual(outcome["status"], status)
267
+
268
+ # ---- read_session (no single-id GET on the wire) -----------------------
269
+
270
+ def test_read_session_filters_the_list_endpoint_client_side(self) -> None:
271
+ def handler(req: _Handler) -> None:
272
+ req.read_body()
273
+ req.reply_json(
274
+ 200,
275
+ [
276
+ {"id": "s1", "profile": "default"},
277
+ {"id": "s2", "profile": "default"},
278
+ ],
279
+ )
280
+
281
+ server = self._serve(handler)
282
+ client = BrowserRuntimeClient(server.base_url, "token")
283
+
284
+ self.assertEqual(client.read_session("s2")["id"], "s2")
285
+ self.assertEqual(len(client.read_session()), 2)
286
+ with self.assertRaises(RuntimeClientError):
287
+ client.read_session("does-not-exist")
288
+
289
+ # ---- artifact verification ---------------------------------------------
290
+
291
+ def test_read_artifact_verifies_sha256_before_returning_bytes(self) -> None:
292
+ data = b"artifact-bytes-for-testing"
293
+ digest = hashlib.sha256(data).hexdigest()
294
+
295
+ def handler(req: _Handler) -> None:
296
+ req.read_body()
297
+ req.send_response(200)
298
+ req.send_header("Content-Type", "application/octet-stream")
299
+ req.send_header("Content-Length", str(len(data)))
300
+ req.end_headers()
301
+ req.wfile.write(data)
302
+
303
+ server = self._serve(handler)
304
+ client = BrowserRuntimeClient(server.base_url, "token")
305
+ reference = {
306
+ "referenceId": "ref-1",
307
+ "artifactId": "artifact-1",
308
+ "sha256": digest,
309
+ "bytes": len(data),
310
+ "mediaType": "application/octet-stream",
311
+ }
312
+
313
+ self.assertEqual(client.read_artifact(reference), data)
314
+
315
+ def test_read_artifact_rejects_a_digest_mismatch(self) -> None:
316
+ data = b"tampered-or-wrong-bytes"
317
+
318
+ def handler(req: _Handler) -> None:
319
+ req.read_body()
320
+ req.send_response(200)
321
+ req.send_header("Content-Type", "application/octet-stream")
322
+ req.send_header("Content-Length", str(len(data)))
323
+ req.end_headers()
324
+ req.wfile.write(data)
325
+
326
+ server = self._serve(handler)
327
+ client = BrowserRuntimeClient(server.base_url, "token")
328
+ reference = {
329
+ "referenceId": "ref-2",
330
+ "artifactId": "artifact-2",
331
+ "sha256": "0" * 64,
332
+ "bytes": len(data),
333
+ "mediaType": "application/octet-stream",
334
+ }
335
+
336
+ with self.assertRaises(RuntimeClientError) as caught:
337
+ client.read_artifact(reference)
338
+ self.assertEqual(caught.exception.kind, "protocol")
339
+
340
+
341
+ if __name__ == "__main__":
342
+ unittest.main()
@@ -0,0 +1,135 @@
1
+ """One live test: start the real ``bobby`` runtime from this worktree's
2
+ release build, mint a bootstrap credential with ``bobby init``, and drive
3
+ ``runtime_info`` / ``create_session`` through the Python SDK over real HTTP.
4
+
5
+ Skipped cleanly (not failed) when ``BOBBY_CHROME_EXECUTABLE`` is unset, same
6
+ convention the Rust workspace tests use (crates/worker-pool/tests/chromium_worker.rs).
7
+ Never prints the bootstrap bearer: it is read from ``bobby init``'s stdout
8
+ and handed straight to the client.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ import os
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ import unittest
22
+ import urllib.error
23
+ import urllib.request
24
+ from pathlib import Path
25
+
26
+ # See test_client.py: makes `bobby_browser` importable for
27
+ # `python3 -m unittest discover -s packages/python-sdk/tests` run straight
28
+ # from a checkout, with no `pip install` step first.
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
+
31
+ from bobby_browser import BrowserRuntimeClient # noqa: E402
32
+
33
+ REPO_ROOT = Path(__file__).resolve().parents[3]
34
+ BOBBY_BIN = REPO_ROOT / "target" / "release" / "bobby"
35
+
36
+ _SKIP_REASON = "BOBBY_CHROME_EXECUTABLE is unset; skipping the live runtime test"
37
+
38
+
39
+ def _free_tcp_port() -> int:
40
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
41
+ probe.bind(("127.0.0.1", 0))
42
+ return probe.getsockname()[1]
43
+
44
+
45
+ def _wait_for_healthz(base_url: str, deadline: float) -> None:
46
+ last_error: Exception | None = None
47
+ while time.monotonic() < deadline:
48
+ try:
49
+ with urllib.request.urlopen(f"{base_url}/healthz", timeout=2) as response:
50
+ if response.status == 200:
51
+ return
52
+ except (urllib.error.URLError, OSError) as error:
53
+ last_error = error
54
+ time.sleep(0.25)
55
+ raise AssertionError(f"bobby serve never became healthy: {last_error}")
56
+
57
+
58
+ @unittest.skipUnless(os.environ.get("BOBBY_CHROME_EXECUTABLE"), _SKIP_REASON)
59
+ class LiveRuntimeTests(unittest.TestCase):
60
+ def test_runtime_info_and_create_session_over_real_http(self) -> None:
61
+ if not BOBBY_BIN.is_file():
62
+ self.skipTest(f"{BOBBY_BIN} not built; run: cargo build -p bobby-browser --release")
63
+
64
+ with tempfile.TemporaryDirectory(prefix="bobby-python-sdk-live-") as tmp:
65
+ tmp_path = Path(tmp)
66
+ # `bobby serve` does not auto-create every storage/browser
67
+ # directory (deploy/docker/entrypoint.sh pre-creates them for
68
+ # the same reason) -- the defaults are relative to the process
69
+ # cwd, which we pin to tmp_path below.
70
+ for relative in (
71
+ "data/profiles",
72
+ "data/uploads",
73
+ "data/downloads",
74
+ "data/artifacts",
75
+ "data/storage/checkpoints",
76
+ ):
77
+ (tmp_path / relative).mkdir(parents=True, exist_ok=True)
78
+
79
+ bootstrap_path = tmp_path / "bootstrap.env"
80
+ init = subprocess.run(
81
+ [str(BOBBY_BIN), "init", "--path", str(bootstrap_path), "--force"],
82
+ cwd=tmp_path,
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=30,
86
+ )
87
+ self.assertEqual(init.returncode, 0, msg="bobby init failed (see stderr on the CI runner)")
88
+ bearer = init.stdout.strip()
89
+ self.assertTrue(bearer, "bobby init printed no bearer on stdout")
90
+
91
+ port = _free_tcp_port()
92
+ config_path = tmp_path / "config.toml"
93
+ config_path.write_text(
94
+ f'[server]\nhost = "127.0.0.1"\nport = {port}\n', encoding="utf-8"
95
+ )
96
+
97
+ env = dict(os.environ)
98
+ env.setdefault("AUTOMATION_RUNTIME_BROWSER_SELECTION", json.dumps({"preference": {"mode": "managedChromium"}}))
99
+
100
+ serve_stdout = open(tmp_path / "serve.stdout.log", "wb")
101
+ serve_stderr = open(tmp_path / "serve.stderr.log", "wb")
102
+ process = subprocess.Popen(
103
+ [str(BOBBY_BIN), "serve", "--config", str(config_path), "--bootstrap-env", str(bootstrap_path)],
104
+ cwd=tmp_path,
105
+ env=env,
106
+ stdout=serve_stdout,
107
+ stderr=serve_stderr,
108
+ )
109
+ try:
110
+ base_url = f"http://127.0.0.1:{port}"
111
+ _wait_for_healthz(base_url, deadline=time.monotonic() + 30)
112
+
113
+ client = BrowserRuntimeClient(base_url, bearer)
114
+ info = client.runtime_info()
115
+ self.assertIn("version", info)
116
+ self.assertIsInstance(info["capabilities"], list)
117
+
118
+ session = client.create_session({"profile": "default", "proxy": None})
119
+ self.assertTrue(session.get("id"))
120
+ self.assertEqual(session.get("profile"), "default")
121
+ finally:
122
+ process.terminate()
123
+ try:
124
+ process.wait(timeout=10)
125
+ except subprocess.TimeoutExpired:
126
+ process.kill()
127
+ process.wait(timeout=10)
128
+ with contextlib.suppress(Exception):
129
+ serve_stdout.close()
130
+ with contextlib.suppress(Exception):
131
+ serve_stderr.close()
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()