agentix-runtime-basic 0.1.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,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ venv/
9
+ .env
10
+
11
+ .pytest_cache/
12
+ .ruff_cache/
13
+ .pyright/
14
+ .mypy_cache/
15
+
16
+ .vscode/
17
+ .idea/
18
+ .DS_Store
@@ -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.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentix-runtime-basic
3
+ Version: 0.1.0
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: agentix>=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,52 @@
1
+ # agentix-runtime-basic
2
+
3
+ Shell + file I/O primitives for [Agentix](https://github.com/Agentiix/Agentix)
4
+ sandboxes. One wheel, two namespaces:
5
+
6
+ | Namespace | Purpose |
7
+ |---|---|
8
+ | `agentix.bash` | execute shell commands, stream stdout/stderr |
9
+ | `agentix.files` | upload/download/list files inside the sandbox |
10
+
11
+ These two used to ship as separate `agentix-bash` and `agentix-files`
12
+ distributions. They consolidated here because every realistic sandbox
13
+ image needs both — splitting them was friction without isolation
14
+ benefit (neither has any non-stdlib runtime deps).
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install agentix-runtime-basic
20
+ ```
21
+
22
+ ## Use
23
+
24
+ ```python
25
+ from agentix import RuntimeClient
26
+ from agentix.bash import run as bash_run, run_stream as bash_stream
27
+ from agentix.files import upload, download
28
+
29
+ async with RuntimeClient(sandbox.runtime_url) as c:
30
+ await c.remote(upload, path="data.json", content=blob)
31
+ result = await c.remote(bash_run, command="cat data.json | jq .")
32
+ async for ev in c.remote(bash_stream, command="pytest -q"):
33
+ ... # ev is a BashStdout / BashStderr / BashExit / BashError
34
+ ```
35
+
36
+ Each namespace is "the package IS the namespace": top-level async
37
+ functions are the remote-callable surface, dataclasses/types like
38
+ `BashResult` and `UploadResult` are importable for return-type
39
+ annotations.
40
+
41
+ ## Building a sandbox image
42
+
43
+ `runtime/Dockerfile` is the base image bundle builds extend from. Most
44
+ users invoke it indirectly:
45
+
46
+ ```bash
47
+ agentix build runtime-basic -o my-agent:0.1.0
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agentix-runtime-basic"
7
+ version = "0.1.0"
8
+ description = "Shell + file I/O primitives for Agentix sandboxes"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Agentiix" }]
13
+ urls = { Homepage = "https://github.com/Agentiix/Agentix-Runtime-Basic" }
14
+ dependencies = [
15
+ # Both namespaces are pure-stdlib at runtime; the framework dep is
16
+ # only here so `pip install agentix-runtime-basic` brings agentix
17
+ # along as a peer (entry-point discovery needs the framework
18
+ # installed in the same venv).
19
+ "agentix>=0.1.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ dev = [
24
+ "pytest>=8",
25
+ "pytest-asyncio>=0.23",
26
+ "ruff>=0.6",
27
+ "pyright>=1.1.380",
28
+ ]
29
+
30
+ # Two namespaces shipped from one wheel. Both register under
31
+ # `agentix.namespace`; the framework spawns one worker subprocess per
32
+ # namespace from the same venv. Splitting them was previously two
33
+ # wheels (agentix-bash + agentix-files) — they're consolidated here
34
+ # because they share dependencies (none beyond the stdlib + agentix)
35
+ # and a deployment lifecycle (every sandbox image needs both).
36
+ [project.entry-points."agentix.namespace"]
37
+ bash = "agentix.bash"
38
+ files = "agentix.files"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ # `src/agentix/` is a PEP 420 namespace package (no __init__.py);
42
+ # hatchling walks the tree and installs files at `agentix/bash/` and
43
+ # `agentix/files/` in the wheel.
44
+ packages = ["src/agentix"]
45
+
46
+ [tool.ruff]
47
+ line-length = 120
48
+ target-version = "py311"
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "W", "UP"]
52
+
53
+ [tool.pyright]
54
+ include = ["src"]
55
+ exclude = ["**/__pycache__", ".venv"]
56
+ typeCheckingMode = "basic"
57
+ pythonVersion = "3.11"
58
+
59
+ [tool.pytest.ini_options]
60
+ asyncio_mode = "auto"
@@ -0,0 +1,40 @@
1
+ # Runtime image — Python slim + framework venv + uv.
2
+ #
3
+ # `agentix build` auto-builds this image (from the repo root) when it's
4
+ # missing locally. You don't normally run `docker build` directly.
5
+ #
6
+ # The runtime image carries:
7
+ # /nix/runtime/ — framework venv (uv-managed); ENTRYPOINT runs from here
8
+ # /nix/.wheels/ — the framework wheel, stashed for bundle stages to reuse
9
+ # uv on $PATH — used by `agentix build`'s generated Dockerfile to
10
+ # create one venv per namespace at /nix/<short>/
11
+ #
12
+ # Namespace bundles extend this image: each namespace gets `/nix/<short>/`
13
+ # with its own uv venv + (optional) Nix sys-deps symlinked into bin/.
14
+ # The multiplexer spawns one worker subprocess per namespace using that
15
+ # namespace's interpreter and prepends `/nix/<short>/bin` to PATH so
16
+ # `subprocess.run("git", ...)` in user code resolves transparently.
17
+
18
+ FROM python:3.11-slim AS builder
19
+ WORKDIR /build
20
+ RUN pip install --no-cache-dir build
21
+ COPY pyproject.toml README.md ./
22
+ COPY agentix ./agentix
23
+ RUN python -m build --wheel --outdir /dist
24
+
25
+ FROM python:3.11-slim
26
+ RUN pip install --no-cache-dir uv
27
+
28
+ # Framework wheel — kept so each bundled namespace's venv can install it
29
+ # without reaching PyPI.
30
+ RUN mkdir -p /nix/.wheels
31
+ COPY --from=builder /dist/*.whl /nix/.wheels/
32
+
33
+ # Runtime's own venv. /nix/runtime owns the framework dispatcher;
34
+ # the multiplexer is what spawns per-namespace workers.
35
+ RUN uv venv /nix/runtime && \
36
+ /nix/runtime/bin/pip install --no-cache-dir /nix/.wheels/agentix-*.whl
37
+
38
+ EXPOSE 8000
39
+ ENV AGENTIX_BIND_PORT=8000
40
+ ENTRYPOINT ["/nix/runtime/bin/agentix-server"]
@@ -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,49 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import shlex
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ import agentix.bash as bash
10
+ import agentix.files as files
11
+
12
+
13
+ @pytest.mark.asyncio
14
+ async def test_files_upload_refuses_symlink_escape(tmp_path, monkeypatch):
15
+ outside = tmp_path / "outside.txt"
16
+ root = tmp_path / "workspace"
17
+ root.mkdir()
18
+ monkeypatch.setenv("AGENTIX_UPLOAD_ROOT", str(root))
19
+ # files reads UPLOAD_ROOT from env at import time; force a reload so
20
+ # the patched env takes effect for this test.
21
+ import importlib
22
+ importlib.reload(files)
23
+
24
+ link = root / "link"
25
+ link.symlink_to(outside)
26
+
27
+ with pytest.raises(PermissionError):
28
+ await files.upload(str(link), b"escape")
29
+ assert not outside.exists()
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_bash_run_drains_stderr_after_output_cap():
34
+ code = (
35
+ "import sys; "
36
+ "sys.stderr.write('x' * 200000); "
37
+ "sys.stderr.flush(); "
38
+ "print('done')"
39
+ )
40
+ command = f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
41
+
42
+ result = await asyncio.wait_for(
43
+ bash.run(command, max_output=1024),
44
+ timeout=5,
45
+ )
46
+
47
+ assert result.exit_code == 0
48
+ assert result.stdout.strip() == "done"
49
+ assert "[truncated at 1024 bytes]" in result.stderr