hyperbrowser 1.6.0__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/PKG-INFO +44 -1
  2. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/README.md +43 -0
  3. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/sandbox.py +2 -0
  4. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_processes.py +126 -14
  5. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_transport.py +55 -10
  6. hyperbrowser-1.7.0/hyperbrowser/client/managers/sandboxes/process_output.py +88 -0
  7. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sandboxes/shared.py +2 -0
  8. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/sandbox.py +2 -0
  9. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_processes.py +140 -14
  10. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_transport.py +74 -11
  11. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/sandbox.py +2 -0
  12. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/pyproject.toml +1 -1
  13. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/LICENSE +0 -0
  14. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/__init__.py +0 -0
  15. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/_request.py +0 -0
  16. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/async_client.py +0 -0
  17. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/base.py +0 -0
  18. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/__init__.py +0 -0
  19. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/browser_use.py +0 -0
  20. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/claude_computer_use.py +0 -0
  21. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/cua.py +0 -0
  22. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/gemini_computer_use.py +0 -0
  23. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/grok_computer_use.py +0 -0
  24. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/hyper_agent.py +0 -0
  25. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/jev_computer_use.py +0 -0
  26. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/agents/meta_computer_use.py +0 -0
  27. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/computer_action.py +0 -0
  28. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/crawl.py +0 -0
  29. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/extension.py +0 -0
  30. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/extract.py +0 -0
  31. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/profile.py +0 -0
  32. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/sandboxes/__init__.py +0 -0
  33. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_files.py +0 -0
  34. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/sandboxes/sandbox_terminal.py +0 -0
  35. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/scrape.py +0 -0
  36. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/session.py +0 -0
  37. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/team.py +0 -0
  38. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/volume.py +0 -0
  39. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/web/__init__.py +0 -0
  40. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/web/batch_fetch.py +0 -0
  41. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/async_manager/web/crawl.py +0 -0
  42. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sandboxes/__init__.py +0 -0
  43. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sandboxes/dockerfile_analysis.py +0 -0
  44. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sandboxes/dockerignore.py +0 -0
  45. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sandboxes/image_build.py +0 -0
  46. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/__init__.py +0 -0
  47. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/browser_use.py +0 -0
  48. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/claude_computer_use.py +0 -0
  49. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/cua.py +0 -0
  50. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/gemini_computer_use.py +0 -0
  51. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/grok_computer_use.py +0 -0
  52. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/hyper_agent.py +0 -0
  53. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/jev_computer_use.py +0 -0
  54. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/agents/meta_computer_use.py +0 -0
  55. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/computer_action.py +0 -0
  56. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/crawl.py +0 -0
  57. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/extension.py +0 -0
  58. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/extract.py +0 -0
  59. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/profile.py +0 -0
  60. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/sandboxes/__init__.py +0 -0
  61. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +0 -0
  62. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_terminal.py +0 -0
  63. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/scrape.py +0 -0
  64. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/session.py +0 -0
  65. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/team.py +0 -0
  66. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/volume.py +0 -0
  67. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/web/__init__.py +0 -0
  68. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/web/batch_fetch.py +0 -0
  69. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/managers/sync_manager/web/crawl.py +0 -0
  70. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/client/sync.py +0 -0
  71. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/config.py +0 -0
  72. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/exceptions.py +0 -0
  73. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/__init__.py +0 -0
  74. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/_parsers.py +0 -0
  75. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/browser_use.py +0 -0
  76. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/claude_computer_use.py +0 -0
  77. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/cua.py +0 -0
  78. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/gemini_computer_use.py +0 -0
  79. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/grok_computer_use.py +0 -0
  80. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/hyper_agent.py +0 -0
  81. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/jev_computer_use.py +0 -0
  82. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/agents/meta_computer_use.py +0 -0
  83. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/computer_action.py +0 -0
  84. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/consts.py +0 -0
  85. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/crawl.py +0 -0
  86. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/extension.py +0 -0
  87. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/extract.py +0 -0
  88. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/profile.py +0 -0
  89. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/scrape.py +0 -0
  90. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/session.py +0 -0
  91. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/team.py +0 -0
  92. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/volume.py +0 -0
  93. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/web/batch_fetch.py +0 -0
  94. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/web/branding.py +0 -0
  95. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/web/common.py +0 -0
  96. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/web/crawl.py +0 -0
  97. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/web/fetch.py +0 -0
  98. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/models/web/search.py +0 -0
  99. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/py.typed +0 -0
  100. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/sandbox_common.py +0 -0
  101. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/tools/__init__.py +0 -0
  102. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/tools/anthropic.py +0 -0
  103. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/tools/openai.py +0 -0
  104. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/tools/schema.py +0 -0
  105. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/transport/async_transport.py +0 -0
  106. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/transport/base.py +0 -0
  107. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/transport/sync.py +0 -0
  108. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/__init__.py +0 -0
  109. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/_json.py +0 -0
  110. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/agents.py +0 -0
  111. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/computer_action.py +0 -0
  112. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/crawl.py +0 -0
  113. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/extension.py +0 -0
  114. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/extract.py +0 -0
  115. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/profile.py +0 -0
  116. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/sandbox.py +0 -0
  117. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/scrape.py +0 -0
  118. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/session.py +0 -0
  119. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/volume.py +0 -0
  120. {hyperbrowser-1.6.0 → hyperbrowser-1.7.0}/hyperbrowser/types/web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperbrowser
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: Python SDK for hyperbrowser
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -323,6 +323,49 @@ sandbox.files.write(
323
323
  )
