agentix-runtime-basic 0.1.1__py3-none-any.whl

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,260 @@
1
+ """Bash primitive — shell command execution as an Agentix namespace.
2
+
3
+ Usage:
4
+
5
+ from agentix import RuntimeClient
6
+ from agentix import bash
7
+ from agentix.bash import BashStdout, BashStderr, BashExit, BashError
8
+
9
+ async with RuntimeClient(sandbox.runtime_url) as c:
10
+ r = await c.remote(bash.run, command="ls -la", cwd="/workspace")
11
+ print(r.exit_code, r.stdout)
12
+
13
+ async for ev in c.remote(bash.run_stream, command="long-job.sh"):
14
+ match ev:
15
+ case BashStdout(data=chunk): print(chunk, end="")
16
+ case BashStderr(data=chunk): print(chunk, end="")
17
+ case BashExit(exit_code=code): print(f"\\nexit {code}")
18
+ case BashError(message=msg): print(f"\\nerror: {msg}")
19
+
20
+ The package IS the namespace — `run` and `run_stream` are top-level
21
+ async functions, dataclasses (`BashResult`, `BashStdout`, …) coexist
22
+ as types callers can import. The framework's discovery picks the async
23
+ functions; types and constants are just regular Python imports.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import os
30
+ from collections.abc import AsyncIterator
31
+ from dataclasses import dataclass
32
+ from typing import Annotated, Literal
33
+
34
+ from pydantic import Field
35
+
36
+ # Env vars stripped before forking a user-space subprocess. The runtime
37
+ # is a Nix-built binary; os.environ is pre-loaded with Nix runtime paths
38
+ # (LD_LIBRARY_PATH pointing at Nix-store libs, NIX_*, PYTHONPATH,
39
+ # FONTCONFIG_*). Leaking those into a host-image subprocess causes glibc
40
+ # ABI mismatches and silent library override bugs.
41
+ _RUNTIME_ONLY_ENV = {
42
+ "LD_LIBRARY_PATH",
43
+ "LD_PRELOAD",
44
+ "PYTHONPATH",
45
+ "PYTHONHOME",
46
+ "LOCALE_ARCHIVE",
47
+ "FONTCONFIG_FILE",
48
+ "FONTCONFIG_PATH",
49
+ "SSL_CERT_FILE",
50
+ "NIX_SSL_CERT_FILE",
51
+ }
52
+
53
+
54
+ def _clean_env(extra: dict[str, str] | None) -> dict[str, str]:
55
+ """Build a subprocess env: scrubbed base + caller overrides."""
56
+ env = {
57
+ k: v
58
+ for k, v in os.environ.items()
59
+ if k not in _RUNTIME_ONLY_ENV and not k.startswith("NIX_")
60
+ }
61
+ if extra:
62
+ env.update(extra)
63
+ return env
64
+
65
+
66
+ async def _read_capped(stream: asyncio.StreamReader, limit: int) -> str:
67
+ """Drain a subprocess stream, retaining at most `limit` bytes.
68
+
69
+ The drain-to-EOF part matters: if one pipe stops being read after its cap
70
+ is reached, the child can block forever writing to that full pipe while the
71
+ parent waits on the process.
72
+ """
73
+ chunks: list[bytes] = []
74
+ total = 0
75
+ truncated = False
76
+ while True:
77
+ chunk = await stream.read(8192)
78
+ if not chunk:
79
+ break
80
+ remaining = limit - total
81
+ if remaining <= 0:
82
+ truncated = True
83
+ continue
84
+ if len(chunk) >= remaining:
85
+ chunks.append(chunk[:remaining])
86
+ truncated = len(chunk) > remaining
87
+ total = limit
88
+ continue
89
+ chunks.append(chunk)
90
+ total += len(chunk)
91
+ if truncated:
92
+ chunks.append(b"\n[truncated at %d bytes]" % limit)
93
+ return b"".join(chunks).decode(errors="replace")
94
+
95
+
96
+ @dataclass
97
+ class BashResult:
98
+ """Return value of `Bash.run` — full output captured before the call returns."""
99
+
100
+ exit_code: int
101
+ stdout: str
102
+ stderr: str
103
+
104
+
105
+ # Algebraic stream events — each variant is its own dataclass so callers
106
+ # can `match event: case BashStdout(...)` and pyright tracks the type.
107
+ # The `type` field is the wire discriminator; users pattern-match the
108
+ # class, not the field.
109
+
110
+
111
+ @dataclass
112
+ class BashStdout:
113
+ """A chunk of subprocess stdout."""
114
+
115
+ data: str
116
+ type: Literal["stdout"] = "stdout"
117
+
118
+
119
+ @dataclass
120
+ class BashStderr:
121
+ """A chunk of subprocess stderr."""
122
+
123
+ data: str
124
+ type: Literal["stderr"] = "stderr"
125
+
126
+
127
+ @dataclass
128
+ class BashExit:
129
+ """The subprocess finished. `exit_code` is its return status."""
130
+
131
+ exit_code: int
132
+ type: Literal["exit"] = "exit"
133
+
134
+
135
+ @dataclass
136
+ class BashError:
137
+ """Wire-side problem (e.g. timeout, fork failure). `message` explains."""
138
+
139
+ message: str
140
+ type: Literal["error"] = "error"
141
+
142
+
143
+ BashEvent = Annotated[
144
+ BashStdout | BashStderr | BashExit | BashError,
145
+ Field(discriminator="type"),
146
+ ]
147
+ """One event from `Bash.run_stream`. Discriminated union of the four
148
+ variants above — JSON wire form carries a `type` tag, but in Python
149
+ the user pattern-matches the class directly."""
150
+
151
+
152
+ async def run(
153
+ command: str,
154
+ cwd: str | None = None,
155
+ env: dict[str, str] | None = None,
156
+ timeout: float | None = None,
157
+ max_output: int = 10 * 1024 * 1024,
158
+ ) -> BashResult:
159
+ """Run a shell command in the sandbox and return its captured output."""
160
+ sub_env = _clean_env(env)
161
+ proc = await asyncio.create_subprocess_shell(
162
+ command,
163
+ stdout=asyncio.subprocess.PIPE,
164
+ stderr=asyncio.subprocess.PIPE,
165
+ cwd=cwd,
166
+ env=sub_env,
167
+ )
168
+ assert proc.stdout is not None and proc.stderr is not None
169
+ stdout_task = asyncio.create_task(_read_capped(proc.stdout, max_output))
170
+ stderr_task = asyncio.create_task(_read_capped(proc.stderr, max_output))
171
+ wait_task = asyncio.create_task(proc.wait())
172
+ try:
173
+ await asyncio.wait_for(
174
+ asyncio.gather(stdout_task, stderr_task, wait_task),
175
+ timeout=timeout,
176
+ )
177
+ except TimeoutError:
178
+ proc.kill()
179
+ await proc.wait()
180
+ for task in (stdout_task, stderr_task):
181
+ task.cancel()
182
+ await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
183
+ return BashResult(
184
+ exit_code=-1, stdout="", stderr=f"Command timed out after {timeout}s",
185
+ )
186
+ stdout = stdout_task.result()
187
+ stderr = stderr_task.result()
188
+ return BashResult(exit_code=proc.returncode or 0, stdout=stdout, stderr=stderr)
189
+
190
+
191
+ async def run_stream(
192
+ command: str,
193
+ cwd: str | None = None,
194
+ env: dict[str, str] | None = None,
195
+ timeout: float | None = None,
196
+ ) -> AsyncIterator[BashEvent]:
197
+ """Run a shell command, yielding events as the subprocess emits them.
198
+
199
+ Terminates with a single `BashExit` event on normal completion or
200
+ a single `BashError` event on timeout / wire-level failure.
201
+ """
202
+ sub_env = _clean_env(env)
203
+ proc = await asyncio.create_subprocess_shell(
204
+ command,
205
+ stdout=asyncio.subprocess.PIPE,
206
+ stderr=asyncio.subprocess.PIPE,
207
+ cwd=cwd,
208
+ env=sub_env,
209
+ )
210
+
211
+ async def _pump(stream, tag, queue):
212
+ while True:
213
+ chunk = await stream.read(4096)
214
+ if not chunk:
215
+ break
216
+ await queue.put((tag, chunk))
217
+ await queue.put((tag, None))
218
+
219
+ queue: asyncio.Queue = asyncio.Queue()
220
+ tasks = [
221
+ asyncio.create_task(_pump(proc.stdout, "stdout", queue)),
222
+ asyncio.create_task(_pump(proc.stderr, "stderr", queue)),
223
+ ]
224
+ open_streams = {"stdout", "stderr"}
225
+
226
+ try:
227
+ deadline = None
228
+ if timeout is not None:
229
+ deadline = asyncio.get_event_loop().time() + timeout
230
+ while open_streams:
231
+ remaining = None
232
+ if deadline is not None:
233
+ remaining = max(deadline - asyncio.get_event_loop().time(), 0)
234
+ if remaining == 0:
235
+ proc.kill()
236
+ yield BashError(message=f"Command timed out after {timeout}s")
237
+ return
238
+ try:
239
+ tag, chunk = await asyncio.wait_for(queue.get(), timeout=remaining)
240
+ except TimeoutError:
241
+ proc.kill()
242
+ yield BashError(message=f"Command timed out after {timeout}s")
243
+ return
244
+ if chunk is None:
245
+ open_streams.discard(tag)
246
+ continue
247
+ text = chunk.decode(errors="replace")
248
+ if tag == "stdout":
249
+ yield BashStdout(data=text)
250
+ else:
251
+ yield BashStderr(data=text)
252
+ await proc.wait()
253
+ yield BashExit(exit_code=proc.returncode or 0)
254
+ finally:
255
+ for t in tasks:
256
+ t.cancel()
257
+ await asyncio.gather(*tasks, return_exceptions=True)
258
+ if proc.returncode is None:
259
+ proc.kill()
260
+ await proc.wait()
@@ -0,0 +1,145 @@
1
+ """Files primitive — sandbox file upload / download as an Agentix namespace.
2
+
3
+ Usage:
4
+
5
+ from agentix import RuntimeClient
6
+ from agentix import files
7
+
8
+ async with RuntimeClient(sandbox.runtime_url) as c:
9
+ r = await c.remote(files.upload, path="/workspace/input.txt", content=b"hello")
10
+ print(r.size)
11
+
12
+ data = await c.remote(files.download, path="/workspace/output.txt")
13
+
14
+ Files are encoded as pydantic `bytes` (base64 in the JSON wire form).
15
+ Suitable for kB–MB sized files; very large blobs should ship via a
16
+ purpose-built binary `WirePattern` rather than the unary JSON path.
17
+
18
+ The package IS the namespace — `upload` and `download` are top-level
19
+ async functions, `UploadResult` is a regular dataclass callers can
20
+ import for type hints.
21
+
22
+ Writes/reads are confined to `$AGENTIX_UPLOAD_ROOT` (default
23
+ `/workspace`). Paths outside that root raise `PermissionError`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import errno
29
+ import os
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ UPLOAD_ROOT = Path(os.environ.get("AGENTIX_UPLOAD_ROOT", "/workspace")).resolve()
34
+
35
+
36
+ @dataclass
37
+ class UploadResult:
38
+ """What `upload` returns — resolved sandbox-side path + bytes written."""
39
+
40
+ path: str
41
+ size: int
42
+
43
+
44
+ def _resolve_within(path: str) -> Path:
45
+ """Return `path` lexically under `UPLOAD_ROOT`.
46
+
47
+ Actual open/read/write calls below walk from an already-open root
48
+ directory fd with O_NOFOLLOW, so symlink swaps cannot redirect the
49
+ final file operation outside the upload root.
50
+ """
51
+ return UPLOAD_ROOT / Path(*_relative_parts(path))
52
+
53
+
54
+ def _relative_parts(path: str) -> tuple[str, ...]:
55
+ raw = os.path.normpath(path)
56
+ if os.path.isabs(raw):
57
+ root = os.fspath(UPLOAD_ROOT)
58
+ try:
59
+ if os.path.commonpath([root, raw]) != root:
60
+ raise PermissionError(f"Path {raw} outside allowed root {UPLOAD_ROOT}")
61
+ except ValueError as exc:
62
+ raise PermissionError(f"Path {raw} outside allowed root {UPLOAD_ROOT}") from exc
63
+ raw = os.path.relpath(raw, root)
64
+ parts = tuple(p for p in Path(raw).parts if p not in ("", "."))
65
+ if not parts or any(p == ".." for p in parts):
66
+ raise PermissionError(f"Path {path!r} outside allowed root {UPLOAD_ROOT}")
67
+ return parts
68
+
69
+
70
+ def _open_parent(parts: tuple[str, ...], *, create: bool) -> tuple[int, str]:
71
+ """Open the parent directory under UPLOAD_ROOT without following symlinks.
72
+
73
+ Returns `(parent_fd, filename)`. The caller owns `parent_fd`.
74
+ """
75
+ root_fd = os.open(UPLOAD_ROOT, os.O_RDONLY | os.O_DIRECTORY)
76
+ fd = root_fd
77
+ try:
78
+ for part in parts[:-1]:
79
+ if create:
80
+ try:
81
+ os.mkdir(part, mode=0o777, dir_fd=fd)
82
+ except FileExistsError:
83
+ pass
84
+ try:
85
+ next_fd = os.open(
86
+ part,
87
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
88
+ dir_fd=fd,
89
+ )
90
+ except OSError as exc:
91
+ if exc.errno == errno.ELOOP:
92
+ raise PermissionError(
93
+ f"Refusing to follow symlink inside {UPLOAD_ROOT}"
94
+ ) from exc
95
+ raise
96
+ os.close(fd)
97
+ fd = next_fd
98
+ return fd, parts[-1]
99
+ except Exception:
100
+ os.close(fd)
101
+ raise
102
+
103
+
104
+ def _open_file(path: str, flags: int, *, create_parents: bool, mode: int = 0o666) -> tuple[int, Path]:
105
+ parts = _relative_parts(path)
106
+ parent_fd, name = _open_parent(parts, create=create_parents)
107
+ try:
108
+ try:
109
+ fd = os.open(name, flags | os.O_NOFOLLOW, mode, dir_fd=parent_fd)
110
+ except OSError as exc:
111
+ if exc.errno == errno.ELOOP:
112
+ raise PermissionError(
113
+ f"Refusing to follow symlink inside {UPLOAD_ROOT}"
114
+ ) from exc
115
+ raise
116
+ return fd, _resolve_within(path)
117
+ finally:
118
+ os.close(parent_fd)
119
+
120
+
121
+ async def upload(path: str, content: bytes) -> UploadResult:
122
+ """Write `content` to `path` inside the sandbox.
123
+
124
+ Creates parent directories as needed. `path` must resolve under
125
+ the upload-root; otherwise raises `PermissionError`.
126
+ """
127
+ fd, p = _open_file(
128
+ path,
129
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
130
+ create_parents=True,
131
+ )
132
+ with os.fdopen(fd, "wb") as f:
133
+ f.write(content)
134
+ return UploadResult(path=str(p), size=len(content))
135
+
136
+
137
+ async def download(path: str) -> bytes:
138
+ """Read the contents of `path` from inside the sandbox.
139
+
140
+ Raises `FileNotFoundError` / `IsADirectoryError` /
141
+ `PermissionError` for the corresponding filesystem conditions.
142
+ """
143
+ fd, _ = _open_file(path, os.O_RDONLY, create_parents=False)
144
+ with os.fdopen(fd, "rb") as f:
145
+ return f.read()
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentix-runtime-basic
3
+ Version: 0.1.1
4
+ Summary: Shell + file I/O primitives for Agentix sandboxes
5
+ Project-URL: Homepage, https://github.com/Agentiix/Agentix-Runtime-Basic
6
+ Author: Agentiix
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: agentixx>=0.1.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pyright>=1.1.380; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
14
+ Requires-Dist: pytest>=8; extra == 'dev'
15
+ Requires-Dist: ruff>=0.6; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # agentix-runtime-basic
19
+
20
+ Shell + file I/O primitives for [Agentix](https://github.com/Agentiix/Agentix)
21
+ sandboxes. One wheel, two namespaces:
22
+
23
+ | Namespace | Purpose |
24
+ |---|---|
25
+ | `agentix.bash` | execute shell commands, stream stdout/stderr |
26
+ | `agentix.files` | upload/download/list files inside the sandbox |
27
+
28
+ These two used to ship as separate `agentix-bash` and `agentix-files`
29
+ distributions. They consolidated here because every realistic sandbox
30
+ image needs both — splitting them was friction without isolation
31
+ benefit (neither has any non-stdlib runtime deps).
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install agentix-runtime-basic
37
+ ```
38
+
39
+ ## Use
40
+
41
+ ```python
42
+ from agentix import RuntimeClient
43
+ from agentix.bash import run as bash_run, run_stream as bash_stream
44
+ from agentix.files import upload, download
45
+
46
+ async with RuntimeClient(sandbox.runtime_url) as c:
47
+ await c.remote(upload, path="data.json", content=blob)
48
+ result = await c.remote(bash_run, command="cat data.json | jq .")
49
+ async for ev in c.remote(bash_stream, command="pytest -q"):
50
+ ... # ev is a BashStdout / BashStderr / BashExit / BashError
51
+ ```
52
+
53
+ Each namespace is "the package IS the namespace": top-level async
54
+ functions are the remote-callable surface, dataclasses/types like
55
+ `BashResult` and `UploadResult` are importable for return-type
56
+ annotations.
57
+
58
+ ## Building a sandbox image
59
+
60
+ `runtime/Dockerfile` is the base image bundle builds extend from. Most
61
+ users invoke it indirectly:
62
+
63
+ ```bash
64
+ agentix build runtime-basic -o my-agent:0.1.0
65
+ ```
66
+
67
+ ## License
68
+
69
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ agentix/bash/__init__.py,sha256=n39E5fT_ObemG5zYUOH1y9xMK1hnWMIyM9y9cSq58c8,8176
2
+ agentix/files/__init__.py,sha256=54kyYKLE2-wI9Eu_fC9lOSNUmP1AetRjDwgjumdQLxI,4846
3
+ agentix_runtime_basic-0.1.1.dist-info/METADATA,sha256=_0MYs4i4tdo4M-Lsfi2iMN4zV0klJlf7n0-hz8SYDOc,2102
4
+ agentix_runtime_basic-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ agentix_runtime_basic-0.1.1.dist-info/entry_points.txt,sha256=yBKDuZJEX0Vi-e-CmwLijw5VmZn3puPBlx3gflhtzfg,62
6
+ agentix_runtime_basic-0.1.1.dist-info/licenses/LICENSE,sha256=mt35xkyIDkKZlEgxp-WYN0zaNDhtZtYtagSi6rMQmXg,1065
7
+ agentix_runtime_basic-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [agentix.namespace]
2
+ bash = agentix.bash
3
+ files = agentix.files
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agentiix
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.