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.
- agentix_runtime_basic-0.1.0/.gitignore +18 -0
- agentix_runtime_basic-0.1.0/LICENSE +21 -0
- agentix_runtime_basic-0.1.0/PKG-INFO +69 -0
- agentix_runtime_basic-0.1.0/README.md +52 -0
- agentix_runtime_basic-0.1.0/pyproject.toml +60 -0
- agentix_runtime_basic-0.1.0/runtime/Dockerfile +40 -0
- agentix_runtime_basic-0.1.0/src/agentix/bash/__init__.py +260 -0
- agentix_runtime_basic-0.1.0/src/agentix/files/__init__.py +145 -0
- agentix_runtime_basic-0.1.0/tests/test_primitives.py +49 -0
|
@@ -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
|