withruntime 0.6.0__tar.gz → 0.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 (136) hide show
  1. {withruntime-0.6.0/withruntime.egg-info → withruntime-0.7.0}/PKG-INFO +6 -2
  2. {withruntime-0.6.0 → withruntime-0.7.0}/README.md +1 -1
  3. {withruntime-0.6.0 → withruntime-0.7.0}/pyproject.toml +8 -1
  4. withruntime-0.7.0/tests/test_account_errors.py +46 -0
  5. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_async_stream_cleanup.py +47 -0
  6. withruntime-0.7.0/tests/test_billing.py +72 -0
  7. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_capacity_wait.py +9 -0
  8. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_client.py +3 -1
  9. withruntime-0.7.0/tests/test_deepagents.py +194 -0
  10. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_e2b_sandbox.py +19 -4
  11. withruntime-0.7.0/tests/test_eval_sandbox.py +43 -0
  12. withruntime-0.7.0/tests/test_exec_stream.py +131 -0
  13. withruntime-0.7.0/tests/test_files_download.py +137 -0
  14. withruntime-0.7.0/tests/test_harbor.py +554 -0
  15. withruntime-0.7.0/tests/test_inspect_ai.py +234 -0
  16. withruntime-0.7.0/tests/test_late_answer.py +69 -0
  17. withruntime-0.7.0/tests/test_secrets.py +193 -0
  18. withruntime-0.7.0/tests/test_sso.py +29 -0
  19. withruntime-0.7.0/tests/test_wake_awake.py +34 -0
  20. withruntime-0.7.0/tests/test_wallet.py +55 -0
  21. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/__init__.py +4 -3
  22. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_client.py +335 -57
  23. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/__init__.py +6 -1
  24. withruntime-0.7.0/withruntime/_async_products/billing.py +31 -0
  25. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/images.py +2 -2
  26. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/network_products.py +46 -0
  27. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/secrets.py +23 -3
  28. withruntime-0.7.0/withruntime/_async_products/sso.py +20 -0
  29. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_clock.py +10 -0
  30. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_errors.py +8 -1
  31. withruntime-0.7.0/withruntime/_eval_sandbox.py +371 -0
  32. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_http.py +64 -18
  33. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_proxy.py +2 -4
  34. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_client.py +335 -57
  35. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/__init__.py +6 -1
  36. withruntime-0.7.0/withruntime/_sync_products/billing.py +32 -0
  37. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/images.py +2 -2
  38. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/network_products.py +46 -0
  39. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/secrets.py +23 -3
  40. withruntime-0.7.0/withruntime/_sync_products/sso.py +21 -0
  41. withruntime-0.7.0/withruntime/_version.py +1 -0
  42. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_ws.py +2 -3
  43. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/daytona/_async_daytona.py +3 -6
  44. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/daytona/_sync_daytona.py +3 -6
  45. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/deepagents.py +15 -7
  46. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/_async_io.py +14 -0
  47. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/_async_sandbox.py +24 -9
  48. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/_core.py +4 -0
  49. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/_sync_io.py +5 -0
  50. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/_sync_sandbox.py +24 -9
  51. withruntime-0.7.0/withruntime/harbor.py +309 -0
  52. withruntime-0.7.0/withruntime/inspect_ai.py +462 -0
  53. withruntime-0.7.0/withruntime/wallet.py +62 -0
  54. {withruntime-0.6.0 → withruntime-0.7.0/withruntime.egg-info}/PKG-INFO +6 -2
  55. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime.egg-info/SOURCES.txt +20 -0
  56. withruntime-0.7.0/withruntime.egg-info/entry_points.txt +2 -0
  57. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime.egg-info/requires.txt +6 -0
  58. withruntime-0.6.0/tests/test_deepagents.py +0 -79
  59. withruntime-0.6.0/tests/test_secrets.py +0 -91
  60. withruntime-0.6.0/withruntime/_version.py +0 -1
  61. {withruntime-0.6.0 → withruntime-0.7.0}/LICENSE +0 -0
  62. {withruntime-0.6.0 → withruntime-0.7.0}/setup.cfg +0 -0
  63. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_audit.py +0 -0
  64. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_daytona.py +0 -0
  65. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_e2b_code_interpreter.py +0 -0
  66. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_files_mode.py +0 -0
  67. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_identity_token.py +0 -0
  68. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_images_v2.py +0 -0
  69. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_images_volumes_interpreter.py +0 -0
  70. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_lifecycle_settings.py +0 -0
  71. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_limits.py +0 -0
  72. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_mounts.py +0 -0
  73. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_network_funding.py +0 -0
  74. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_network_products.py +0 -0
  75. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_observability.py +0 -0
  76. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_openai_agents.py +0 -0
  77. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_previews_network_desktop.py +0 -0
  78. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_proxy.py +0 -0
  79. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_referrals.py +0 -0
  80. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_snapshot_after_fork.py +0 -0
  81. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_switching.py +0 -0
  82. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_tools.py +0 -0
  83. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_vercel.py +0 -0
  84. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_volume_backups.py +0 -0
  85. {withruntime-0.6.0 → withruntime-0.7.0}/tests/test_watch.py +0 -0
  86. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/audit.py +0 -0
  87. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/desktop.py +0 -0
  88. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/interpreter.py +0 -0
  89. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/limits.py +0 -0
  90. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/mcp.py +0 -0
  91. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/mounts.py +0 -0
  92. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/network.py +0 -0
  93. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/observability.py +0 -0
  94. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/previews.py +0 -0
  95. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/referrals.py +0 -0
  96. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/switching.py +0 -0
  97. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/volumes.py +0 -0
  98. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_async_products/watch.py +0 -0
  99. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_connection.py +0 -0
  100. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/audit.py +0 -0
  101. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/desktop.py +0 -0
  102. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/interpreter.py +0 -0
  103. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/limits.py +0 -0
  104. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/mcp.py +0 -0
  105. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/mounts.py +0 -0
  106. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/network.py +0 -0
  107. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/observability.py +0 -0
  108. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/previews.py +0 -0
  109. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/referrals.py +0 -0
  110. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/switching.py +0 -0
  111. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/volumes.py +0 -0
  112. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_sync_products/watch.py +0 -0
  113. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/_tunnel.py +0 -0
  114. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/daytona/__init__.py +0 -0
  115. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/daytona/_async_io.py +0 -0
  116. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/daytona/_core.py +0 -0
  117. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/daytona/_sync_io.py +0 -0
  118. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/__init__.py +0 -0
  119. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/__init__.py +0 -0
  120. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
  121. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/_models.py +0 -0
  122. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
  123. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/openai_agents.py +0 -0
  124. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/tools.py +0 -0
  125. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/__init__.py +0 -0
  126. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/_async_io.py +0 -0
  127. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/_async_sandbox.py +0 -0
  128. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/_core.py +0 -0
  129. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/_sync_io.py +0 -0
  130. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/_sync_sandbox.py +0 -0
  131. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/api.py +0 -0
  132. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/sandbox/__init__.py +0 -0
  133. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/vercel/sandbox/sync.py +0 -0
  134. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime/webhooks.py +0 -0
  135. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime.egg-info/dependency_links.txt +0 -0
  136. {withruntime-0.6.0 → withruntime-0.7.0}/withruntime.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withruntime
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -10,6 +10,10 @@ Provides-Extra: openai-agents
10
10
  Requires-Dist: openai-agents>=0.22.3; extra == "openai-agents"
