withruntime 0.7.0__tar.gz → 0.7.2__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.7.0/withruntime.egg-info → withruntime-0.7.2}/PKG-INFO +6 -1
  2. {withruntime-0.7.0 → withruntime-0.7.2}/pyproject.toml +8 -1
  3. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_e2b_sandbox.py +23 -0
  4. withruntime-0.7.2/tests/test_unpack.py +79 -0
  5. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_client.py +10 -15
  6. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_eval_sandbox.py +2 -1
  7. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_client.py +10 -15
  8. withruntime-0.7.2/withruntime/_unpack.py +99 -0
  9. withruntime-0.7.2/withruntime/_version.py +1 -0
  10. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/_async_sandbox.py +6 -2
  11. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/_core.py +16 -5
  12. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/_sync_sandbox.py +6 -2
  13. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/harbor.py +2 -2
  14. {withruntime-0.7.0 → withruntime-0.7.2/withruntime.egg-info}/PKG-INFO +6 -1
  15. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime.egg-info/SOURCES.txt +2 -0
  16. withruntime-0.7.0/withruntime/_version.py +0 -1
  17. {withruntime-0.7.0 → withruntime-0.7.2}/LICENSE +0 -0
  18. {withruntime-0.7.0 → withruntime-0.7.2}/README.md +0 -0
  19. {withruntime-0.7.0 → withruntime-0.7.2}/setup.cfg +0 -0
  20. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_account_errors.py +0 -0
  21. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_async_stream_cleanup.py +0 -0
  22. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_audit.py +0 -0
  23. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_billing.py +0 -0
  24. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_capacity_wait.py +0 -0
  25. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_client.py +0 -0
  26. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_daytona.py +0 -0
  27. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_deepagents.py +0 -0
  28. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_e2b_code_interpreter.py +0 -0
  29. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_eval_sandbox.py +0 -0
  30. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_exec_stream.py +0 -0
  31. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_files_download.py +0 -0
  32. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_files_mode.py +0 -0
  33. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_harbor.py +0 -0
  34. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_identity_token.py +0 -0
  35. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_images_v2.py +0 -0
  36. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_images_volumes_interpreter.py +0 -0
  37. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_inspect_ai.py +0 -0
  38. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_late_answer.py +0 -0
  39. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_lifecycle_settings.py +0 -0
  40. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_limits.py +0 -0
  41. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_mounts.py +0 -0
  42. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_network_funding.py +0 -0
  43. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_network_products.py +0 -0
  44. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_observability.py +0 -0
  45. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_openai_agents.py +0 -0
  46. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_previews_network_desktop.py +0 -0
  47. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_proxy.py +0 -0
  48. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_referrals.py +0 -0
  49. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_secrets.py +0 -0
  50. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_snapshot_after_fork.py +0 -0
  51. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_sso.py +0 -0
  52. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_switching.py +0 -0
  53. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_tools.py +0 -0
  54. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_vercel.py +0 -0
  55. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_volume_backups.py +0 -0
  56. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_wake_awake.py +0 -0
  57. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_wallet.py +0 -0
  58. {withruntime-0.7.0 → withruntime-0.7.2}/tests/test_watch.py +0 -0
  59. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/__init__.py +0 -0
  60. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/__init__.py +0 -0
  61. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/audit.py +0 -0
  62. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/billing.py +0 -0
  63. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/desktop.py +0 -0
  64. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/images.py +0 -0
  65. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/interpreter.py +0 -0
  66. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/limits.py +0 -0
  67. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/mcp.py +0 -0
  68. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/mounts.py +0 -0
  69. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/network.py +0 -0
  70. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/network_products.py +0 -0
  71. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/observability.py +0 -0
  72. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/previews.py +0 -0
  73. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/referrals.py +0 -0
  74. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/secrets.py +0 -0
  75. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/sso.py +0 -0
  76. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/switching.py +0 -0
  77. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/volumes.py +0 -0
  78. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_async_products/watch.py +0 -0
  79. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_clock.py +0 -0
  80. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_connection.py +0 -0
  81. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_errors.py +0 -0
  82. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_http.py +0 -0
  83. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_proxy.py +0 -0
  84. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/__init__.py +0 -0
  85. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/audit.py +0 -0
  86. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/billing.py +0 -0
  87. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/desktop.py +0 -0
  88. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/images.py +0 -0
  89. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/interpreter.py +0 -0
  90. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/limits.py +0 -0
  91. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/mcp.py +0 -0
  92. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/mounts.py +0 -0
  93. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/network.py +0 -0
  94. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/network_products.py +0 -0
  95. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/observability.py +0 -0
  96. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/previews.py +0 -0
  97. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/referrals.py +0 -0
  98. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/secrets.py +0 -0
  99. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/sso.py +0 -0
  100. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/switching.py +0 -0
  101. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/volumes.py +0 -0
  102. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_sync_products/watch.py +0 -0
  103. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_tunnel.py +0 -0
  104. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/_ws.py +0 -0
  105. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/daytona/__init__.py +0 -0
  106. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/daytona/_async_daytona.py +0 -0
  107. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/daytona/_async_io.py +0 -0
  108. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/daytona/_core.py +0 -0
  109. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/daytona/_sync_daytona.py +0 -0
  110. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/daytona/_sync_io.py +0 -0
  111. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/deepagents.py +0 -0
  112. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/__init__.py +0 -0
  113. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/_async_io.py +0 -0
  114. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/_sync_io.py +0 -0
  115. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/__init__.py +0 -0
  116. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
  117. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_models.py +0 -0
  118. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
  119. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/inspect_ai.py +0 -0
  120. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/openai_agents.py +0 -0
  121. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/tools.py +0 -0
  122. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/__init__.py +0 -0
  123. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/_async_io.py +0 -0
  124. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/_async_sandbox.py +0 -0
  125. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/_core.py +0 -0
  126. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/_sync_io.py +0 -0
  127. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/_sync_sandbox.py +0 -0
  128. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/api.py +0 -0
  129. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/sandbox/__init__.py +0 -0
  130. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/vercel/sandbox/sync.py +0 -0
  131. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/wallet.py +0 -0
  132. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime/webhooks.py +0 -0
  133. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime.egg-info/dependency_links.txt +0 -0
  134. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime.egg-info/entry_points.txt +0 -0
  135. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime.egg-info/requires.txt +0 -0
  136. {withruntime-0.7.0 → withruntime-0.7.2}/withruntime.egg-info/top_level.txt +0 -0
