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.
- {withruntime-0.7.1/withruntime.egg-info → withruntime-0.7.2}/PKG-INFO +1 -1
- {withruntime-0.7.1 → withruntime-0.7.2}/pyproject.toml +1 -1
- withruntime-0.7.2/tests/test_unpack.py +79 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_client.py +10 -15
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_eval_sandbox.py +2 -1
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_client.py +10 -15
- withruntime-0.7.2/withruntime/_unpack.py +99 -0
- withruntime-0.7.2/withruntime/_version.py +1 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/harbor.py +2 -2
- {withruntime-0.7.1 → withruntime-0.7.2/withruntime.egg-info}/PKG-INFO +1 -1
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/SOURCES.txt +2 -0
- withruntime-0.7.1/withruntime/_version.py +0 -1
- {withruntime-0.7.1 → withruntime-0.7.2}/LICENSE +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/README.md +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/setup.cfg +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_account_errors.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_async_stream_cleanup.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_audit.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_billing.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_capacity_wait.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_client.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_daytona.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_deepagents.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_e2b_code_interpreter.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_e2b_sandbox.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_eval_sandbox.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_exec_stream.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_files_download.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_files_mode.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_harbor.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_identity_token.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_images_v2.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_images_volumes_interpreter.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_inspect_ai.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_late_answer.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_lifecycle_settings.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_limits.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_mounts.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_network_funding.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_network_products.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_observability.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_openai_agents.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_previews_network_desktop.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_proxy.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_referrals.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_secrets.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_snapshot_after_fork.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_sso.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_switching.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_tools.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_vercel.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_volume_backups.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_wake_awake.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_wallet.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/tests/test_watch.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/audit.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/billing.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/desktop.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/images.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/interpreter.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/limits.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/mcp.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/mounts.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/network.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/network_products.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/observability.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/previews.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/referrals.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/secrets.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/sso.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/switching.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/volumes.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_async_products/watch.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_clock.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_connection.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_errors.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_http.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_proxy.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/audit.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/billing.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/desktop.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/images.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/interpreter.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/limits.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/mcp.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/mounts.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/network.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/network_products.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/observability.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/previews.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/referrals.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/secrets.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/sso.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/switching.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/volumes.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_sync_products/watch.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_tunnel.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/_ws.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_async_daytona.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_async_io.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_core.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_sync_daytona.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/daytona/_sync_io.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/deepagents.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_async_io.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_async_sandbox.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_core.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_sync_io.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/_sync_sandbox.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_async_ci.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_models.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/e2b/code_interpreter/_sync_ci.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/inspect_ai.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/openai_agents.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/tools.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_async_io.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_async_sandbox.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_core.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_sync_io.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/_sync_sandbox.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/api.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/sandbox/__init__.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/vercel/sandbox/sync.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/wallet.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime/webhooks.py +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/dependency_links.txt +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/entry_points.txt +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/requires.txt +0 -0
- {withruntime-0.7.1 → withruntime-0.7.2}/withruntime.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "withruntime"
|
|
7
|
-
version = "0.7.
|
|
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
|
|
641
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
640
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
306
|
-
archive.extractall(target, filter="data")
|
|
306
|
+
unpack_archive(data, str(target))
|
|
307
307
|
|
|
308
308
|
|
|
309
309
|
__all__ = ["ENVIRONMENT_TYPE", "MissingSudoError", "RuntimeEnvironment"]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|