11
11
  Provides-Extra: deepagents
12
12
  Requires-Dist: deepagents>=0.7; extra == "deepagents"
13
+ Provides-Extra: harbor
14
+ Requires-Dist: harbor>=0.23.0; extra == "harbor"
15
+ Provides-Extra: inspect-ai
16
+ Requires-Dist: inspect-ai>=0.3.268; extra == "inspect-ai"
13
17
  Dynamic: license-file
14
18
 
15
19
  # Runtime Cloud Python SDK
@@ -39,7 +43,7 @@ with Sandbox.create() as sbx:
39
43
 
40
44
  `Sandbox.create()` needs no arguments and returns once the sandbox is running;
41
45
  leaving the `with` block stops it. With no arguments you get the free trial
42
- while it lasts: 50 free hours, no card, up to eight sandboxes running at once.
46
+ while it lasts: 100 free hours, no card, up to eight sandboxes running at once.
43
47
  The current default image includes NumPy, pandas and matplotlib; see the
44
48
  [sandbox environment](https://withruntime.com/docs/sandbox-environment).
45
49
  `AsyncRuntime` is the same client for asyncio, method for method:
@@ -25,7 +25,7 @@ with Sandbox.create() as sbx:
25
25
 
26
26
  `Sandbox.create()` needs no arguments and returns once the sandbox is running;
27
27
  leaving the `with` block stops it. With no arguments you get the free trial
28
- while it lasts: 50 free hours, no card, up to eight sandboxes running at once.
28
+ while it lasts: 100 free hours, no card, up to eight sandboxes running at once.
29
29
  The current default image includes NumPy, pandas and matplotlib; see the
30
30
  [sandbox environment](https://withruntime.com/docs/sandbox-environment).
31
31
  `AsyncRuntime` is the same client for asyncio, method for method:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "withruntime"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -16,6 +16,13 @@ dependencies = []
16
16
  openai-agents = ["openai-agents>=0.22.3"]
17
17
  # withruntime.deepagents: a LangChain Deep Agents sandbox backend.
18
18
  deepagents = ["deepagents>=0.7"]
19
+ # withruntime.harbor: an environment for Harbor (Terminal-Bench): -e withruntime.harbor:RuntimeEnvironment.
20
+ harbor = ["harbor>=0.23.0"]
21
+ # withruntime.inspect_ai: sandbox="runtime" for Inspect AI, registered by the entry point below.
22
+ inspect-ai = ["inspect-ai>=0.3.268"]
23
+
24
+ [project.entry-points.inspect_ai]
25
+ withruntime = "withruntime.inspect_ai"
19
26
 
20
27
  [tool.setuptools.packages.find]
21
28
  where = ["."]
@@ -0,0 +1,46 @@
1
+ """A blocked account gets its own error type (ARCHITECTURE.md section 11,
2
+ "A blocked account is told why")."""
3
+
4
+ import unittest
5
+ from typing import Any
6
+
7
+ import withruntime
8
+ from withruntime._errors import error_for
9
+
10
+
11
+ class AccountErrorsTest(unittest.TestCase):
12
+ def test_account_blocked_is_its_own_type(self) -> None:
13
+ error = error_for(402, {"error": {"code": "account_blocked",
14
+ "message": "This account cannot spend: a payment on it is disputed."}}, None)
15
+ self.assertIsInstance(error, withruntime.AccountBlockedError)
16
+ self.assertIsInstance(error, withruntime.RuntimeError)
17
+ self.assertEqual(error.code, "account_blocked")
18
+ self.assertEqual(error.status, 402)
19
+ self.assertFalse(error.retryable)
20
+
21
+ def test_empty_balance_stays_plain(self) -> None:
22
+ error = error_for(402, {"error": {"code": "insufficient_funds", "message": "Add credit."}}, None)
23
+ self.assertNotIsInstance(error, withruntime.AccountBlockedError)
24
+
25
+
26
+ class AccountCloseTest(unittest.TestCase):
27
+ """runtime.account.close (ARCHITECTURE.md section 3.1)."""
28
+
29
+ def test_close_posts_the_typed_name_once(self) -> None:
30
+ from withruntime._sync_client import Account
31
+
32
+ calls: list[tuple[Any, ...]] = []
33
+
34
+ class Transport:
35
+ def json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
36
+ calls.append((method, path, kwargs))
37
+ return {"orgId": "o1", "name": "Acme", "closedAt": "2026-09-25T20:00:00Z", "released": True}
38
+
39
+ closed = Account(Transport()).close(confirm="Acme") # type: ignore[arg-type]
40
+ self.assertTrue(closed["released"])
41
+ self.assertEqual(calls, [("POST", "/v1/account:close", {"body": {"confirm": "Acme"}, "retry": False})])
42
+ self.assertTrue(hasattr(withruntime.Runtime(api_key="rk_x"), "account"))
43
+
44
+
45
+ if __name__ == "__main__":
46
+ unittest.main()
@@ -63,6 +63,38 @@ class AsyncStreamCleanupTest(unittest.IsolatedAsyncioTestCase):
63
63
  await asyncio.wait_for(asyncio.gather(*(event.wait() for event in self.peer_eofs)), 0.5)
64
64
  self.assertEqual(len(self.transport._http._idle), 0)
65
65
 
66
+ async def test_cancel_in_the_turn_a_read_completes_is_not_swallowed(self):
67
+ """Python 3.11's asyncio.wait_for returned the read's data and dropped the cancel
68
+ (CPython gh-86296); the stream then read on until its timeout, which is how
69
+ test_cancelled_pending_read_closes_peer failed there. Both paths of within()."""
70
+ import unittest.mock
71
+ from withruntime._http import within
72
+
73
+ async def race():
74
+ ready = asyncio.get_running_loop().create_future()
75
+
76
+ async def read():
77
+ return await ready
78
+ task = asyncio.create_task(within(read(), 5))
79
+ await asyncio.sleep(0)
80
+ ready.set_result(b'data')
81
+ task.cancel()
82
+ with self.assertRaises(asyncio.CancelledError):
83
+ await task
84
+
85
+ async def results():
86
+ self.assertEqual(await within(asyncio.sleep(0, b'ok'), 5), b'ok')
87
+ with self.assertRaises(asyncio.TimeoutError):
88
+ await within(asyncio.sleep(5), 0.01)
89
+
90
+ await race()
91
+ await results()
92
+ # Python 3.10 has no asyncio.timeout: the fallback waits on a task instead.
93
+ with unittest.mock.patch.dict(asyncio.__dict__):
94
+ asyncio.__dict__.pop('timeout', None)
95
+ await race()
96
+ await results()
97
+
66
98
  async def test_buffered_stream_close_then_transport_close_does_not_repool_later(self):
67
99
  stream = self.transport.events('GET', '/events')
68
100
  self.assertEqual(await anext(stream), {'event': 1})
@@ -220,3 +252,18 @@ class SyncStreamCleanupTest(unittest.TestCase):
220
252
  response.close()
221
253
  self.assertTrue(self.eof.wait(0.5), 'closed pool retained a late response')
222
254
  self.assertEqual(self.transport._http._idle.qsize(), 0)
255
+
256
+
257
+ class NoWaitForTest(unittest.TestCase):
258
+ def test_no_module_uses_asyncio_wait_for(self):
259
+ # asyncio.wait_for loses a cancel before Python 3.12 (gh-86296); the
260
+ # client waits through _http.within instead, everywhere.
261
+ import re
262
+ from pathlib import Path
263
+ package = Path(__file__).resolve().parent.parent / "withruntime"
264
+ found = [f"{path.relative_to(package)}:{number}"
265
+ for path in sorted(package.rglob("*.py"))
266
+ for number, line in enumerate(path.read_text().splitlines(), 1)
267
+ if re.search(r"\basyncio\.wait_for\(", line)]
268
+ self.assertEqual(found, [])
269
+
@@ -0,0 +1,72 @@
1
+ """runtime.billing.topup() and topup_status() against a stub API, sync and
2
+ async. The route is tested in packages/cloud (billing-api)."""
3
+ import asyncio
4
+ import json
5
+ import threading
6
+ import unittest
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+
9
+ from withruntime import AsyncRuntime, Runtime
10
+
11
+ TOPUP = {
12
+ "purchaseId": "3f0c2a4e-1b7d-4c5a-9e2f-0a1b2c3d4e5f",
13
+ "status": "open",
14
+ "amountUsd": "20.00",
15
+ "amount": "20.000000",
16
+ "payBy": "2026-09-25T13:00:00.000Z",
17
+ "networks": [{"network": "base", "address": "0xbase", "tokens": [{"currency": "usdc", "contract": "0xusdc"}]}],
18
+ }
19
+
20
+
21
+ class Stub(BaseHTTPRequestHandler):
22
+ seen: list = []
23
+
24
+ def log_message(self, *args):
25
+ pass
26
+
27
+ def answer(self):
28
+ length = int(self.headers.get("content-length") or 0)
29
+ body = json.loads(self.rfile.read(length)) if length else None
30
+ Stub.seen.append((self.command, self.path, body, self.headers.get("idempotency-key")))
31
+ data = json.dumps(TOPUP).encode()
32
+ self.send_response(200)
33
+ self.send_header("content-type", "application/json")
34
+ self.send_header("content-length", str(len(data)))
35
+ self.end_headers()
36
+ self.wfile.write(data)
37
+
38
+ do_GET = answer
39
+ do_POST = answer
40
+
41
+
42
+ class BillingTest(unittest.TestCase):
43
+ @classmethod
44
+ def setUpClass(cls):
45
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
46
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
47
+ cls.url = f"http://127.0.0.1:{cls.server.server_address[1]}"
48
+
49
+ @classmethod
50
+ def tearDownClass(cls):
51
+ cls.server.shutdown()
52
+
53
+ def setUp(self):
54
+ Stub.seen = []
55
+
56
+ def test_sync_topup_and_status(self):
57
+ with Runtime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
58
+ self.assertEqual(runtime.billing.topup(20, idempotency_key="k1")["amount"], "20.000000")
59
+ self.assertEqual(runtime.billing.topup_status(TOPUP["purchaseId"])["status"], "open")
60
+ self.assertEqual(Stub.seen[0], ("POST", "/v1/billing/topups", {"usd": 20}, "k1"))
61
+ self.assertEqual(Stub.seen[1][:2], ("GET", f"/v1/billing/topups/{TOPUP['purchaseId']}"))
62
+
63
+ def test_async_topup(self):
64
+ async def go():
65
+ async with AsyncRuntime(api_key="rk_test", base_url=self.url, max_retries=0) as runtime:
66
+ return await runtime.billing.topup(20)
67
+
68
+ self.assertEqual(asyncio.run(go())["networks"][0]["address"], "0xbase")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ unittest.main()
@@ -84,6 +84,15 @@ class CapacityWait(unittest.TestCase):
84
84
  self.assertEqual(len(set(wire.bodies)), 1)
85
85
  self.assertEqual(json.loads(wire.bodies[0]), {"funding": "trial", "labels": {"ci": "1"}})
86
86
 
87
+ def test_a_create_says_why_it_waits_before_each_wait(self):
88
+ heard = []
89
+ busy = refusal("trial_busy")
90
+ for _, outcome, _ in create([busy, busy], on_capacity_wait=lambda error, seconds: heard.append(
91
+ (error.code, seconds))):
92
+ self.assertEqual(outcome.id, "sbx")
93
+ # Sync, then async: two waits each, of retryAfterMs.
94
+ self.assertEqual(heard, [("trial_busy", 0.005)] * 4)
95
+
87
96
  def test_the_paid_limit_a_domain_limit_and_a_full_region_are_waited_out_too(self):
88
97
  for code in ("quota_exceeded", "trial_domain_limit", "no_capacity"):
89
98
  for wire, outcome, _ in create([refusal(code)]):
@@ -83,8 +83,10 @@ class Sync(unittest.TestCase):
83
83
  self.assertEqual(seen, ["hello\n"])
84
84
 
85
85
  def test_errors_are_typed_and_keys_never_leak(self):
86
+ # A misspelled field is refused before sending (test_exec_stream.CreateFields);
87
+ # a value out of range is the API's to refuse.
86
88
  with self.assertRaises(InvalidRequestError) as caught:
87
- client().sandboxes.create(vcpus=2)
89
+ client().sandboxes.create(vcpu=0)
88
90
  self.assertIn("issues", caught.exception.details)
89
91
  self.assertNotIn("rk_test", str(caught.exception))
90
92
  self.assertTrue(ServiceUnavailableError("x", status=503).retryable)
@@ -0,0 +1,194 @@
1
+ """withruntime.deepagents against stand-in sandboxes. Skipped without Deep Agents
2
+ (pip install "withruntime[deepagents]"). A real sandbox and create_deep_agent
3
+ were exercised on 23 September 2026; see packages/cloud-guide/docs/frameworks.md.
4
+ LocalSandbox runs Deep Agents' own commands on this machine, so every protocol
5
+ method is checked end to end; sdks/python/integrations/langchain-withruntime
6
+ runs LangChain's standard sandbox suite the same way."""
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+ try:
15
+ import deepagents # noqa: F401
16
+ HAVE_DEEPAGENTS = True
17
+ except ImportError:
18
+ HAVE_DEEPAGENTS = False
19
+
20
+ from withruntime import CommandResult
21
+ from withruntime._errors import NotFoundError
22
+ from withruntime._errors import RuntimeError as RuntimeCloudError
23
+
24
+
25
+ class Files:
26
+ def __init__(self):
27
+ self.data, self.removed = {}, []
28
+
29
+ def read(self, path):
30
+ if path not in self.data:
31
+ raise NotFoundError("missing", code="file_not_found", status=404)
32
+ return self.data[path]
33
+
34
+ def write(self, path, data):
35
+ self.data[path] = data
36
+
37
+ def remove(self, path, recursive=False):
38
+ self.removed.append(path)
39
+ self.data.pop(path, None)
40
+
41
+
42
+ class StubSandbox:
43
+ id = "sbx-1"
44
+
45
+ def __init__(self):
46
+ self.files, self.commands, self.outside = Files(), [], {}
47
+
48
+ def exec(self, command, **kwargs):
49
+ self.commands.append((command, kwargs))
50
+ if isinstance(command, list) and command[:2] == ["sh", "-c"]:
51
+ source, target = command[4], command[5]
52
+ if source.startswith("/workspace/"):
53
+ self.outside[target] = self.files.data[source]
54
+ elif source in self.outside:
55
+ self.files.data[target] = self.outside[source]
56
+ else:
57
+ return CommandResult(exit_code=1, stdout="", stderr=f"cat: {source}: No such file or directory\n")
58
+ return CommandResult(exit_code=0, stdout="", stderr="")
59
+ return CommandResult(exit_code=2, stdout="out\n", stderr="err\n", timed_out=True)
60
+
61
+
62
+ class LocalFiles:
63
+ """The Files API over a temporary directory that stands for /workspace."""
64
+
65
+ def __init__(self, root):
66
+ self.root = root
67
+
68
+ def host(self, path):
69
+ assert path.startswith("/workspace/"), path
70
+ return self.root / path[len("/workspace/"):]
71
+
72
+ def read(self, path):
73
+ target = self.host(path)
74
+ if target.is_dir():
75
+ raise RuntimeCloudError(f"{path} is a directory", code="is_directory", status=400)
76
+ if not target.exists():
77
+ raise NotFoundError("missing", code="file_not_found", status=404)
78
+ return target.read_bytes()
79
+
80
+ def write(self, path, data):
81
+ target = self.host(path)
82
+ target.parent.mkdir(parents=True, exist_ok=True)
83
+ target.write_bytes(data)
84
+
85
+ def remove(self, path, recursive=False):
86
+ self.host(path).unlink()
87
+
88
+
89
+ class LocalSandbox:
90
+ """exec runs on this machine: a string under bash -c, a list without a shell,
91
+ with a /workspace argument mapped to the stand-in directory."""
92
+ id = "sbx-local"
93
+
94
+ def __init__(self, root):
95
+ self.files = LocalFiles(root / "workspace")
96
+
97
+ def exec(self, command, timeout_ms=None, **kwargs):
98
+ if isinstance(command, str):
99
+ argv = ["bash", "-c", command]
100
+ else:
101
+ argv = [str(self.files.host(a)) if a.startswith("/workspace/") else a for a in command]
102
+ done = subprocess.run(argv, capture_output=True, timeout=None if timeout_ms is None else timeout_ms / 1000)
103
+ return CommandResult(exit_code=done.returncode, stdout=done.stdout.decode(errors="replace"),
104
+ stderr=done.stderr.decode(errors="replace"))
105
+
106
+
107
+ @unittest.skipUnless(HAVE_DEEPAGENTS, "Deep Agents is not installed")
108
+ class DeepAgentsTest(unittest.TestCase):
109
+ def test_execute_joins_output_and_reports_timeouts(self):
110
+ from withruntime.deepagents import RuntimeSandbox
111
+ sandbox = StubSandbox()
112
+ response = RuntimeSandbox(sandbox, timeout_seconds=9).execute("make")
113
+ self.assertEqual(response.exit_code, 2)
114
+ self.assertTrue(response.output.startswith("out\nerr\n"))
115
+ self.assertIn("ran past 9 seconds", response.output)
116
+ self.assertEqual(sandbox.commands[-1][1], {"timeout_ms": 9000})
117
+
118
+ def test_files_inside_and_outside_the_workspace(self):
119
+ from withruntime.deepagents import RuntimeSandbox
120
+ sandbox = StubSandbox()
121
+ backend = RuntimeSandbox(sandbox)
122
+ uploaded = backend.upload_files([("/workspace/a.txt", b"a"), ("/etc/app.conf", b"b"), ("rel.txt", b"c")])
123
+ self.assertEqual([u.error for u in uploaded], [None, None, "invalid_path"])
124
+ self.assertEqual(sandbox.outside["/etc/app.conf"], b"b")
125
+ downloaded = backend.download_files(["/workspace/a.txt", "/etc/app.conf", "/workspace/none", "/etc/none"])
126
+ self.assertEqual([d.content for d in downloaded[:2]], [b"a", b"b"])
127
+ self.assertEqual([d.error for d in downloaded[2:]], ["file_not_found", "file_not_found"])
128
+ self.assertTrue(all(path.startswith("/workspace/.deepagents-staging/") for path in sandbox.files.removed))
129
+ self.assertEqual(backend.id, "sbx-1")
130
+
131
+ def test_long_output_is_returned_whole_by_default(self):
132
+ from withruntime.deepagents import RuntimeSandbox
133
+
134
+ class Loud(StubSandbox):
135
+ def exec(self, command, **kwargs):
136
+ return CommandResult(exit_code=0, stdout="x" * 250_000, stderr="")
137
+
138
+ response = RuntimeSandbox(Loud()).execute("cat big")
139
+ self.assertEqual((len(response.output), response.truncated), (250_000, False))
140
+ capped = RuntimeSandbox(Loud(), max_output_chars=10).execute("cat big")
141
+ self.assertEqual((capped.output, capped.truncated), ("x" * 10, True))
142
+
143
+
144
+ @unittest.skipUnless(HAVE_DEEPAGENTS, "Deep Agents is not installed")
145
+ @unittest.skipUnless(sys.platform.startswith("linux") and shutil.which("bash") and shutil.which("python3"),
146
+ "LocalSandbox runs Deep Agents' GNU shell and python3 commands on this machine")
147
+ class DeepAgentsEndToEndTest(unittest.TestCase):
148
+ def setUp(self):
149
+ from withruntime.deepagents import RuntimeSandbox
150
+ self.dir = Path(tempfile.mkdtemp(prefix="runtime-deepagents-test-"))
151
+ self.root = str(self.dir / "outside")
152
+ self.backend = RuntimeSandbox(LocalSandbox(self.dir))
153
+
154
+ def tearDown(self):
155
+ shutil.rmtree(self.dir, ignore_errors=True)
156
+
157
+ def test_every_protocol_method(self):
158
+ backend, root = self.backend, self.root
159
+ self.assertIsNone(backend.write(f"{root}/src/app.py", "print('hello')\n").error)
160
+ edited = backend.edit(f"{root}/src/app.py", "hello", "world")
161
+ self.assertEqual((edited.error, edited.occurrences), (None, 1))
162
+ read = backend.read(f"{root}/src/app.py")
163
+ self.assertIn("print('world')", read.file_data["content"])
164
+ ran = backend.execute(f"python3 {root}/src/app.py")
165
+ self.assertEqual((ran.output, ran.exit_code, ran.truncated), ("world\n", 0, False))
166
+ self.assertEqual([e["path"] for e in backend.ls(f"{root}/src").entries], [f"{root}/src/app.py"])
167
+ self.assertEqual([m["path"] for m in backend.glob("**/*.py", path=root).matches], [f"{root}/src/app.py"])
168
+ grep = backend.grep("world", path=root)
169
+ self.assertEqual([(m["path"], m["line"]) for m in grep.matches], [(f"{root}/src/app.py", 1)])
170
+ self.assertIsNone(backend.delete(f"{root}/src/app.py").error)
171
+ self.assertIsNotNone(backend.read(f"{root}/src/app.py").error)
172
+ # Nothing staged outside the workspace was left behind.
173
+ self.assertEqual(list((self.dir / "workspace" / ".deepagents-staging").iterdir()), [])
174
+
175
+ def test_a_file_past_the_old_output_cap_reads_back(self):
176
+ backend, path = self.backend, f"{self.root}/big.txt"
177
+ text = "".join(f"line {i:06d} {'x' * 90}\n" for i in range(1500)) # about 150 KB
178
+ self.assertIsNone(backend.write(path, text).error)
179
+ read = backend.read(path, limit=2000)
180
+ self.assertIsNone(read.error)
181
+ self.assertIn("line 001499", read.file_data["content"])
182
+
183
+ def test_async_methods(self):
184
+ import asyncio
185
+
186
+ async def run():
187
+ self.assertIsNone((await self.backend.awrite(f"{self.root}/a.txt", "one")).error)
188
+ return await self.backend.aread(f"{self.root}/a.txt")
189
+
190
+ self.assertIn("one", asyncio.run(run()).file_data["content"])
191
+
192
+
193
+ if __name__ == "__main__":
194
+ unittest.main()
@@ -340,11 +340,15 @@ class ForksPortsAndGaps(Base):
340
340
  self.assertEqual(caught.exception.code, "fork_unavailable")
341
341
 
342
342
  def test_ports(self):
343
+ # 25 September 2026: get_host raised, so E2B code did not run unchanged.
343
344
  sbx = self.create()
344
- with self.assertRaises(NotSupportedException) as caught:
345
- sbx.get_host(3000)
346
- self.assertIn("get_public_host", str(caught.exception))
347
- self.assertEqual(sbx.get_public_host(3000), f"3000-{sbx.sandbox_id}.runtimehost.com")
345
+ host = f"3000-{sbx.sandbox_id.replace('-', '')}.runtimehost.com"
346
+ self.assertEqual(sbx.get_host(3000), host)
347
+ self.assertEqual(sbx.get_host(3000), host)
348
+ self.assertEqual(sbx.get_public_host(3000), host)
349
+ self.assertEqual(len(self.world.called("previews.create")), 1)
350
+ with self.assertRaises(e2b.InvalidArgumentException):
351
+ sbx.get_host(0)
348
352
 
349
353
  def test_gaps(self):
350
354
  sbx = self.create()
@@ -356,6 +360,17 @@ class ForksPortsAndGaps(Base):
356
360
 
357
361
 
358
362
  class Async(unittest.TestCase):
363
+ def test_get_host_answers_at_once_and_the_share_lands_beside_it(self):
364
+ world = World()
365
+
366
+ async def scenario():
367
+ async with await AsyncSandbox.create(client=world.async_client()) as sbx:
368
+ host = sbx.get_host(8000) # no await, as in E2B
369
+ self.assertEqual(host, f"8000-{sbx.sandbox_id.replace('-', '')}.runtimehost.com")
370
+ self.assertEqual(await sbx.get_public_host(8000), host)
371
+ self.assertEqual(len(world.called("previews.create")), 1)
372
+ asyncio.run(scenario())
373
+
359
374
  def test_async_sandbox_end_to_end(self):
360
375
  world = World()
361
376
 
@@ -0,0 +1,43 @@
1
+ """Exit codes in withruntime._eval_sandbox, which Harbor and Inspect share, as the
2
+ live API reports them (read on 25 September 2026): a command past its limit ends
3
+ with -9 and timedOut, and a command a signal ended with the negative signal
4
+ number. Harbor and Inspect expect what a shell reports. Needs neither."""
5
+ import asyncio
6
+ import unittest
7
+
8
+ from withruntime._eval_sandbox import _Shell, shell_exit_code
9
+
10
+
11
+ class _Sandbox:
12
+ id = "sbx_exit"
13
+
14
+ def __init__(self, exit_event):
15
+ self.exit_event = exit_event
16
+
17
+ async def exec_stream(self, argv, *, env=None, stdin=None, timeout_ms=None):
18
+ yield {"type": "start", "processId": "p1"}
19
+ yield self.exit_event
20
+
21
+
22
+ def _run(exit_event):
23
+ shell = _Shell(_Sandbox(exit_event), "/tmp")
24
+ return asyncio.run(shell._stream(["sh", "-c", "x"], None, None, 1000, None, None))
25
+
26
+
27
+ class ShellExitCodeTest(unittest.TestCase):
28
+ def test_a_timed_out_command_is_124(self):
29
+ result = _run({"type": "exit", "exitCode": -9, "state": "timed_out", "timedOut": True})
30
+ self.assertEqual((result.exit_code, result.timed_out), (124, True))
31
+
32
+ def test_a_command_a_signal_ended_is_128_plus_the_signal(self):
33
+ self.assertEqual(_run({"type": "exit", "exitCode": -15, "timedOut": False}).exit_code, 143)
34
+ self.assertEqual(_run({"type": "exit", "exitCode": -9, "timedOut": False}).exit_code, 137)
35
+
36
+ def test_ordinary_codes_are_kept(self):
37
+ self.assertEqual(_run({"type": "exit", "exitCode": 3, "timedOut": False}).exit_code, 3)
38
+ self.assertEqual(shell_exit_code(0, False), 0)
39
+ self.assertEqual(shell_exit_code(None, False), -1)
40
+
41
+
42
+ if __name__ == "__main__":
43
+ unittest.main()