324
324
  ```
325
325
 
326
+ ### Run commands and collect output
327
+
328
+ `sandbox.exec()` and `sandbox.processes.start()` stream command output from the
329
+ receiver as soon as execution starts. The SDK collects stdout and stderr in
330
+ memory, so a completed result is not limited to the receiver's replay buffer.
331
+ This requires a receiver supporting streaming `POST /sandbox/processes`; roll
332
+ out the receiver before upgrading the SDK.
333
+
334
+ ```python
335
+ result = sandbox.exec("make test", max_output_bytes=128 * 1024 * 1024)
336
+ print(result.stdout, result.stderr, result.exit_code)
337
+
338
+ process = sandbox.processes.start("make test")
339
+ try:
340
+ for event in process.stream():
341
+ if event.type == "stdout":
342
+ print(event.data, end="")
343
+ result = process.wait()
344
+ finally:
345
+ process.disconnect()
346
+ ```
347
+
348
+ The combined output limit defaults to 64 MiB per command and can be adjusted
349
+ with `max_output_bytes`. Exceeding it raises `output_limit_exceeded`. A broken
350
+ stream, missing output, or receiver truncation raises `incomplete_output`.
351
+ These errors include the process ID and do not automatically rerun the command.
352
+
353
+ Process streams use a separate 60-second read-idle timeout once response headers
354
+ arrive. Output and the receiver's 15-second heartbeats reset this timeout, so quiet
355
+ commands can run longer than the client's ordinary HTTP timeout. That ordinary
356
+ timeout still applies to connection setup and waiting for response headers.
357
+
358
+ `start()` returns after the process starts and collects in the background.
359
+ `wait(timeout_sec=...)` limits the local wait; collection continues after a wait
360
+ timeout. The timeout passed to `start()` or `exec()` limits command execution.
361
+ A local wait timeout raises `TimeoutError` (`asyncio.TimeoutError` in the async API).
362
+ `disconnect()` stops collection and leaves the command running. Use `kill()` to
363
+ stop it. Reattaching with `get()` can retrieve only retained receiver output;
364
+ `wait()` raises if that output has been truncated.
365
+
366
+ The async API has the same behavior: await `exec()`, `start()`, `wait()`, and
367
+ `disconnect()`, and use `async for` with `stream()`.
368
+
326
369
  ### Resume terminal output after reconnect
327
370
 
328
371
  ```python
@@ -295,6 +295,49 @@ sandbox.files.write(
295
295
  )
