agentboxes 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentboxes
3
+ Version: 0.2.0
4
+ Summary: Python client for an agentbox server: sandboxes for AI agents on your own Linux box
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+
9
+ # agentbox (Python client)
10
+
11
+ Standard library only, Python 3.9+. Install with `pip install agentboxes` (the import is `agentbox`), or from a clone with `pip install -e sdk-python`.
12
+
13
+ ```python
14
+ from agentbox import Agentbox, ExecTimeout
15
+
16
+ client = Agentbox("http://127.0.0.1:7788", api_key=None)
17
+ box = client.create(allow_hosts=["pypi.org"], ttl_seconds=300)
18
+ result = box.exec(["python", "-c", "print(2+2)"]) # ExecResult(exit_code, stdout, stderr, duration_ms, truncated)
19
+ box.write_file("main.py", "print('hello')")
20
+ print(box.read_file("main.py"))
21
+ for event in box.exec_stream(["python", "main.py"]): # ("stdout" | "stderr", line) ... then ("exit", ExecResult)
22
+ print(event)
23
+ box.upload_directory("./myrepo", "repo")
24
+ box.download_directory("repo", "./out")
25
+ box.extend()
26
+ box.destroy()
27
+ ```
28
+
29
+ Errors: `SandboxNotFound`, `PoolExhausted`, `ExecTimeout` (with `.result`), and `AgentboxError` with `status` and `code` for everything else.
@@ -0,0 +1,21 @@
1
+ # agentbox (Python client)
2
+
3
+ Standard library only, Python 3.9+. Install with `pip install agentboxes` (the import is `agentbox`), or from a clone with `pip install -e sdk-python`.
4
+
5
+ ```python
6
+ from agentbox import Agentbox, ExecTimeout
7
+
8
+ client = Agentbox("http://127.0.0.1:7788", api_key=None)
9
+ box = client.create(allow_hosts=["pypi.org"], ttl_seconds=300)
10
+ result = box.exec(["python", "-c", "print(2+2)"]) # ExecResult(exit_code, stdout, stderr, duration_ms, truncated)
11
+ box.write_file("main.py", "print('hello')")
12
+ print(box.read_file("main.py"))
13
+ for event in box.exec_stream(["python", "main.py"]): # ("stdout" | "stderr", line) ... then ("exit", ExecResult)
14
+ print(event)
15
+ box.upload_directory("./myrepo", "repo")
16
+ box.download_directory("repo", "./out")
17
+ box.extend()
18
+ box.destroy()
19
+ ```
20
+
21
+ Errors: `SandboxNotFound`, `PoolExhausted`, `ExecTimeout` (with `.result`), and `AgentboxError` with `status` and `code` for everything else.
@@ -0,0 +1,237 @@
1
+ """Python client for an agentbox server. Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import tarfile
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
12
+ from urllib import error, request
13
+
14
+ __all__ = ["Agentbox", "Sandbox", "ExecResult", "AgentboxError", "SandboxNotFound", "PoolExhausted", "ExecTimeout"]
15
+
16
+
17
+ class AgentboxError(Exception):
18
+ """Any non-2xx answer from the server. `code` is the server's error name, for example ValidationError."""
19
+
20
+ def __init__(self, message: str, status: int, code: str):
21
+ super().__init__(message)
22
+ self.status = status
23
+ self.code = code
24
+
25
+
26
+ class SandboxNotFound(AgentboxError):
27
+ pass
28
+
29
+
30
+ class PoolExhausted(AgentboxError):
31
+ """The server's live sandbox limit, or this key's limit, is reached."""
32
+
33
+
34
+ @dataclass
35
+ class ExecResult:
36
+ exit_code: int
37
+ stdout: str
38
+ stderr: str
39
+ duration_ms: int
40
+ timed_out: bool
41
+ truncated: bool
42
+
43
+
44
+ class ExecTimeout(Exception):
45
+ """The per-exec timeout killed the command. `result` holds the output collected before that."""
46
+
47
+ def __init__(self, result: ExecResult):
48
+ super().__init__(f"command killed after {result.duration_ms} ms")
49
+ self.result = result
50
+
51
+
52
+ def _result(data: Dict[str, Any]) -> ExecResult:
53
+ return ExecResult(data["exitCode"], data["stdout"], data["stderr"], data["durationMs"], data["timedOut"], data["truncated"])
54
+
55
+
56
+ class Agentbox:
57
+ def __init__(self, base_url: str = "http://127.0.0.1:7788", api_key: Optional[str] = None, timeout: float = 600.0):
58
+ self.base_url = base_url.rstrip("/")
59
+ self.api_key = api_key
60
+ self.timeout = timeout
61
+
62
+ def create(
63
+ self,
64
+ template: Optional[str] = None,
65
+ allow_hosts: Optional[List[str]] = None,
66
+ secrets: Optional[Dict[str, Union[str, Dict[str, str]]]] = None,
67
+ memory_mb: Optional[int] = None,
68
+ cpus: Optional[float] = None,
69
+ disk_mb: Optional[int] = None,
70
+ pids: Optional[int] = None,
71
+ ttl_seconds: Optional[int] = None,
72
+ ) -> "Sandbox":
73
+ body = {
74
+ "template": template,
75
+ "allowHosts": allow_hosts,
76
+ "secrets": secrets,
77
+ "memoryMb": memory_mb,
78
+ "cpus": cpus,
79
+ "diskMb": disk_mb,
80
+ "pids": pids,
81
+ "ttlSeconds": ttl_seconds,
82
+ }
83
+ info = self._json("POST", "/sandboxes", {k: v for k, v in body.items() if v is not None})
84
+ return Sandbox(self, info)
85
+
86
+ def get(self, sandbox_id: str) -> "Sandbox":
87
+ return Sandbox(self, self._json("GET", f"/sandboxes/{sandbox_id}"))
88
+
89
+ def list(self, all: bool = False) -> List[Dict[str, Any]]:
90
+ return self._json("GET", "/sandboxes?all=true" if all else "/sandboxes")
91
+
92
+ def templates(self) -> List[Dict[str, Any]]:
93
+ return self._json("GET", "/templates")
94
+
95
+ def stats(self) -> Dict[str, Any]:
96
+ return self._json("GET", "/stats")
97
+
98
+ # Sends a request and raises a typed error for any non-2xx answer.
99
+ def request(self, method: str, path: str, body: Optional[bytes] = None, content_type: Optional[str] = None, stream: bool = False):
100
+ headers = {}
101
+ if self.api_key is not None:
102
+ headers["Authorization"] = f"Bearer {self.api_key}"
103
+ if content_type is not None:
104
+ headers["Content-Type"] = content_type
105
+ req = request.Request(self.base_url + path, data=body, method=method, headers=headers)
106
+ try:
107
+ response = request.urlopen(req, timeout=self.timeout)
108
+ except error.HTTPError as http_error:
109
+ code, message = "HttpError", f"{method} {path} failed with status {http_error.code}"
110
+ try:
111
+ payload = json.loads(http_error.read().decode("utf-8"))
112
+ code, message = payload.get("error", code), payload.get("message", message)
113
+ except (ValueError, UnicodeDecodeError):
114
+ pass
115
+ if http_error.code == 404:
116
+ raise SandboxNotFound(message, 404, code) from None
117
+ if http_error.code == 429:
118
+ raise PoolExhausted(message, 429, code) from None
119
+ raise AgentboxError(message, http_error.code, code) from None
120
+ if stream:
121
+ return response
122
+ with response:
123
+ return response.read()
124
+
125
+ def _json(self, method: str, path: str, body: Any = None) -> Any:
126
+ raw = self.request(method, path, None if body is None else json.dumps(body).encode("utf-8"), "application/json")
127
+ return json.loads(raw.decode("utf-8")) if raw else None
128
+
129
+
130
+ Event = Union[Tuple[str, str], Tuple[str, ExecResult]]
131
+
132
+
133
+ class Sandbox:
134
+ def __init__(self, client: Agentbox, info: Dict[str, Any]):
135
+ self.client = client
136
+ self.info = info
137
+
138
+ @property
139
+ def id(self) -> str:
140
+ return self.info["id"]
141
+
142
+ def _exec_body(self, cmd: List[str], timeout_ms: Optional[int], cwd: Optional[str], env: Optional[Dict[str, str]]) -> Dict[str, Any]:
143
+ body: Dict[str, Any] = {"cmd": cmd}
144
+ if timeout_ms is not None:
145
+ body["timeoutMs"] = timeout_ms
146
+ if cwd is not None:
147
+ body["cwd"] = cwd
148
+ if env is not None:
149
+ body["env"] = env
150
+ return body
151
+
152
+ # Runs a command and waits for it. Raises ExecTimeout when the server killed it at the deadline.
153
+ def exec(self, cmd: List[str], timeout_ms: Optional[int] = None, cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> ExecResult:
154
+ result = _result(self.client._json("POST", f"/sandboxes/{self.id}/exec", self._exec_body(cmd, timeout_ms, cwd, env)))
155
+ if result.timed_out:
156
+ raise ExecTimeout(result)
157
+ return result
158
+
159
+ # Yields ("stdout" | "stderr", line) as the command runs, then ("exit", ExecResult) with empty output fields.
160
+ def exec_stream(self, cmd: List[str], timeout_ms: Optional[int] = None, cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> Iterator[Event]:
161
+ body = json.dumps(self._exec_body(cmd, timeout_ms, cwd, env)).encode("utf-8")
162
+ response = self.client.request("POST", f"/sandboxes/{self.id}/exec/stream", body, "application/json", stream=True)
163
+ pending = {"stdout": "", "stderr": ""}
164
+ with response:
165
+ for event, data in _sse(response):
166
+ if event in pending:
167
+ pending[event] += data
168
+ while "\n" in pending[event]:
169
+ line, pending[event] = pending[event].split("\n", 1)
170
+ yield (event, line + "\n")
171
+ elif event == "exit":
172
+ for name in ("stdout", "stderr"):
173
+ if pending[name]:
174
+ yield (name, pending[name])
175
+ yield ("exit", ExecResult(data["exitCode"], "", "", data["durationMs"], data["timedOut"], data["truncated"]))
176
+ elif event == "error":
177
+ raise AgentboxError(data["message"], 0, data["error"])
178
+
179
+ def write_file(self, path: str, content: Union[str, bytes]) -> None:
180
+ data = content.encode("utf-8") if isinstance(content, str) else content
181
+ self.client.request("PUT", f"/sandboxes/{self.id}/files?path={request.quote(path, safe='')}", data, "application/octet-stream")
182
+
183
+ def read_file(self, path: str) -> str:
184
+ return self.read_file_bytes(path).decode("utf-8")
185
+
186
+ def read_file_bytes(self, path: str) -> bytes:
187
+ return self.client.request("GET", f"/sandboxes/{self.id}/files?path={request.quote(path, safe='')}")
188
+
189
+ def upload_tar(self, tar: bytes, directory: str = "/workspace") -> None:
190
+ self.client.request("PUT", f"/sandboxes/{self.id}/archive?path={request.quote(directory, safe='')}", tar, "application/x-tar")
191
+
192
+ def download_tar(self, directory: str = "/workspace") -> bytes:
193
+ return self.client.request("GET", f"/sandboxes/{self.id}/archive?path={request.quote(directory, safe='')}")
194
+
195
+ # Sends a local directory's contents into the sandbox.
196
+ def upload_directory(self, local_dir: str, directory: str = "/workspace") -> None:
197
+ buffer = io.BytesIO()
198
+ with tarfile.open(fileobj=buffer, mode="w") as archive:
199
+ for name in sorted(os.listdir(local_dir)):
200
+ archive.add(os.path.join(local_dir, name), arcname=name)
201
+ self.upload_tar(buffer.getvalue(), directory)
202
+
203
+ # Writes a sandbox directory's contents into a local directory, refusing members that would leave it.
204
+ def download_directory(self, directory: str, local_dir: str) -> None:
205
+ with tarfile.open(fileobj=io.BytesIO(self.download_tar(directory)), mode="r") as archive:
206
+ for member in archive.getmembers():
207
+ if member.name.startswith("/") or ".." in member.name.split("/"):
208
+ raise AgentboxError(f"archive from the sandbox has an unsafe path: {member.name}", 0, "UnsafeArchive")
209
+ archive.extractall(local_dir)
210
+
211
+ def egress_log(self) -> Dict[str, Any]:
212
+ return self.client._json("GET", f"/sandboxes/{self.id}/egress-log")
213
+
214
+ def extend(self, ttl_seconds: Optional[int] = None) -> Dict[str, Any]:
215
+ self.info = self.client._json("POST", f"/sandboxes/{self.id}/extend", {} if ttl_seconds is None else {"ttlSeconds": ttl_seconds})
216
+ return self.info
217
+
218
+ def refresh(self) -> Dict[str, Any]:
219
+ self.info = self.client._json("GET", f"/sandboxes/{self.id}")
220
+ return self.info
221
+
222
+ def destroy(self) -> None:
223
+ self.info = self.client._json("DELETE", f"/sandboxes/{self.id}")
224
+
225
+
226
+ # Parses a Server-Sent Events body into (event, data) pairs, with data JSON-decoded.
227
+ def _sse(response) -> Iterator[Tuple[str, Any]]:
228
+ event, data = None, None
229
+ for raw in response:
230
+ line = raw.decode("utf-8").rstrip("\n")
231
+ if line.startswith("event: "):
232
+ event = line[7:]
233
+ elif line.startswith("data: "):
234
+ data = line[6:]
235
+ elif line == "" and event is not None and data is not None:
236
+ yield event, json.loads(data)
237
+ event, data = None, None
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentboxes
3
+ Version: 0.2.0
4
+ Summary: Python client for an agentbox server: sandboxes for AI agents on your own Linux box
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+
9
+ # agentbox (Python client)
10
+
11
+ Standard library only, Python 3.9+. Install with `pip install agentboxes` (the import is `agentbox`), or from a clone with `pip install -e sdk-python`.
12
+
13
+ ```python
14
+ from agentbox import Agentbox, ExecTimeout
15
+
16
+ client = Agentbox("http://127.0.0.1:7788", api_key=None)
17
+ box = client.create(allow_hosts=["pypi.org"], ttl_seconds=300)
18
+ result = box.exec(["python", "-c", "print(2+2)"]) # ExecResult(exit_code, stdout, stderr, duration_ms, truncated)
19
+ box.write_file("main.py", "print('hello')")
20
+ print(box.read_file("main.py"))
21
+ for event in box.exec_stream(["python", "main.py"]): # ("stdout" | "stderr", line) ... then ("exit", ExecResult)
22
+ print(event)
23
+ box.upload_directory("./myrepo", "repo")
24
+ box.download_directory("repo", "./out")
25
+ box.extend()
26
+ box.destroy()
27
+ ```
28
+
29
+ Errors: `SandboxNotFound`, `PoolExhausted`, `ExecTimeout` (with `.result`), and `AgentboxError` with `status` and `code` for everything else.
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ agentbox/__init__.py
4
+ agentboxes.egg-info/PKG-INFO
5
+ agentboxes.egg-info/SOURCES.txt
6
+ agentboxes.egg-info/dependency_links.txt
7
+ agentboxes.egg-info/top_level.txt
8
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ agentbox
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "agentboxes"
3
+ version = "0.2.0"
4
+ description = "Python client for an agentbox server: sandboxes for AI agents on your own Linux box"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ dependencies = []
9
+
10
+ [build-system]
11
+ requires = ["setuptools>=68"]
12
+ build-backend = "setuptools.build_meta"
13
+
14
+ [tool.setuptools.packages.find]
15
+ include = ["agentbox*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,98 @@
1
+ """Drives the Python client against the HTTP app with the fake runtime, started from test/support/fakeServer.ts."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
10
+ sys.path.insert(0, os.path.join(ROOT, "sdk-python"))
11
+
12
+ from agentbox import Agentbox, AgentboxError, ExecTimeout, PoolExhausted, SandboxNotFound # noqa: E402
13
+
14
+
15
+ class ClientTest(unittest.TestCase):
16
+ @classmethod
17
+ def setUpClass(cls):
18
+ cls.server = subprocess.Popen(
19
+ [os.path.join(ROOT, "node_modules", ".bin", "tsx"), os.path.join(ROOT, "test", "support", "fakeServer.ts")],
20
+ cwd=ROOT,
21
+ stdin=subprocess.PIPE,
22
+ stdout=subprocess.PIPE,
23
+ env={**os.environ, "FAKE_API_KEY": "k1", "MAX_SANDBOXES": "2"},
24
+ )
25
+ port = cls.server.stdout.readline().decode().strip()
26
+ cls.client = Agentbox(f"http://127.0.0.1:{port}", api_key="k1")
27
+
28
+ @classmethod
29
+ def tearDownClass(cls):
30
+ cls.server.stdin.close()
31
+ cls.server.wait(timeout=10)
32
+ cls.server.stdout.close()
33
+
34
+ def test_lifecycle_files_stream_and_destroy(self):
35
+ box = self.client.create(ttl_seconds=60)
36
+ self.assertEqual(box.info["limits"]["ttlSeconds"], 60)
37
+ result = box.exec(["python", "-c", "print(2+2)"], timeout_ms=1000, cwd="src", env={"CI": "1"})
38
+ self.assertEqual(result.stdout, "python -c print(2+2)") # the fake runtime echoes the command
39
+ box.write_file("src/main.py", "print('hi')")
40
+ self.assertEqual(box.read_file("src/main.py"), "print('hi')")
41
+ box.write_file("bin", b"\x00\xff")
42
+ self.assertEqual(box.read_file_bytes("/workspace/bin"), b"\x00\xff")
43
+ events = list(box.exec_stream(["echo", "hi"]))
44
+ self.assertEqual(events[0], ("stdout", "echo hi"))
45
+ self.assertEqual(events[-1][0], "exit")
46
+ self.assertEqual(events[-1][1].exit_code, 0)
47
+ before = box.info["expiresAt"]
48
+ box.extend(1800)
49
+ self.assertGreater(box.info["expiresAt"], before)
50
+ box.upload_tar(b"tar!", "incoming")
51
+ self.assertEqual(box.download_tar("incoming"), b"tar!")
52
+ self.assertEqual(len(self.client.list()), 1)
53
+ box.destroy()
54
+ self.assertEqual(box.info["status"], "destroyed")
55
+ self.assertEqual(self.client.list(), [])
56
+ self.assertEqual(self.client.templates()[0]["name"], "default")
57
+ self.assertEqual(self.client.stats()["sandboxes"]["live"], 0)
58
+
59
+ def test_directories_round_trip_through_real_tar_files(self):
60
+ box = self.client.create()
61
+ try:
62
+ with tempfile.TemporaryDirectory() as src, tempfile.TemporaryDirectory() as out:
63
+ os.makedirs(os.path.join(src, "pkg"))
64
+ with open(os.path.join(src, "pkg", "a.txt"), "w") as f:
65
+ f.write("alpha")
66
+ box.upload_directory(src, "proj")
67
+ box.download_directory("proj", out) # the fake runtime hands the same archive back
68
+ with open(os.path.join(out, "pkg", "a.txt")) as f:
69
+ self.assertEqual(f.read(), "alpha")
70
+ finally:
71
+ box.destroy()
72
+
73
+ def test_typed_errors(self):
74
+ with self.assertRaises(SandboxNotFound):
75
+ self.client.get("sbx_nope")
76
+ a = self.client.create()
77
+ b = self.client.create()
78
+ with self.assertRaises(PoolExhausted):
79
+ self.client.create()
80
+ b.destroy()
81
+ with self.assertRaises(AgentboxError) as caught:
82
+ a.exec(["true"], timeout_ms=10**9)
83
+ self.assertEqual(caught.exception.code, "ValidationError")
84
+ self.assertEqual(caught.exception.status, 400)
85
+ with self.assertRaises(AgentboxError) as caught:
86
+ a.write_file("../x", "y")
87
+ self.assertEqual(caught.exception.code, "ValidationError")
88
+ a.destroy()
89
+ with self.assertRaises(AgentboxError) as caught:
90
+ a.exec(["true"])
91
+ self.assertEqual(caught.exception.status, 409)
92
+ with self.assertRaises(AgentboxError) as caught:
93
+ Agentbox(self.client.base_url, api_key="wrong").list()
94
+ self.assertEqual(caught.exception.status, 401)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()