withruntime 0.7.1__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.1/withruntime.egg-info → withruntime-0.7.2}/PKG-INFO +1 -1
  2. {withruntime-0.7.1 → withruntime-0.7.2}/pyproject.toml +1 -1
  3. withruntime-0.7.2/tests/test_unpack.py +79 -0
  4. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_client.py +10 -15
  5. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_eval_sandbox.py +2 -1
  6. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_client.py +10 -15
  7. withruntime-0.7.2/withruntime/_unpack.py +99 -0
  8. withruntime-0.7.2/withruntime/_version.py +1 -0
  9. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/harbor.py +2 -2
  10. {withruntime-0.7.1 → withruntime-0.7.2/withruntime.egg-info}/PKG-INFO +1 -1
  11. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/SOURCES.txt +2 -0
  12. withruntime-0.7.1/withruntime/_version.py +0 -1
  13. {withruntime-0.7.1 → withruntime-0.7.2}/LICENSE +0 -0
  14. {withruntime-0.7.1 → withruntime-0.7.2}/README.md +0 -0
  15. {withruntime-0.7.1 → withruntime-0.7.2}/setup.cfg +0 -0
  16. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_account_errors.py +0 -0
  17. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_async_stream_cleanup.py +0 -0
  18. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_audit.py +0 -0
  19. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_billing.py +0 -0
  20. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_capacity_wait.py +0 -0
  21. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_client.py +0 -0
  22. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_daytona.py +0 -0
  23. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_deepagents.py +0 -0
  24. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_e2b_code_interpreter.py +0 -0
  25. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_e2b_sandbox.py +0 -0
  26. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_eval_sandbox.py +0 -0
  27. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_exec_stream.py +0 -0
  28. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_files_download.py +0 -0
  29. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_files_mode.py +0 -0
  30. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_harbor.py +0 -0
  31. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_identity_token.py +0 -0
  32. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_images_v2.py +0 -0
  33. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_images_volumes_interpreter.py +0 -0
  34. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_inspect_ai.py +0 -0
  35. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_late_answer.py +0 -0
  36. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_lifecycle_settings.py +0 -0
  37. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_limits.py +0 -0
  38. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_mounts.py +0 -0
  39. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_network_funding.py +0 -0
  40. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_network_products.py +0 -0
  41. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_observability.py +0 -0
  42. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_openai_agents.py +0 -0
  43. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_previews_network_desktop.py +0 -0
  44. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_proxy.py +0 -0
  45. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_referrals.py +0 -0
  46. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_secrets.py +0 -0
  47. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_snapshot_after_fork.py +0 -0
  48. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_sso.py +0 -0
  49. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_switching.py +0 -0
  50. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_tools.py +0 -0
  51. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_vercel.py +0 -0
  52. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_volume_backups.py +0 -0
  53. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_wake_awake.py +0 -0
  54. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_wallet.py +0 -0
  55. {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_watch.py +0 -0
  56. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/__init__.py +0 -0
  57. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/__init__.py +0 -0
  58. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/audit.py +0 -0
  59. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/billing.py +0 -0
  60. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/desktop.py +0 -0
  61. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/images.py +0 -0
  62. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/interpreter.py +0 -0
  63. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/limits.py +0 -0
  64. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/mcp.py +0 -0
  65. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/mounts.py +0 -0
  66. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/network.py +0 -0
  67. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/network_products.py +0 -0
  68. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/observability.py +0 -0
  69. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/previews.py +0 -0
  70. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/referrals.py +0 -0
  71. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/secrets.py +0 -0
  72. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/sso.py +0 -0
  73. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/switching.py +0 -0
  74. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/volumes.py +0 -0
  75. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/watch.py +0 -0
  76. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_clock.py +0 -0
  77. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_connection.py +0 -0
  78. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_errors.py +0 -0
  79. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_http.py +0 -0
  80. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_proxy.py +0 -0
  81. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/__init__.py +0 -0
  82. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/audit.py +0 -0
  83. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/billing.py +0 -0
  84. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/desktop.py +0 -0
  85. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/images.py +0 -0
  86. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/interpreter.py +0 -0
  87. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/limits.py +0 -0
  88. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/mcp.py +0 -0
  89. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/mounts.py +0 -0
  90. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/network.py +0 -0
  91. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/network_products.py +0 -0
  92. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/observability.py +0 -0
  93. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/previews.py +0 -0
  94. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/referrals.py +0 -0
  95. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/secrets.py +0 -0
  96. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/sso.py +0 -0
  97. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/switching.py +0 -0
  98. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/volumes.py +0 -0
  99. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/watch.py +0 -0
  100. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_tunnel.py +0 -0
  101. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_ws.py +0 -0
  102. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/__init__.py +0 -0
  103. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_async_daytona.py +0 -0
  104. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_async_io.py +0 -0
  105. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_core.py +0 -0
  106. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_sync_daytona.py +0 -0
  107. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_sync_io.py +0 -0
  108. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/deepagents.py +0 -0
  109. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/__init__.py +0 -0
  110. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_async_io.py +0 -0
  111. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_async_sandbox.py +0 -0
  112. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_core.py +0 -0
  113. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_sync_io.py +0 -0
  114. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_sync_sandbox.py +0 -0
  115. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/__init__.py +0 -0
  116. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
  117. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_models.py +0 -0
  118. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
  119. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/inspect_ai.py +0 -0
  120. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/openai_agents.py +0 -0
  121. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/tools.py +0 -0
  122. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/__init__.py +0 -0
  123. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_async_io.py +0 -0
  124. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_async_sandbox.py +0 -0
  125. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_core.py +0 -0
  126. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_sync_io.py +0 -0
  127. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_sync_sandbox.py +0 -0
  128. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/api.py +0 -0
  129. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/sandbox/__init__.py +0 -0
  130. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/sandbox/sync.py +0 -0
  131. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/wallet.py +0 -0
  132. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/webhooks.py +0 -0
  133. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/dependency_links.txt +0 -0
  134. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/entry_points.txt +0 -0
  135. {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/requires.txt +0 -0
  136. {withruntime-0.7.1 → withruntime-0.7.2}/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.7.1
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
6
  Project-URL: Homepage, https://withruntime.com
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "withruntime"
7
- version = "0.7.1"
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" }
@@ -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"
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withruntime
3
- Version: 0.7.1
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
6
  Project-URL: Homepage, https://withruntime.com
@@ -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.1"
File without changes
File without changes
File without changes