296
296
  ```
297
297
 
298
+ ### Run commands and collect output
299
+
300
+ `sandbox.exec()` and `sandbox.processes.start()` stream command output from the
301
+ receiver as soon as execution starts. The SDK collects stdout and stderr in
302
+ memory, so a completed result is not limited to the receiver's replay buffer.
303
+ This requires a receiver supporting streaming `POST /sandbox/processes`; roll
304
+ out the receiver before upgrading the SDK.
305
+
306
+ ```python
307
+ result = sandbox.exec("make test", max_output_bytes=128 * 1024 * 1024)
308
+ print(result.stdout, result.stderr, result.exit_code)
309
+
310
+ process = sandbox.processes.start("make test")
311
+ try:
312
+ for event in process.stream():
313
+ if event.type == "stdout":
314
+ print(event.data, end="")
315
+ result = process.wait()
316
+ finally:
317
+ process.disconnect()
318
+ ```
319
+
320
+ The combined output limit defaults to 64 MiB per command and can be adjusted
321
+ with `max_output_bytes`. Exceeding it raises `output_limit_exceeded`. A broken
322
+ stream, missing output, or receiver truncation raises `incomplete_output`.
323
+ These errors include the process ID and do not automatically rerun the command.
324
+
325
+ Process streams use a separate 60-second read-idle timeout once response headers
326
+ arrive. Output and the receiver's 15-second heartbeats reset this timeout, so quiet
327
+ commands can run longer than the client's ordinary HTTP timeout. That ordinary
328
+ timeout still applies to connection setup and waiting for response headers.
329
+
330
+ `start()` returns after the process starts and collects in the background.
331
+ `wait(timeout_sec=...)` limits the local wait; collection continues after a wait
332
+ timeout. The timeout passed to `start()` or `exec()` limits command execution.
333
+ A local wait timeout raises `TimeoutError` (`asyncio.TimeoutError` in the async API).
334
+ `disconnect()` stops collection and leaves the command running. Use `kill()` to
335
+ stop it. Reattaching with `get()` can retrieve only retained receiver output;
336
+ `wait()` raises if that output has been truncated.
337
+
338
+ The async API has the same behavior: await `exec()`, `start()`, `wait()`, and
339
+ `disconnect()`, and use `async for` with `stream()`.
340
+
298
341
  ### Resume terminal output after reconnect
299
342
 
300
343
  ```python
@@ -307,6 +307,7 @@ class SandboxHandle:
307
307
  timeout_ms: Optional[int] = None,
308
308
  timeout_sec: Optional[int] = None,
309
309
  run_as: Optional[str] = None,
310
+ max_output_bytes: int = 64 * 1024 * 1024,
310
311
  ):
311
312
  return await self.processes.exec(
312
313
  input,
@@ -315,6 +316,7 @@ class SandboxHandle:
315
316
  timeout_ms=timeout_ms,
316
317
  timeout_sec=timeout_sec,
317
318
  run_as=run_as,
319
+ max_output_bytes=max_output_bytes,
318
320
  )
319
321
 
320
322
  async def get_process(self, process_id: str) -> SandboxProcessHandle:
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import base64
2
3
  from typing import AsyncIterator, Dict, Optional, Union
3
4
 
@@ -16,6 +17,11 @@ from .....types import (
16
17
  SandboxProcessStdinParams as SandboxProcessStdinParamsDict,
17
18
  )
18
19
  from ...sandboxes.shared import _normalize_exec_params
20
+ from ...sandboxes.process_output import (
21
+ DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
22
+ ProcessOutput,
23
+ validate_output_limit,
24
+ )
19
25
  from .sandbox_transport import RuntimeTransport
20
26
 
21
27
  DEFAULT_PROCESS_KILL_WAIT_SECONDS = 5.0
@@ -25,6 +31,10 @@ class SandboxProcessHandle:
25
31
  def __init__(self, transport: RuntimeTransport, summary: SandboxProcessSummary):
26
32
  self._transport = transport
27
33
  self._summary = summary
34
+ self._output = None
35
+ self._collector = None
36
+ self._events = None
37
+ self._changed = asyncio.Event()
28
38
 
29
39
  @property
30
40
  def id(self) -> str:
@@ -50,6 +60,15 @@ class SandboxProcessHandle:
50
60
  timeout_ms: Optional[int] = None,
51
61
  timeout_sec: Optional[int] = None,
52
62
  ) -> SandboxProcessResult:
63
+ if self._collector is not None:
64
+ timeout = None
65
+ if timeout_sec is not None and timeout_sec > 0:
66
+ timeout = timeout_sec
67
+ elif timeout_ms is not None and timeout_ms > 0:
68
+ timeout = timeout_ms / 1000
69
+ if not self._collector.done():
70
+ await asyncio.wait_for(asyncio.shield(self._collector), timeout)
71
+ return self._collected_result()
53
72
  payload = await self._transport.request_json(
54
73
  f"/sandbox/processes/{self.id}/wait",
55
74
  method="POST",
@@ -60,6 +79,10 @@ class SandboxProcessHandle:
60
79
  headers={"content-type": "application/json"},
61
80
  )