@@ -1,8 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withruntime
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
5
5
  License: Apache-2.0
6
+ Project-URL: Homepage, https://withruntime.com
7
+ Project-URL: Documentation, https://withruntime.com/docs/python
8
+ Project-URL: Source, https://github.com/withruntime/runtime/tree/main/sdks/python
9
+ Project-URL: Issues, https://github.com/withruntime/runtime/issues
10
+ Keywords: sandbox,ai-agents,agents,microvm,firecracker,code-execution,code-interpreter,mcp,llm
6
11
  Requires-Python: >=3.10
7
12
  Description-Content-Type: text/markdown
8
13
  License-File: LICENSE
@@ -4,12 +4,19 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "withruntime"
7
- version = "0.7.0"
7
+ version = "0.7.2"
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" }
11
11
  requires-python = ">=3.10"
12
12
  dependencies = []
13
+ keywords = ["sandbox", "ai-agents", "agents", "microvm", "firecracker", "code-execution", "code-interpreter", "mcp", "llm"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://withruntime.com"
17
+ Documentation = "https://withruntime.com/docs/python"
18
+ Source = "https://github.com/withruntime/runtime/tree/main/sdks/python"
19
+ Issues = "https://github.com/withruntime/runtime/issues"
13
20
 
14
21
  [project.optional-dependencies]
15
22
  # withruntime.openai_agents: a sandbox client for the OpenAI Agents SDK's SandboxAgent.
@@ -145,6 +145,29 @@ class Commands(Base):
145
145
  result = sbx.commands.run("python3 -c 'print(\"x\"*70000)'", timeout=60)
146
146
  self.assertEqual((result.exit_code, len(result.stdout), len(result.stderr)), (0, 70_001, 70_000))
147
147
 
148
+ def test_output_dropped_before_it_was_read_is_marked_and_warned(self):
149
+ # A customer's report, 24 September 2026: output lost in a stream came
150
+ # back looking whole.
151
+ sbx = self.create()
152
+ with warnings.catch_warnings(record=True) as caught:
153
+ warnings.simplefilter("always")
154
+ self.world.exec = lambda *_: Result(0, "tail\n", lost=True)
155
+ self.assertTrue(sbx.commands.run("big").truncated)
156
+ self.world.exec = lambda *_: Result(1, "tail\n", lost=True)
157
+ with self.assertRaises(CommandExitException) as failed:
158
+ sbx.commands.run("big")
159
+ self.assertTrue(failed.exception.truncated)
160
+ self.world.exec = lambda *_: Result(0, "whole\n")
161
+ self.assertFalse(sbx.commands.run("small").truncated)
162
+ self.assertEqual(len(caught), 2)
163
+ self.assertIn("output was dropped", str(caught[0].message))
164
+ self.world.output = lambda _: [{"type": "truncated", "droppedBytes": 10, "resumeAt": 10},
165
+ {"type": "stdout", "data": "tail\n", "offset": 10},
166
+ {"type": "exit", "exitCode": 0, "state": "exited", "timedOut": False}]
167
+ with warnings.catch_warnings():
168
+ warnings.simplefilter("ignore")
169
+ self.assertTrue(sbx.commands.run("big", background=True).wait().truncated)
170
+
148
171
  def test_non_zero_exit(self):
149
172
  self.world.exec = lambda *_: Result(3, "partial\n", "boom\n")
150
173
  sbx = self.create()
@@ -0,0 +1,79 @@
1
+ """A directory download cannot write outside its target (26 September 2026).
2
+
3
+ The archive comes from the sandbox, so its contents are the customer's untrusted
4
+ code's to choose. The old check looked at each name before extracting, while the
5
+ archive's own links did not exist yet: a link ``x -> ../outside`` followed by
6
+ ``x/pwned.txt`` passed it and wrote outside. These archives are the ones a
7
+ hostile sandbox would send."""
8
+ import io
9
+ import os
10
+ import tarfile
11
+ import tempfile
12
+ import unittest
13
+
14
+ from withruntime._errors import RuntimeError
15
+ from withruntime._unpack import unpack_archive
16
+
17
+
18
+ def archive(*entries):
19
+ buffer = io.BytesIO()
20
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
21
+ for name, kind, value in entries:
22
+ info = tarfile.TarInfo(name)
23
+ if kind == "dir":
24
+ info.type = tarfile.DIRTYPE
25
+ tar.addfile(info)
26
+ elif kind == "link":
27
+ info.type, info.linkname = tarfile.SYMTYPE, value
28
+ tar.addfile(info)
29
+ else:
30
+ body = value.encode()
31
+ info.size = len(body)
32
+ tar.addfile(info, io.BytesIO(body))
33
+ return buffer.getvalue()
34
+
35
+
36
+ class Unpack(unittest.TestCase):
37
+ def setUp(self):
38
+ self.base = tempfile.TemporaryDirectory()
39
+ self.target = os.path.join(self.base.name, "target")
40
+ self.outside = os.path.join(self.base.name, "outside")
41
+ os.mkdir(self.outside)
42
+
43
+ def tearDown(self):
44
+ self.base.cleanup()
45
+
46
+ def refused(self, data, pattern):
47
+ with self.assertRaisesRegex(RuntimeError, pattern) as caught:
48
+ unpack_archive(data, self.target)
49
+ self.assertEqual(caught.exception.code, "unsafe_archive")
50
+
51
+ def test_a_link_then_a_file_through_it_cannot_write_outside(self):
52
+ self.refused(archive(("x", "link", "../outside"), ("x/pwned.txt", "file", "owned")), "outside the target")
53
+ self.assertFalse(os.path.exists(os.path.join(self.outside, "pwned.txt")))
54
+
55
+ def test_an_absolute_link_is_refused_and_removed(self):
56
+ self.refused(archive(("etc", "link", "/etc")), "leads outside the target")
57
+ self.assertFalse(os.path.lexists(os.path.join(self.target, "etc")))
58
+
59
+ def test_a_link_that_climbs_out_through_another_link_is_refused(self):
60
+ # Lexically d/up/.. is d; followed, d/up is the target and .. leaves it.
61
+ self.refused(archive(("d", "dir", None), ("d/up", "link", ".."), ("x", "link", "d/up/..")), "x -> d/up/..")
62
+ self.assertFalse(os.path.lexists(os.path.join(self.target, "x")))
63
+
64
+ def test_a_file_is_never_written_through_a_link_already_there(self):
65
+ os.mkdir(self.target)
66
+ os.symlink(self.outside, os.path.join(self.target, "cache"))
67
+ self.refused(archive(("cache/pwned.txt", "file", "owned")), "passes through a link")
68
+ self.assertFalse(os.path.exists(os.path.join(self.outside, "pwned.txt")))
69
+
70
+ def test_links_that_stay_inside_land_intact(self):
71
+ unpack_archive(archive(("./node_modules/pkg/cli.js", "file", "run()"),
72
+ ("./node_modules/.bin/tool", "link", "../pkg/cli.js"),
73
+ ("./current", "link", "node_modules/.bin")), self.target)
74
+ with open(os.path.join(self.target, "current", "tool")) as tool:
75
+ self.assertEqual(tool.read(), "run()")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ unittest.main()
@@ -22,6 +22,7 @@ from ._errors import WAITS_FOR_ROOM, CommandError, ConnectionError, RuntimeError
22
22
  from ._http import AsyncHTTP as HTTP
23
23
  from ._http import Origin
24
24
  from ._proxy import describe as describe_route
25
+ from ._unpack import unpack_archive
25
26
  from ._ws import AsyncWebSocket as WebSocket
26
27
  from ._tunnel import AsyncPortForward
27
28
  from ._tunnel import async_open_forward as open_forward
@@ -637,8 +638,9 @@ class AsyncFiles:
637
638
  return (await self.read(path)).decode(encoding)
638
639
 
639
640
  async def write(self, path: str, data: Union[str, bytes], mode: Optional[int] = None) -> dict[str, Any]:
640
- """Writes a file of any size, atomically, making parent directories.
641
- Large files go in parallel 1 MiB chunks checked against their SHA-256.
641
+ """Writes a file, atomically, making parent directories. Under
642
+ /workspace any size, large ones in parallel 1 MiB chunks checked against
643
+ their SHA-256; elsewhere, with the sandbox user's rights, up to 1 MiB.
642
644
  ``mode`` sets its permissions (0o755 for a program); 0o644 when left out."""
643
645
  payload = data.encode() if isinstance(data, str) else bytes(data)
644
646
  octal = None if mode is None else format(mode & 0o777, "03o")
@@ -752,22 +754,12 @@ class AsyncFiles:
752
754
  os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
753
755
  await self._stream_to(remote_path, local_path)
754
756
  return
755
- import io
756
- import tarfile
757
757
  staging = f"/tmp/.runtime-download-{uuid.uuid4().hex}.tar.gz"
758
758
  packed = await AsyncSandbox(self._t, {"id": self._id}).exec(["tar", "-czf", staging, "-C", remote_path, "."])
759
759
  if packed.exit_code != 0:
760
760
  raise CommandError(packed.to_dict())
761
761
  try:
762
- data = await self.read(staging)
763
- os.makedirs(local_path, exist_ok=True)
764
- root = os.path.realpath(local_path)
765
- with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
766
- for member in archive.getmembers():
767
- destination = os.path.realpath(os.path.join(root, member.name))
768
- if destination != root and not destination.startswith(root + os.sep):
769
- raise RuntimeError(f"Refusing an archive entry outside the target: {member.name}", code="unsafe_archive")
770
- archive.extractall(root) # noqa: S202 - every member checked above
762
+ unpack_archive(await self.read(staging), local_path)
771
763
  finally:
772
764
  try:
773
765
  await self.remove(staging)
@@ -815,7 +807,8 @@ class AsyncSandbox:
815
807
  on_stderr: Optional[Callable[[str], Any]] = None, check: bool = False,
816
808
  idempotency_key: Optional[str] = None) -> CommandResult:
817
809
  """Runs a command: a string under ``bash -c``, a list without a shell.
818
- With on_stdout/on_stderr the output streams. A timeout is a result
810
+ With on_stdout/on_stderr the output streams. The timeout is 60 s by
811
+ default, 24 h when the output streams. A timeout is a result
819
812
  (timed_out=True, the output so far), never an error; ``check=True``
820
813
  raises CommandError on a non-zero exit."""
821
814
  streaming = on_stdout is not None or on_stderr is not None or (timeout_ms or 0) > 60_000
@@ -1283,7 +1276,9 @@ class AsyncRuntime:
1283
1276
  microdollars in strings (1,000,000 = $1): ``credited``, ``spent``,
1284
1277
  ``held``, ``expired``, ``available`` (credited - spent - expired - held)
1285
1278
  and ``takenBack`` (the part of spent that refunds and disputes took);
1286
- ``trial`` is in milliseconds, or None."""
1279
+ ``trial`` is in milliseconds, or None; ``outbound`` is this month's
1280
+ outbound traffic in bytes, with ``chargedMicros`` for what went past
1281
+ the free 100 GiB."""
1287
1282
  return await self._t.json("GET", "/v1/usage")
1288
1283
 
1289
1284
  async def request(self, method: str, path: str, **kwargs: Any) -> Any:
@@ -27,7 +27,8 @@ logger = logging.getLogger("withruntime")
27
27
  # by an older builder is not reused. runtime-builder/3 gives uid 1000 sudo in
28
28
  # images built from a public image (ARCHITECTURE.md section 10, "Root in images
29
29
  # built from a public image"); images tagged before it may have none.
30
- IMAGE_RECIPE = "runtime-builder/3"
30
+ # runtime-builder/4 (26 September 2026) fixed that sudo's -s, -i and VAR=value.
31
+ IMAGE_RECIPE = "runtime-builder/4"
31
32
 
32
33
 
33
34
  def image_tag(key: str) -> str:
@@ -21,6 +21,7 @@ from ._errors import WAITS_FOR_ROOM, CommandError, ConnectionError, RuntimeError
21
21
  from ._http import SyncHTTP as HTTP
22
22
  from ._http import Origin
23
23
  from ._proxy import describe as describe_route
24
+ from ._unpack import unpack_archive
24
25
  from ._ws import SyncWebSocket as WebSocket
25
26
  from ._tunnel import PortForward
26
27
  from ._tunnel import sync_open_forward as open_forward
@@ -636,8 +637,9 @@ class Files:
636
637
  return (self.read(path)).decode(encoding)
637
638
 
638
639
  def write(self, path: str, data: Union[str, bytes], mode: Optional[int] = None) -> dict[str, Any]:
639
- """Writes a file of any size, atomically, making parent directories.
640
- Large files go in parallel 1 MiB chunks checked against their SHA-256.
640
+ """Writes a file, atomically, making parent directories. Under
641
+ /workspace any size, large ones in parallel 1 MiB chunks checked against
642
+ their SHA-256; elsewhere, with the sandbox user's rights, up to 1 MiB.
641
643
  ``mode`` sets its permissions (0o755 for a program); 0o644 when left out."""
642
644
  payload = data.encode() if isinstance(data, str) else bytes(data)
643
645
  octal = None if mode is None else format(mode & 0o777, "03o")
@@ -751,22 +753,12 @@ class Files:
751
753
  os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
752
754
  self._stream_to(remote_path, local_path)
753
755
  return
754
- import io
755
- import tarfile
756
756
  staging = f"/tmp/.runtime-download-{uuid.uuid4().hex}.tar.gz"
757
757
  packed = Sandbox(self._t, {"id": self._id}).exec(["tar", "-czf", staging, "-C", remote_path, "."])
758
758
  if packed.exit_code != 0:
759
759
  raise CommandError(packed.to_dict())
760
760
  try:
761
- data = self.read(staging)
762
- os.makedirs(local_path, exist_ok=True)
763
- root = os.path.realpath(local_path)
764
- with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
765
- for member in archive.getmembers():
766
- destination = os.path.realpath(os.path.join(root, member.name))
767
- if destination != root and not destination.startswith(root + os.sep):
768
- raise RuntimeError(f"Refusing an archive entry outside the target: {member.name}", code="unsafe_archive")
769
- archive.extractall(root) # noqa: S202 - every member checked above
761
+ unpack_archive(self.read(staging), local_path)
770
762
  finally:
771
763
  try:
772
764
  self.remove(staging)
@@ -814,7 +806,8 @@ class Sandbox:
814
806
  on_stderr: Optional[Callable[[str], Any]] = None, check: bool = False,
815
807
  idempotency_key: Optional[str] = None) -> CommandResult:
816
808
  """Runs a command: a string under ``bash -c``, a list without a shell.
817
- With on_stdout/on_stderr the output streams. A timeout is a result
809
+ With on_stdout/on_stderr the output streams. The timeout is 60 s by
810
+ default, 24 h when the output streams. A timeout is a result
818
811
  (timed_out=True, the output so far), never an error; ``check=True``
819
812
  raises CommandError on a non-zero exit."""
820
813
  streaming = on_stdout is not None or on_stderr is not None or (timeout_ms or 0) > 60_000
@@ -1282,7 +1275,9 @@ class Runtime:
1282
1275
  microdollars in strings (1,000,000 = $1): ``credited``, ``spent``,
1283
1276
  ``held``, ``expired``, ``available`` (credited - spent - expired - held)
1284
1277
  and ``takenBack`` (the part of spent that refunds and disputes took);
1285
- ``trial`` is in milliseconds, or None."""
1278
+ ``trial`` is in milliseconds, or None; ``outbound`` is this month's
1279
+ outbound traffic in bytes, with ``chargedMicros`` for what went past
1280
+ the free 100 GiB."""
1286
1281
  return self._t.json("GET", "/v1/usage")
1287
1282
 
1288
1283
  def request(self, method: str, path: str, **kwargs: Any) -> Any:
@@ -0,0 +1,99 @@
1
+ """Unpacks a directory download without letting the archive write outside it.
2
+
3
+ The archive comes from a sandbox, whose contents the customer's untrusted code
4
+ controls. Checking each name before extracting is not enough: a link
5
+ ``x -> ../outside`` followed by ``x/file`` passes that check and writes through
6
+ the link, and ``tarfile``'s own ``data`` filter depends on the interpreter's
7
+ patch release. So no write ever passes through a link, links are made only after
8
+ every file and directory is in place, and each link is then walked through the
9
+ finished tree and removed, failing the unpack, if it leads out. The JavaScript
10
+ SDK's ``unpackArchive`` in ``packages/cloud-sdk/src/tar.ts`` keeps the same rules.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import os
16
+ import tarfile
17
+
18
+ from ._errors import RuntimeError
19
+
20
+
21
+ def _refuse(message: str) -> RuntimeError:
22
+ return RuntimeError(message, code="unsafe_archive")
23
+
24
+
25
+ def _inside(root: str, path: str) -> bool:
26
+ return path == root or path.startswith(root + os.sep)
27
+
28
+
29
+ def _assert_plain(root: str, destination: str, name: str) -> None:
30
+ """Refuses unless ``destination`` is inside ``root`` and no part of the way
31
+ to it, itself included, is a link."""
32
+ if not _inside(root, destination):
33
+ raise _refuse(f"Refusing an archive entry outside the target: {name}")
34
+ path = root
35
+ for part in os.path.relpath(destination, root).split(os.sep):
36
+ if part in ("", "."):
37
+ continue
38
+ path = os.path.join(path, part)
39
+ if os.path.islink(path):
40
+ raise _refuse(f"Refusing an archive entry that passes through a link: {name}")
41
+
42
+
43
+ def _link_stays_inside(root: str, directory: str, link: str) -> bool:
44
+ """Whether following ``link`` from ``directory`` stays in ``root`` without
45
+ turning at a link before its last step. The last step may be a link; that
46
+ link is checked on its own."""
47
+ if os.path.isabs(link):
48
+ return False
49
+ parts = [part for part in link.split("/") if part not in ("", ".")]
50
+ path = directory
51
+ for index, part in enumerate(parts):
52
+ path = os.path.dirname(path) if part == ".." else os.path.join(path, part)
53
+ if not _inside(root, path):
54
+ return False
55
+ if index < len(parts) - 1 and part != ".." and os.path.islink(path):
56
+ return False
57
+ return True
58
+
59
+
60
+ def unpack_archive(data: bytes, target: str) -> None:
61
+ """Unpacks a gzipped tar into ``target``, refusing any entry that would
62
+ land outside it."""
63
+ os.makedirs(target, exist_ok=True)
64
+ root = os.path.realpath(target)
65
+ links: list[tuple[str, str, str]] = []
66
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
67
+ for member in archive:
68
+ name = member.name
69
+ while name.startswith("./"):
70
+ name = name[2:]
71
+ if name in ("", "."):
72
+ continue
73
+ destination = os.path.normpath(os.path.join(root, name))
74
+ _assert_plain(root, destination, name)
75
+ if member.isdir():
76
+ os.makedirs(destination, exist_ok=True)
77
+ elif member.issym():
78
+ links.append((destination, member.linkname, name))
79
+ elif member.isfile():
80
+ os.makedirs(os.path.dirname(destination), exist_ok=True)
81
+ source = archive.extractfile(member)
82
+ with open(destination, "wb") as out:
83
+ out.write(source.read() if source else b"")
84
+ os.chmod(destination, member.mode & 0o777)
85
+ for destination, link, name in links:
86
+ _assert_plain(root, destination, name)
87
+ os.makedirs(os.path.dirname(destination), exist_ok=True)
88
+ try:
89
+ os.symlink(link, destination)
90
+ except FileExistsError:
91
+ pass
92
+ for destination, link, name in links:
93
+ if _link_stays_inside(root, os.path.dirname(destination), link):
94
+ continue
95
+ try:
96
+ os.unlink(destination)
97
+ except OSError:
98
+ pass
99
+ raise _refuse(f"Refusing an archive link that leads outside the target: {name} -> {link}")
@@ -0,0 +1 @@
1
+ VERSION = "0.7.2"
@@ -51,6 +51,7 @@ class AsyncCommandHandle:
51
51
  self._stdout = ""
52
52
  self._stderr = ""
53
53
  self._exit: Optional[Dict[str, Any]] = None
54
+ self._truncated = False
54
55
  self._failure: Optional[BaseException] = None
55
56
  self._disconnected = False
56
57
  self._task = start(self._follow)
@@ -66,6 +67,8 @@ class AsyncCommandHandle:
66
67
  elif event["type"] == "stderr":
67
68
  self._stderr += event["data"]
68
69
  await call(self._on_stderr, event["data"])
70
+ elif event["type"] == "truncated":
71
+ self._truncated = True
69
72
  elif event["type"] == "exit":
70
73
  self._exit = event
71
74
  except Exception as error: # noqa: BLE001
@@ -110,7 +113,7 @@ class AsyncCommandHandle:
110
113
  "commands.connect(pid)." if self._disconnected
111
114
  else "The command's output ended without an exit.")
112
115
  return core.settle(self._exit.get("exitCode"), self._stdout, self._stderr,
113
- bool(self._exit.get("timedOut")), self._timeout_ms)
116
+ bool(self._exit.get("timedOut")), self._timeout_ms, self._truncated)
114
117
 
115
118
  async def disconnect(self) -> None:
116
119
  """Stops receiving output; the command keeps running."""
@@ -162,7 +165,8 @@ class AsyncCommands:
162
165
  result = await _guard("sandbox", lambda: self._sandbox.runtime.exec(
163
166
  cmd, cwd=cwd, env=self._env(envs), timeout_ms=timeout_ms,
164
167
  on_stdout=wrap(on_stdout) or core.whole_output, on_stderr=wrap(on_stderr)))
165
- return core.settle(result.exit_code, result.stdout, result.stderr, result.timed_out, timeout_ms)
168
+ return core.settle(result.exit_code, result.stdout, result.stderr, result.timed_out, timeout_ms,
169
+ result.stdout_truncated or result.stderr_truncated)
166
170
 
167
171
  async def _start(self, cmd: str, envs: Optional[Dict[str, str]], cwd: Optional[str], stdin: bool,
168
172
  timeout_ms: int, on_stdout: Any, on_stderr: Any) -> AsyncCommandHandle:
@@ -92,6 +92,10 @@ class CommandResult:
92
92
  stdout: str
93
93
  exit_code: int
94
94
  error: Optional[str]
95
+ #: Runtime's addition: some of the command's output was dropped before it
96
+ #: was read, so ``stdout`` and ``stderr`` are missing part of it. A warning
97
+ #: says so too.
98
+ truncated: bool = False
95
99
 
96
100
 
97
101
  @dataclass
@@ -323,21 +327,28 @@ def command_timeout_ms(timeout: Optional[float]) -> int:
323
327
  return min(int(math.ceil(timeout * 1000)), LONGEST_MS)
324
328
 
325
329
 
326
- def to_result(exit_code: Optional[int], stdout: str, stderr: str) -> CommandResult:
330
+ def to_result(exit_code: Optional[int], stdout: str, stderr: str, truncated: bool = False) -> CommandResult:
327
331
  code = -1 if exit_code is None else exit_code
328
332
  error = None if code == 0 else ("terminated by a signal" if exit_code is None else f"exit status {code}")
329
- return CommandResult(stderr=stderr, stdout=stdout, exit_code=code, error=error)
333
+ return CommandResult(stderr=stderr, stdout=stdout, exit_code=code, error=error, truncated=truncated)
330
334
 
331
335
 
332
- def settle(exit_code: Optional[int], stdout: str, stderr: str, timed_out: bool, timeout_ms: int) -> CommandResult:
336
+ def settle(exit_code: Optional[int], stdout: str, stderr: str, timed_out: bool, timeout_ms: int,
337
+ truncated: bool = False) -> CommandResult:
338
+ """E2B never loses output, so a result that did says so in ``truncated``
339
+ rather than passing as whole."""
333
340
  if timed_out:
334
341
  raise TimeoutException(
335
342
  f"Command timed out after {timeout_ms} ms: this error is likely due to exceeding 'timeout'. "
336
343
  "Pass a larger 'timeout', or 0 for no limit.")
337
- result = to_result(exit_code, stdout, stderr)
344
+ result = to_result(exit_code, stdout, stderr, truncated)
345
+ if truncated:
346
+ warnings.warn("Part of the command's output was dropped before it was read, so stdout and stderr are "
347
+ "incomplete (result.truncated). Write large output to a file and read it with "
348
+ "sandbox.files.read.", stacklevel=3)
338
349
  if result.exit_code != 0:
339
350
  raise CommandExitException(stderr=result.stderr, stdout=result.stdout, exit_code=result.exit_code,
340
- error=result.error)
351
+ error=result.error, truncated=result.truncated)
341
352
  return result
342
353
 
343
354
 
@@ -50,6 +50,7 @@ class CommandHandle:
50
50
  self._stdout = ""
51
51
  self._stderr = ""
52
52
  self._exit: Optional[Dict[str, Any]] = None
53
+ self._truncated = False
53
54
  self._failure: Optional[BaseException] = None
54
55
  self._disconnected = False
55
56
  self._task = start(self._follow)
@@ -65,6 +66,8 @@ class CommandHandle:
65
66
  elif event["type"] == "stderr":
66
67
  self._stderr += event["data"]
67
68
  call(self._on_stderr, event["data"])
69
+ elif event["type"] == "truncated":
70
+ self._truncated = True
68
71
  elif event["type"] == "exit":
69
72
  self._exit = event
70
73
  except Exception as error: # noqa: BLE001
@@ -109,7 +112,7 @@ class CommandHandle:
109
112
  "commands.connect(pid)." if self._disconnected
110
113
  else "The command's output ended without an exit.")
111
114
  return core.settle(self._exit.get("exitCode"), self._stdout, self._stderr,
112
- bool(self._exit.get("timedOut")), self._timeout_ms)
115
+ bool(self._exit.get("timedOut")), self._timeout_ms, self._truncated)
113
116
 
114
117
  def disconnect(self) -> None:
115
118
  """Stops receiving output; the command keeps running."""
@@ -161,7 +164,8 @@ class Commands:
161
164
  result = _guard("sandbox", lambda: self._sandbox.runtime.exec(
162
165
  cmd, cwd=cwd, env=self._env(envs), timeout_ms=timeout_ms,
163
166
  on_stdout=wrap(on_stdout) or core.whole_output, on_stderr=wrap(on_stderr)))
164
- return core.settle(result.exit_code, result.stdout, result.stderr, result.timed_out, timeout_ms)
167
+ return core.settle(result.exit_code, result.stdout, result.stderr, result.timed_out, timeout_ms,
168
+ result.stdout_truncated or result.stderr_truncated)
165
169
 
166
170
  def _start(self, cmd: str, envs: Optional[Dict[str, str]], cwd: Optional[str], stdin: bool,
167
171
  timeout_ms: int, on_stdout: Any, on_stderr: Any) -> CommandHandle:
@@ -55,6 +55,7 @@ except ImportError: # pragma: no cover - older Harbor
55
55
  from ._async_client import AsyncRuntime, AsyncSandbox
56
56
  from ._errors import NotFoundError
57
57
  from ._errors import RuntimeError as RuntimeCloudError
58
+ from ._unpack import unpack_archive
58
59
  from ._eval_sandbox import (_TRANSFER_TIMEOUT_S, MissingSudoError, _file_error, _missing_sudo, _Shell, _sizes,
59
60
  dockerfile_workdir, ensure_image, image_name, image_tag)
60
61
 
@@ -302,8 +303,7 @@ class RuntimeEnvironment(BaseEnvironment):
302
303
  await shell.remove(staging)
303
304
  target = Path(target_dir)
304
305
  target.mkdir(parents=True, exist_ok=True)
305
- with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as archive:
306
- archive.extractall(target, filter="data")
306
+ unpack_archive(data, str(target))
307
307
 
308
308
 
309
309
  __all__ = ["ENVIRONMENT_TYPE", "MissingSudoError", "RuntimeEnvironment"]
@@ -1,8 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withruntime
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: Runtime Cloud: one client (sync and async) for every Runtime product. Sandboxes first.
5
5
  License: Apache-2.0
6
+ Project-URL: Homepage, https://withruntime.com
7
+ Project-URL: Documentation, https://withruntime.com/docs/python
8
+ Project-URL: Source, https://github.com/withruntime/runtime/tree/main/sdks/python
9
+ Project-URL: Issues, https://github.com/withruntime/runtime/issues
10
+ Keywords: sandbox,ai-agents,agents,microvm,firecracker,code-execution,code-interpreter,mcp,llm
6
11
  Requires-Python: >=3.10
7
12
  Description-Content-Type: text/markdown
8
13
  License-File: LICENSE
@@ -36,6 +36,7 @@ tests/test_snapshot_after_fork.py
36
36
  tests/test_sso.py
37
37
  tests/test_switching.py
38
38
  tests/test_tools.py
39
+ tests/test_unpack.py
39
40
  tests/test_vercel.py
40
41
  tests/test_volume_backups.py
41
42
  tests/test_wake_awake.py
@@ -51,6 +52,7 @@ withruntime/_http.py
51
52
  withruntime/_proxy.py
52
53
  withruntime/_sync_client.py
53
54
  withruntime/_tunnel.py
55
+ withruntime/_unpack.py
54
56
  withruntime/_version.py
55
57
  withruntime/_ws.py
56
58
  withruntime/deepagents.py
@@ -1 +0,0 @@
1
- VERSION = "0.7.0"
File without changes
File without changes
File without changes