62
81
  result = SandboxProcessResult(**payload["result"])
82
+ if result.output_truncated:
83
+ raise ProcessOutput(self.id, 0).failure(
84
+ "Retained process output is incomplete; collect output from process start"
85
+ )
63
86
  self._summary = SandboxProcessSummary(
64
87
  id=result.id,
65
88
  status=result.status,
@@ -133,6 +156,21 @@ class SandboxProcessHandle:
133
156
  )
134
157
 
135
158
  async def stream(self, from_seq: Optional[int] = None) -> AsyncIterator[object]:
159
+ if self._output is not None:
160
+ index = 0
161
+ while True:
162
+ self._changed.clear()
163
+ while index < len(self._output.events):
164
+ event = self._output.events[index]
165
+ index += 1
166
+ if from_seq is None or event.seq >= from_seq:
167
+ yield event
168
+ if self._collector.done():
169
+ yield SandboxProcessExitEvent(
170
+ type="exit", result=self._collected_result()
171
+ )
172
+ return
173
+ await self._changed.wait()
136
174
  params = {"from_seq": from_seq} if from_seq and from_seq > 0 else None
137
175
  async for event in self._transport.stream_sse(
138
176
  f"/sandbox/processes/{self.id}/stream",
@@ -153,6 +191,70 @@ class SandboxProcessHandle:
153
191
  result=SandboxProcessResult(**data),
154
192
  )
155
193
 
194
+ def _collected_result(self) -> SandboxProcessResult:
195
+ if self._output.error is not None:
196
+ raise self._output.error
197
+ if self._output.result is None:
198
+ raise self._output.failure(
199
+ "Command stream ended before its completion event"
200
+ )
201
+ result = self._output.result
202
+ self._summary = self._summary.model_copy(
203
+ update={
204
+ "status": result.status,
205
+ "exit_code": result.exit_code,
206
+ "completed_at": result.completed_at,
207
+ }
208
+ )
209
+ return result
210
+
211
+ async def _collect(self) -> None:
212
+ try:
213
+ async for event in self._events:
214
+ self._output.consume(event)
215
+ self._changed.set()
216
+ if self._output.result is not None:
217
+ return
218
+ self._output.error = self._output.failure(
219
+ "Command stream ended before its completion event"
220
+ )
221
+ except asyncio.CancelledError:
222
+ if self._output.result is None:
223
+ self._output.error = self._output.failure(
224
+ "Command output collection disconnected"
225
+ )
226
+ except Exception as error:
227
+ self._output.error = (
228
+ self._output.failure(str(error))
229
+ if not hasattr(error, "code")
230
+ else error
231
+ )
232
+ finally:
233
+ try:
234
+ await self._events.aclose()
235
+ except Exception as error:
236
+ if self._output.result is None and self._output.error is None:
237
+ self._output.error = self._output.failure(str(error))
238
+ finally:
239
+ self._changed.set()
240
+
241
+ async def disconnect(self) -> None:
242
+ """Stop collecting output; the detached command continues running."""
243
+ if self._collector is not None and not self._collector.done():
244
+ if self._output.result is None and self._output.error is None:
245
+ self._output.error = self._output.failure(
246
+ "Command output collection disconnected"
247
+ )
248
+ self._collector.cancel()
249
+ try:
250
+ await self._collector
251
+ except asyncio.CancelledError:
252
+ pass
253
+ finally:
254
+ # Cancellation may happen before the collector gets its first turn.
255
+ await self._events.aclose()
256
+ self._changed.set()
257
+
156
258
  async def result(self) -> SandboxProcessResult:
157
259
  return await self.wait()
158
260
 
@@ -170,22 +272,21 @@ class SandboxProcessesApi:
170
272
  timeout_ms: Optional[int] = None,
171
273
  timeout_sec: Optional[int] = None,
172
274
  run_as: Optional[str] = None,
275
+ max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
173
276
  ) -> SandboxProcessResult:
174
- params = _normalize_exec_params(
277
+ handle = await self.start(
175
278
  input,
176
279
  cwd=cwd,
177
280
  env=env,
178
281
  timeout_ms=timeout_ms,
179
282
  timeout_sec=timeout_sec,
180
283
  run_as=run_as,
284
+ max_output_bytes=max_output_bytes,
181
285
  )
182
- payload = await self._transport.request_json(
183
- "/sandbox/exec",
184
- method="POST",
185
- json_body=dump_request(params, SandboxExecParams),
186
- headers={"content-type": "application/json"},
187
- )
188
- return SandboxProcessResult(**payload["result"])
286
+ try:
287
+ return await handle.wait()
288
+ finally:
289
+ await handle.disconnect()
189
290
 
190
291
  async def start(
191
292
  self,
@@ -196,7 +297,9 @@ class SandboxProcessesApi:
196
297
  timeout_ms: Optional[int] = None,
197
298
  timeout_sec: Optional[int] = None,
198
299
  run_as: Optional[str] = None,
300
+ max_output_bytes: int = DEFAULT_MAX_PROCESS_OUTPUT_BYTES,
199
301
  ) -> SandboxProcessHandle:
302
+ validate_output_limit(max_output_bytes)
200
303
  params = _normalize_exec_params(
201
304
  input,
202
305
  cwd=cwd,
@@ -205,16 +308,25 @@ class SandboxProcessesApi:
205
308
  timeout_sec=timeout_sec,
206
309
  run_as=run_as,
207
310
  )
208
- payload = await self._transport.request_json(
311
+ events = self._transport.stream_sse(
209
312
  "/sandbox/processes",
210
313
  method="POST",
211
314
  json_body=dump_request(params, SandboxExecParams),
212
- headers={"content-type": "application/json"},
213
- )
214
- return SandboxProcessHandle(
215
- self._transport,
216
- SandboxProcessSummary(**payload["process"]),
217
315
  )
316
+ try:
317
+ started = await events.__anext__()
318
+ if started["event"] != "started":
319
+ raise RuntimeError("Expected process start event")
320
+ handle = SandboxProcessHandle(
321
+ self._transport, SandboxProcessSummary(**started["data"])
322
+ )
323
+ except BaseException:
324
+ await events.aclose()
325
+ raise
326
+ handle._events = events
327
+ handle._output = ProcessOutput(handle.id, max_output_bytes)
328
+ handle._collector = asyncio.create_task(handle._collect())
329
+ return handle
218
330
 
219
331
  async def get(self, process_id: str) -> SandboxProcessHandle:
220
332
  payload = await self._transport.request_json(f"/sandbox/processes/{process_id}")
@@ -3,6 +3,7 @@ from typing import Any, AsyncIterator, Dict, Optional
3
3
 
4
4
  import httpx
5
5
 
6
+ from .....exceptions import HyperbrowserError
6
7
  from .....sandbox_common import (
7
8
  RuntimeConnection,
8
9
  build_headers,
@@ -12,7 +13,11 @@ from .....sandbox_common import (
12
13
  request_context,
13
14
  resolve_runtime_transport_target,
14
15
  )
15
- from ...sandboxes.shared import _build_query_path, _is_replayable_http_content
16
+ from ...sandboxes.shared import (
17
+ PROCESS_STREAM_IDLE_TIMEOUT_SECONDS,
18
+ _build_query_path,
19
+ _is_replayable_http_content,
20
+ )
16
21
 
17
22
 
18
23
  class RuntimeTransport:
@@ -83,9 +88,16 @@ class RuntimeTransport:
83
88
  await client.aclose()
84
89
 
85
90
  async def stream_sse(
86
- self, path: str, params: Optional[Dict[str, object]] = None
91
+ self,
92
+ path: str,
93
+ params: Optional[Dict[str, object]] = None,
94
+ *,
95
+ method: str = "GET",
96
+ json_body: Optional[Dict[str, object]] = None,
87
97
  ) -> AsyncIterator[Dict[str, object]]:
88
- client, response = await self._open_stream(path, params=params)
98
+ client, response = await self._open_stream(
99
+ path, params=params, method=method, json_body=json_body
100
+ )
89
101
  event_name = "message"
90
102
  event_id = None
91
103
  data_lines = []
@@ -115,6 +127,8 @@ class RuntimeTransport:
115
127
 
116
128
  try:
117
129
  async for line in response.aiter_lines():
130
+ # HTTPX < 0.24 includes line terminators in aiter_lines().
131
+ line = line.rstrip("\r\n")
118
132
  if line == "":
119
133
  event = flush_event()
120
134
  if event is not None:
@@ -194,18 +208,45 @@ class RuntimeTransport:
194
208
  *,
195
209
  params: Optional[Dict[str, object]] = None,
196
210
  allow_refresh: bool = True,
211
+ method: str = "GET",
212
+ json_body: Optional[Dict[str, object]] = None,
197
213
  ):
198
214
  connection = await self._resolve_connection(False)
199
- client, response = await self._send_stream(connection, path, params=params)
215
+ client, response = await self._send_stream(
216
+ connection, path, params=params, method=method, json_body=json_body
217
+ )
200
218
  if response.status_code == 401 and allow_refresh:
201
219
  await response.aclose()
202
220
  await client.aclose()
203
221
  refreshed = await self._resolve_connection(True)
204
- client, response = await self._send_stream(refreshed, path, params=params)
222
+ client, response = await self._send_stream(
223
+ refreshed, path, params=params, method=method, json_body=json_body
224
+ )
205
225
 
206
- if not response.is_success:
207
- await response.aread()
208
- ensure_response_ok(response, "runtime")
226
+ try:
227
+ if not response.is_success:
228
+ await response.aread()
229
+ ensure_response_ok(response, "runtime")
230
+ if method == "POST" and "text/event-stream" not in response.headers.get(
231
+ "content-type", ""
232
+ ):
233
+ raise HyperbrowserError(
234
+ "Receiver does not support streaming command start; update the receiver. The command may have started; do not retry it automatically.",
235
+ code="streaming_not_supported",
236
+ service="runtime",
237
+ )
238
+ if "text/event-stream" in response.headers.get("content-type", ""):
239
+ # HTTPX passes this timeout extension through to body reads.
240
+ # Change it before reading the body, after response headers have
241
+ # arrived under the ordinary request timeout. Heartbeats reset
242
+ # this idle timeout independently of the command's deadline.
243
+ response.request.extensions["timeout"]["read"] = (
244
+ PROCESS_STREAM_IDLE_TIMEOUT_SECONDS
245
+ )
246
+ except BaseException:
247
+ await response.aclose()
248
+ await client.aclose()
249
+ raise
209
250
  return client, response
210
251
 
211
252
  async def _open_binary_stream(
@@ -320,6 +361,8 @@ class RuntimeTransport:
320
361
  path: str,
321
362
  *,
322
363
  params: Optional[Dict[str, object]],
364
+ method: str = "GET",
365
+ json_body: Optional[Dict[str, object]] = None,
323
366
  ):
324
367
  request_path = _build_query_path(path, params)
325
368
  target = resolve_runtime_transport_target(
@@ -335,7 +378,9 @@ class RuntimeTransport:
335
378
  client = httpx.AsyncClient(timeout=self._timeout)
336
379
 
337
380
  try:
338
- request = client.build_request("GET", target.url, headers=headers)
381
+ request = client.build_request(
382
+ method, target.url, headers=headers, json=json_body
383
+ )
339
384
  response = await client.send(request, stream=True)
340
385
  return client, response
341
386
  except BaseException as error:
@@ -344,5 +389,5 @@ class RuntimeTransport:
344
389
  error,
345
390
  "runtime",
346
391
  "Unknown runtime request error",
347
- request_context("GET", path),
392
+ request_context(method, path),
348
393
  )
@@ -0,0 +1,88 @@
1
+ """Collection and validation of the receiver's command event stream."""
2
+
3
+ import base64
4
+ import codecs
5
+ from typing import Dict, List, Optional
6
+
7
+ from ....exceptions import HyperbrowserError
8
+ from ....models.sandbox import SandboxProcessOutputEvent, SandboxProcessResult
9
+
10
+ DEFAULT_MAX_PROCESS_OUTPUT_BYTES = 64 * 1024 * 1024
11
+
12
+
13
+ class ProcessOutput:
14
+ def __init__(self, process_id: str, max_bytes: int):
15
+ self.process_id = process_id
16
+ self.max_bytes = max_bytes
17
+ self.size = 0
18
+ self.seq = 0
19
+ self.events: List[SandboxProcessOutputEvent] = []
20
+ self.result: Optional[SandboxProcessResult] = None
21
+ self.error: Optional[BaseException] = None
22
+ self._chunks: Dict[str, List[str]] = {"stdout": [], "stderr": []}
23
+ self._decoders = {
24
+ stream: codecs.getincrementaldecoder("utf-8")(errors="replace")
25
+ for stream in ("stdout", "stderr", "system")
26
+ }
27
+
28
+ def failure(
29
+ self, message: str, code: str = "incomplete_output"
30
+ ) -> HyperbrowserError:
31
+ return HyperbrowserError(
32
+ message,
33
+ code=code,
34
+ service="runtime",
35
+ details={"process_id": self.process_id, "last_seq": self.seq},
36
+ )
37
+
38
+ def consume(self, event) -> None:
39
+ kind, data = event["event"], event["data"]
40
+ if kind == "output":
41
+ if data["seq"] != self.seq + 1:
42
+ raise self.failure("Command output contains a sequence gap")
43
+ stream = data["stream"]
44
+ if stream not in self._decoders:
45
+ raise self.failure("Unknown command output stream")
46
+ raw = (
47
+ base64.b64decode(data["data"], validate=True)
48
+ if data.get("encoding") == "base64"
49
+ else data["data"].encode("utf-8")
50
+ )
51
+ self.size += len(raw)
52
+ if self.size > self.max_bytes:
53
+ raise self.failure(
54
+ "Command output exceeds max_output_bytes; increase the collection limit or disconnect a detached process",
55
+ "output_limit_exceeded",
56
+ )
57
+ self.seq = data["seq"]
58
+ text = self._decoders[stream].decode(raw)
59
+ self._chunks["stdout" if stream == "stdout" else "stderr"].append(text)
60
+ self.events.append(
61
+ SandboxProcessOutputEvent(
62
+ type=stream,
63
+ seq=self.seq,
64
+ data=text,
65
+ timestamp=data["timestamp"],
66
+ )
67
+ )
68
+ elif kind == "done":
69
+ if data.get("last_seq") != self.seq or data.get("output_truncated", False):
70
+ raise self.failure("Receiver reported incomplete command output")
71
+ for stream, decoder in self._decoders.items():
72
+ self._chunks["stdout" if stream == "stdout" else "stderr"].append(
73
+ decoder.decode(b"", final=True)
74
+ )
75
+ result = dict(data)
76
+ result["stdout"] = "".join(self._chunks["stdout"])
77
+ result["stderr"] = "".join(self._chunks["stderr"])
78
+ self.result = SandboxProcessResult(**result)
79
+ elif kind == "error":
80
+ raise self.failure(
81
+ str(data.get("error", "Command stream failed")),
82
+ data.get("code", "incomplete_output"),
83
+ )
84
+
85
+
86
+ def validate_output_limit(value: int) -> None:
87
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
88
+ raise ValueError("max_output_bytes must be a positive integer")
@@ -27,6 +27,8 @@ from ....types import (
27
27
  )
28
28
 
29
29
  DEFAULT_WATCH_TIMEOUT_MS = 60_000
30
+ # The receiver sends process SSE keepalives every 15 seconds.
31
+ PROCESS_STREAM_IDLE_TIMEOUT_SECONDS = 60.0
30
32
  SHELL_SAFE_TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_@%+=:,./-]+$")
31
33
 
32
34
 
@@ -299,6 +299,7 @@ class SandboxHandle:
299
299
  timeout_ms: Optional[int] = None,
300
300
  timeout_sec: Optional[int] = None,
301
301
  run_as: Optional[str] = None,
302
+ max_output_bytes: int = 64 * 1024 * 1024,
302
303
  ):
303
304
  return self.processes.exec(
304
305
  input,
@@ -307,6 +308,7 @@ class SandboxHandle:
307
308
  timeout_ms=timeout_ms,
308
309
  timeout_sec=timeout_sec,
309
310
  run_as=run_as,
311
+ max_output_bytes=max_output_bytes,
310
312
  )
311
313
 
312
314
  def get_process(self, process_id: str) -> SandboxProcessHandle: