axio-tools-docker 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.
- axio_tools_docker-0.1.0/.github/workflows/publish.yml +40 -0
- axio_tools_docker-0.1.0/.github/workflows/tests.yml +44 -0
- axio_tools_docker-0.1.0/LICENSE +21 -0
- axio_tools_docker-0.1.0/Makefile +16 -0
- axio_tools_docker-0.1.0/PKG-INFO +8 -0
- axio_tools_docker-0.1.0/README.md +15 -0
- axio_tools_docker-0.1.0/pyproject.toml +34 -0
- axio_tools_docker-0.1.0/src/axio_tools_docker/__init__.py +7 -0
- axio_tools_docker-0.1.0/src/axio_tools_docker/config.py +46 -0
- axio_tools_docker-0.1.0/src/axio_tools_docker/handler.py +73 -0
- axio_tools_docker-0.1.0/src/axio_tools_docker/manager.py +158 -0
- axio_tools_docker-0.1.0/src/axio_tools_docker/plugin.py +87 -0
- axio_tools_docker-0.1.0/src/axio_tools_docker/settings.py +170 -0
- axio_tools_docker-0.1.0/tests/test_config.py +53 -0
- axio_tools_docker-0.1.0/tests/test_handler.py +99 -0
- axio_tools_docker-0.1.0/tests/test_manager.py +257 -0
- axio_tools_docker-0.1.0/tests/test_plugin.py +85 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
env:
|
|
8
|
+
FORCE_COLOR: 1
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v6
|
|
16
|
+
|
|
17
|
+
- name: Set version from release tag
|
|
18
|
+
run: uv version "${GITHUB_REF_NAME#v}"
|
|
19
|
+
|
|
20
|
+
- name: Build package
|
|
21
|
+
run: uv build
|
|
22
|
+
|
|
23
|
+
- uses: actions/upload-artifact@v4
|
|
24
|
+
with:
|
|
25
|
+
name: dist
|
|
26
|
+
path: dist/
|
|
27
|
+
|
|
28
|
+
publish:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
needs: build
|
|
31
|
+
environment: pypi
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/download-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: dist
|
|
38
|
+
path: dist/
|
|
39
|
+
|
|
40
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ master, main ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ master, main ]
|
|
8
|
+
|
|
9
|
+
env:
|
|
10
|
+
FORCE_COLOR: 1
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
ruff:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v6
|
|
18
|
+
- run: uv sync --frozen
|
|
19
|
+
- run: uv run ruff check
|
|
20
|
+
- run: uv run ruff format --check
|
|
21
|
+
|
|
22
|
+
mypy:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: astral-sh/setup-uv@v6
|
|
27
|
+
- run: uv sync --frozen
|
|
28
|
+
- run: uv run mypy .
|
|
29
|
+
|
|
30
|
+
tests:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
strategy:
|
|
33
|
+
fail-fast: false
|
|
34
|
+
matrix:
|
|
35
|
+
python:
|
|
36
|
+
- "3.12"
|
|
37
|
+
- "3.13"
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
- uses: astral-sh/setup-uv@v6
|
|
41
|
+
with:
|
|
42
|
+
python-version: ${{ matrix.python }}
|
|
43
|
+
- run: uv sync --frozen
|
|
44
|
+
- run: uv run pytest -vv --cov=axio_tools_docker --cov-report=term-missing
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Axio contributors
|
|
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,16 @@
|
|
|
1
|
+
.PHONY: fmt lint typecheck test all
|
|
2
|
+
|
|
3
|
+
fmt:
|
|
4
|
+
uv run ruff format src/ tests/
|
|
5
|
+
uv run ruff check --fix src/ tests/
|
|
6
|
+
|
|
7
|
+
lint:
|
|
8
|
+
uv run ruff check src/ tests/
|
|
9
|
+
|
|
10
|
+
typecheck:
|
|
11
|
+
uv run mypy src/
|
|
12
|
+
|
|
13
|
+
test:
|
|
14
|
+
uv run pytest tests/ -v
|
|
15
|
+
|
|
16
|
+
all: fmt lint typecheck test
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "axio-tools-docker"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Docker sandbox tools for Axio"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
dependencies = ["axio"]
|
|
8
|
+
|
|
9
|
+
[project.entry-points."axio.tools.settings"]
|
|
10
|
+
docker = "axio_tools_docker.plugin:DockerPlugin"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/axio_tools_docker"]
|
|
18
|
+
|
|
19
|
+
[tool.pytest.ini_options]
|
|
20
|
+
asyncio_mode = "auto"
|
|
21
|
+
|
|
22
|
+
[tool.ruff]
|
|
23
|
+
line-length = 119
|
|
24
|
+
target-version = "py312"
|
|
25
|
+
|
|
26
|
+
[tool.ruff.lint]
|
|
27
|
+
select = ["E", "F", "I", "UP"]
|
|
28
|
+
|
|
29
|
+
[tool.mypy]
|
|
30
|
+
strict = true
|
|
31
|
+
python_version = "3.12"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "mypy>=1.14", "ruff>=0.9"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""SandboxConfig: frozen dataclass for Docker sandbox parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class SandboxConfig:
|
|
10
|
+
"""Configuration for a Docker sandbox container."""
|
|
11
|
+
|
|
12
|
+
image: str = "python:latest"
|
|
13
|
+
memory: str = "256m"
|
|
14
|
+
cpus: str = "1.0"
|
|
15
|
+
network: bool = False
|
|
16
|
+
workdir: str = "/workspace"
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict[str, str]:
|
|
19
|
+
"""Serialize to flat string dict for config DB persistence.
|
|
20
|
+
|
|
21
|
+
Only non-default values are included.
|
|
22
|
+
"""
|
|
23
|
+
defaults = SandboxConfig()
|
|
24
|
+
result: dict[str, str] = {}
|
|
25
|
+
if self.image != defaults.image:
|
|
26
|
+
result["image"] = self.image
|
|
27
|
+
if self.memory != defaults.memory:
|
|
28
|
+
result["memory"] = self.memory
|
|
29
|
+
if self.cpus != defaults.cpus:
|
|
30
|
+
result["cpus"] = self.cpus
|
|
31
|
+
if self.network != defaults.network:
|
|
32
|
+
result["network"] = str(self.network)
|
|
33
|
+
if self.workdir != defaults.workdir:
|
|
34
|
+
result["workdir"] = self.workdir
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: dict[str, str]) -> SandboxConfig:
|
|
39
|
+
"""Deserialize from flat string dict."""
|
|
40
|
+
return cls(
|
|
41
|
+
image=data.get("image", "python:latest"),
|
|
42
|
+
memory=data.get("memory", "256m"),
|
|
43
|
+
cpus=data.get("cpus", "1.0"),
|
|
44
|
+
network=data["network"].lower() in ("true", "1", "yes") if "network" in data else False,
|
|
45
|
+
workdir=data.get("workdir", "/workspace"),
|
|
46
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Dynamic ToolHandler builders for Docker sandbox tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from axio.tool import ToolHandler
|
|
8
|
+
|
|
9
|
+
from .manager import SandboxManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_sandbox_exec(manager: SandboxManager) -> type[ToolHandler]:
|
|
13
|
+
"""Create a ToolHandler that executes commands in the sandbox."""
|
|
14
|
+
|
|
15
|
+
class SandboxExecHandler(ToolHandler):
|
|
16
|
+
"""Execute a shell command inside a Docker sandbox container.
|
|
17
|
+
Returns combined stdout/stderr. Non-zero exit codes are reported.
|
|
18
|
+
Use for running code, tests, or CLI tools in an isolated environment."""
|
|
19
|
+
|
|
20
|
+
command: str
|
|
21
|
+
timeout: int = 30
|
|
22
|
+
|
|
23
|
+
_manager: ClassVar[SandboxManager]
|
|
24
|
+
|
|
25
|
+
async def __call__(self) -> str:
|
|
26
|
+
if not self._manager.docker_available():
|
|
27
|
+
return "Error: Docker is not installed or not on PATH"
|
|
28
|
+
return await self._manager.exec(self.command, timeout=self.timeout)
|
|
29
|
+
|
|
30
|
+
SandboxExecHandler._manager = manager
|
|
31
|
+
return SandboxExecHandler
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_sandbox_write(manager: SandboxManager) -> type[ToolHandler]:
|
|
35
|
+
"""Create a ToolHandler that writes files in the sandbox."""
|
|
36
|
+
|
|
37
|
+
class SandboxWriteHandler(ToolHandler):
|
|
38
|
+
"""Write content to a file inside the Docker sandbox container.
|
|
39
|
+
Creates parent directories as needed. Use for placing source code,
|
|
40
|
+
config files, or test data in the sandbox."""
|
|
41
|
+
|
|
42
|
+
path: str
|
|
43
|
+
content: str
|
|
44
|
+
|
|
45
|
+
_manager: ClassVar[SandboxManager]
|
|
46
|
+
|
|
47
|
+
async def __call__(self) -> str:
|
|
48
|
+
if not self._manager.docker_available():
|
|
49
|
+
return "Error: Docker is not installed or not on PATH"
|
|
50
|
+
return await self._manager.write_file(self.path, self.content)
|
|
51
|
+
|
|
52
|
+
SandboxWriteHandler._manager = manager
|
|
53
|
+
return SandboxWriteHandler
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_sandbox_read(manager: SandboxManager) -> type[ToolHandler]:
|
|
57
|
+
"""Create a ToolHandler that reads files from the sandbox."""
|
|
58
|
+
|
|
59
|
+
class SandboxReadHandler(ToolHandler):
|
|
60
|
+
"""Read the contents of a file from the Docker sandbox container.
|
|
61
|
+
Returns the full file content as text."""
|
|
62
|
+
|
|
63
|
+
path: str
|
|
64
|
+
|
|
65
|
+
_manager: ClassVar[SandboxManager]
|
|
66
|
+
|
|
67
|
+
async def __call__(self) -> str:
|
|
68
|
+
if not self._manager.docker_available():
|
|
69
|
+
return "Error: Docker is not installed or not on PATH"
|
|
70
|
+
return await self._manager.read_file(self.path)
|
|
71
|
+
|
|
72
|
+
SandboxReadHandler._manager = manager
|
|
73
|
+
return SandboxReadHandler
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""SandboxManager: Docker container lifecycle for sandbox execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
from .config import SandboxConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SandboxManager:
|
|
15
|
+
"""Manages a session-persistent Docker container for sandboxed execution."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.config = SandboxConfig()
|
|
19
|
+
self._container_id: str | None = None
|
|
20
|
+
self._lock = asyncio.Lock()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def container_running(self) -> bool:
|
|
24
|
+
"""Whether a sandbox container is currently active."""
|
|
25
|
+
return self._container_id is not None
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def docker_available() -> bool:
|
|
29
|
+
"""Check whether the docker CLI is on PATH."""
|
|
30
|
+
return shutil.which("docker") is not None
|
|
31
|
+
|
|
32
|
+
async def _create_container(self) -> str:
|
|
33
|
+
"""Create a new detached container and return its ID."""
|
|
34
|
+
cmd: list[str] = [
|
|
35
|
+
"docker",
|
|
36
|
+
"run",
|
|
37
|
+
"-d",
|
|
38
|
+
"--memory",
|
|
39
|
+
self.config.memory,
|
|
40
|
+
"--cpus",
|
|
41
|
+
self.config.cpus,
|
|
42
|
+
"-w",
|
|
43
|
+
self.config.workdir,
|
|
44
|
+
]
|
|
45
|
+
if not self.config.network:
|
|
46
|
+
cmd.extend(["--network", "none"])
|
|
47
|
+
cmd.extend([self.config.image, "sleep", "infinity"])
|
|
48
|
+
|
|
49
|
+
proc = await asyncio.create_subprocess_exec(
|
|
50
|
+
*cmd,
|
|
51
|
+
stdout=asyncio.subprocess.PIPE,
|
|
52
|
+
stderr=asyncio.subprocess.PIPE,
|
|
53
|
+
)
|
|
54
|
+
stdout, stderr = await proc.communicate()
|
|
55
|
+
if proc.returncode != 0:
|
|
56
|
+
raise RuntimeError(f"Failed to create container: {stderr.decode().strip()}")
|
|
57
|
+
container_id = stdout.decode().strip()[:12]
|
|
58
|
+
logger.info("Created sandbox container %s (image=%s)", container_id, self.config.image)
|
|
59
|
+
return container_id
|
|
60
|
+
|
|
61
|
+
async def _ensure_container(self) -> str:
|
|
62
|
+
"""Return the running container ID, creating one if needed."""
|
|
63
|
+
if self._container_id is not None:
|
|
64
|
+
return self._container_id
|
|
65
|
+
async with self._lock:
|
|
66
|
+
if self._container_id is not None:
|
|
67
|
+
return self._container_id
|
|
68
|
+
self._container_id = await self._create_container()
|
|
69
|
+
return self._container_id
|
|
70
|
+
|
|
71
|
+
async def exec(self, command: str, timeout: int = 30) -> str:
|
|
72
|
+
"""Execute a shell command inside the container."""
|
|
73
|
+
container_id = await self._ensure_container()
|
|
74
|
+
proc = await asyncio.create_subprocess_exec(
|
|
75
|
+
"docker",
|
|
76
|
+
"exec",
|
|
77
|
+
container_id,
|
|
78
|
+
"sh",
|
|
79
|
+
"-c",
|
|
80
|
+
command,
|
|
81
|
+
stdout=asyncio.subprocess.PIPE,
|
|
82
|
+
stderr=asyncio.subprocess.PIPE,
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
86
|
+
except TimeoutError:
|
|
87
|
+
proc.kill()
|
|
88
|
+
return f"[timeout after {timeout}s]"
|
|
89
|
+
|
|
90
|
+
output = ""
|
|
91
|
+
if stdout:
|
|
92
|
+
output += stdout.decode()
|
|
93
|
+
if stderr:
|
|
94
|
+
output += f"\n[stderr]\n{stderr.decode()}"
|
|
95
|
+
if proc.returncode != 0:
|
|
96
|
+
output += f"\n[exit code: {proc.returncode}]"
|
|
97
|
+
return output.strip() or "(no output)"
|
|
98
|
+
|
|
99
|
+
async def write_file(self, path: str, content: str) -> str:
|
|
100
|
+
"""Write content to a file inside the container via stdin piping."""
|
|
101
|
+
container_id = await self._ensure_container()
|
|
102
|
+
proc = await asyncio.create_subprocess_exec(
|
|
103
|
+
"docker",
|
|
104
|
+
"exec",
|
|
105
|
+
"-i",
|
|
106
|
+
container_id,
|
|
107
|
+
"sh",
|
|
108
|
+
"-c",
|
|
109
|
+
f"cat > {path}",
|
|
110
|
+
stdin=asyncio.subprocess.PIPE,
|
|
111
|
+
stdout=asyncio.subprocess.PIPE,
|
|
112
|
+
stderr=asyncio.subprocess.PIPE,
|
|
113
|
+
)
|
|
114
|
+
stdout, stderr = await proc.communicate(input=content.encode())
|
|
115
|
+
if proc.returncode != 0:
|
|
116
|
+
return f"Error writing {path}: {stderr.decode().strip()}"
|
|
117
|
+
return f"Wrote {path}"
|
|
118
|
+
|
|
119
|
+
async def read_file(self, path: str) -> str:
|
|
120
|
+
"""Read a file from inside the container."""
|
|
121
|
+
container_id = await self._ensure_container()
|
|
122
|
+
proc = await asyncio.create_subprocess_exec(
|
|
123
|
+
"docker",
|
|
124
|
+
"exec",
|
|
125
|
+
container_id,
|
|
126
|
+
"cat",
|
|
127
|
+
path,
|
|
128
|
+
stdout=asyncio.subprocess.PIPE,
|
|
129
|
+
stderr=asyncio.subprocess.PIPE,
|
|
130
|
+
)
|
|
131
|
+
stdout, stderr = await proc.communicate()
|
|
132
|
+
if proc.returncode != 0:
|
|
133
|
+
return f"Error reading {path}: {stderr.decode().strip()}"
|
|
134
|
+
return stdout.decode()
|
|
135
|
+
|
|
136
|
+
async def recreate(self) -> str:
|
|
137
|
+
"""Destroy the current container and create a fresh one."""
|
|
138
|
+
await self.close()
|
|
139
|
+
return await self._ensure_container()
|
|
140
|
+
|
|
141
|
+
async def close(self) -> None:
|
|
142
|
+
"""Stop and remove the container if running."""
|
|
143
|
+
if self._container_id is None:
|
|
144
|
+
return
|
|
145
|
+
container_id = self._container_id
|
|
146
|
+
self._container_id = None
|
|
147
|
+
try:
|
|
148
|
+
proc = await asyncio.create_subprocess_exec(
|
|
149
|
+
"docker",
|
|
150
|
+
"rm",
|
|
151
|
+
"-f",
|
|
152
|
+
container_id,
|
|
153
|
+
stdout=asyncio.subprocess.PIPE,
|
|
154
|
+
stderr=asyncio.subprocess.PIPE,
|
|
155
|
+
)
|
|
156
|
+
await proc.communicate()
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.warning("Failed to remove container %s", container_id, exc_info=True)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""DockerPlugin: ToolsPlugin implementation for Docker sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from axio.tool import Tool
|
|
9
|
+
|
|
10
|
+
from .config import SandboxConfig
|
|
11
|
+
from .handler import build_sandbox_exec, build_sandbox_read, build_sandbox_write
|
|
12
|
+
from .manager import SandboxManager
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DockerPlugin:
|
|
18
|
+
"""Dynamic tool provider for Docker sandbox execution.
|
|
19
|
+
|
|
20
|
+
Discovered via the ``axio.tools.settings`` entry point group.
|
|
21
|
+
The TUI interacts with this class through the ToolsPlugin protocol.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._manager = SandboxManager()
|
|
26
|
+
self._tools: list[Tool] = []
|
|
27
|
+
self._config: Any = None
|
|
28
|
+
self._global_config: Any = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def label(self) -> str:
|
|
32
|
+
return "Docker Sandbox"
|
|
33
|
+
|
|
34
|
+
async def init(self, config: Any = None, global_config: Any = None) -> None:
|
|
35
|
+
self._config = config
|
|
36
|
+
self._global_config = global_config
|
|
37
|
+
await self._load_config()
|
|
38
|
+
self._build_tools()
|
|
39
|
+
|
|
40
|
+
async def _load_config(self) -> None:
|
|
41
|
+
"""Load sandbox config from DB (project first, then global)."""
|
|
42
|
+
for db in (self._config, self._global_config):
|
|
43
|
+
if db is None:
|
|
44
|
+
continue
|
|
45
|
+
raw = await db.get_prefix("docker.")
|
|
46
|
+
if not raw:
|
|
47
|
+
continue
|
|
48
|
+
data: dict[str, str] = {}
|
|
49
|
+
for full_key, value in raw.items():
|
|
50
|
+
parts = full_key.split(".", 1)
|
|
51
|
+
if len(parts) == 2:
|
|
52
|
+
data[parts[1]] = value
|
|
53
|
+
if data:
|
|
54
|
+
self._manager.config = SandboxConfig.from_dict(data)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
def _build_tools(self) -> None:
|
|
58
|
+
"""Create the three sandbox tools."""
|
|
59
|
+
self._tools = [
|
|
60
|
+
Tool(
|
|
61
|
+
name="sandbox_exec",
|
|
62
|
+
description="Execute a shell command inside a Docker sandbox container",
|
|
63
|
+
handler=build_sandbox_exec(self._manager),
|
|
64
|
+
),
|
|
65
|
+
Tool(
|
|
66
|
+
name="sandbox_write",
|
|
67
|
+
description="Write content to a file inside the Docker sandbox container",
|
|
68
|
+
handler=build_sandbox_write(self._manager),
|
|
69
|
+
),
|
|
70
|
+
Tool(
|
|
71
|
+
name="sandbox_read",
|
|
72
|
+
description="Read a file from the Docker sandbox container",
|
|
73
|
+
handler=build_sandbox_read(self._manager),
|
|
74
|
+
),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def all_tools(self) -> list[Tool]:
|
|
79
|
+
return self._tools
|
|
80
|
+
|
|
81
|
+
def settings_screen(self) -> Any:
|
|
82
|
+
from .settings import DockerSettingsScreen
|
|
83
|
+
|
|
84
|
+
return DockerSettingsScreen(self._manager, config=self._config, global_config=self._global_config)
|
|
85
|
+
|
|
86
|
+
async def close(self) -> None:
|
|
87
|
+
await self._manager.close()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""TUI settings screen for Docker sandbox configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from textual.app import ComposeResult
|
|
9
|
+
from textual.binding import Binding
|
|
10
|
+
from textual.containers import Container, Horizontal
|
|
11
|
+
from textual.screen import ModalScreen
|
|
12
|
+
from textual.widgets import Button, Checkbox, Input, Static
|
|
13
|
+
|
|
14
|
+
_HAS_TEXTUAL = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
_HAS_TEXTUAL = False
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .config import SandboxConfig
|
|
20
|
+
from .manager import SandboxManager
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if _HAS_TEXTUAL:
|
|
24
|
+
|
|
25
|
+
class DockerSettingsScreen(ModalScreen[None]):
|
|
26
|
+
"""Settings screen for Docker sandbox configuration."""
|
|
27
|
+
|
|
28
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
29
|
+
CSS = """
|
|
30
|
+
DockerSettingsScreen { align: center middle; }
|
|
31
|
+
#docker-settings {
|
|
32
|
+
width: 70;
|
|
33
|
+
height: auto;
|
|
34
|
+
max-height: 90%;
|
|
35
|
+
border: heavy $accent;
|
|
36
|
+
background: $panel;
|
|
37
|
+
padding: 1 2;
|
|
38
|
+
}
|
|
39
|
+
#docker-settings Input { margin-bottom: 1; }
|
|
40
|
+
#docker-settings Checkbox { margin-bottom: 1; }
|
|
41
|
+
.docker-buttons { height: auto; margin-top: 1; }
|
|
42
|
+
.docker-buttons Button { margin: 0 1; }
|
|
43
|
+
.docker-container-actions { height: auto; margin-top: 1; margin-bottom: 1; }
|
|
44
|
+
.docker-container-actions Button { margin: 0 1; }
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
manager: SandboxManager,
|
|
50
|
+
config: Any = None,
|
|
51
|
+
global_config: Any = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__()
|
|
54
|
+
self._manager = manager
|
|
55
|
+
self._config = config
|
|
56
|
+
self._global_config = global_config
|
|
57
|
+
|
|
58
|
+
def compose(self) -> ComposeResult:
|
|
59
|
+
cfg = self._manager.config
|
|
60
|
+
docker_ok = self._manager.docker_available()
|
|
61
|
+
status = "[green]Docker available[/]" if docker_ok else "[red]Docker not found[/]"
|
|
62
|
+
running = self._manager.container_running
|
|
63
|
+
|
|
64
|
+
with Container(id="docker-settings"):
|
|
65
|
+
yield Static("[bold]Docker Sandbox Settings[/]")
|
|
66
|
+
yield Static(f"Status: {status}", id="docker-status")
|
|
67
|
+
yield Static(
|
|
68
|
+
"Container: [green]running[/]" if running else "Container: [dim]stopped[/]",
|
|
69
|
+
id="docker-container-status",
|
|
70
|
+
)
|
|
71
|
+
with Horizontal(classes="docker-container-actions"):
|
|
72
|
+
yield Button("Prepare", id="btn-prepare", variant="success", disabled=running)
|
|
73
|
+
yield Button("Stop", id="btn-stop", variant="error", disabled=not running)
|
|
74
|
+
yield Button("Recreate", id="btn-recreate", variant="warning", disabled=not running)
|
|
75
|
+
yield Static("Image:")
|
|
76
|
+
yield Input(value=cfg.image, placeholder="e.g. python:latest", id="docker-image")
|
|
77
|
+
yield Static("Memory limit:")
|
|
78
|
+
yield Input(value=cfg.memory, placeholder="e.g. 256m", id="docker-memory")
|
|
79
|
+
yield Static("CPU limit:")
|
|
80
|
+
yield Input(value=cfg.cpus, placeholder="e.g. 1.0", id="docker-cpus")
|
|
81
|
+
yield Checkbox("Allow network access", cfg.network, id="docker-network")
|
|
82
|
+
with Horizontal(classes="docker-buttons"):
|
|
83
|
+
yield Button("Save", id="btn-save", variant="primary")
|
|
84
|
+
yield Button("Cancel", id="btn-cancel")
|
|
85
|
+
|
|
86
|
+
def on_mount(self) -> None:
|
|
87
|
+
self.query_one("#docker-image", Input).focus()
|
|
88
|
+
|
|
89
|
+
def _update_container_status(self) -> None:
|
|
90
|
+
running = self._manager.container_running
|
|
91
|
+
status_widget = self.query_one("#docker-container-status", Static)
|
|
92
|
+
status_widget.update(
|
|
93
|
+
"Container: [green]running[/]" if running else "Container: [dim]stopped[/]",
|
|
94
|
+
)
|
|
95
|
+
self.query_one("#btn-prepare", Button).disabled = running
|
|
96
|
+
self.query_one("#btn-stop", Button).disabled = not running
|
|
97
|
+
self.query_one("#btn-recreate", Button).disabled = not running
|
|
98
|
+
|
|
99
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
100
|
+
if event.button.id == "btn-cancel":
|
|
101
|
+
self.dismiss(None)
|
|
102
|
+
elif event.button.id == "btn-save":
|
|
103
|
+
self._save()
|
|
104
|
+
elif event.button.id == "btn-prepare":
|
|
105
|
+
self.app.run_worker(self._do_prepare())
|
|
106
|
+
elif event.button.id == "btn-stop":
|
|
107
|
+
self.app.run_worker(self._do_stop())
|
|
108
|
+
elif event.button.id == "btn-recreate":
|
|
109
|
+
self.app.run_worker(self._do_recreate())
|
|
110
|
+
|
|
111
|
+
async def _do_prepare(self) -> None:
|
|
112
|
+
self.notify("Preparing container...")
|
|
113
|
+
try:
|
|
114
|
+
await self._manager._ensure_container()
|
|
115
|
+
self._update_container_status()
|
|
116
|
+
self.notify("Container ready")
|
|
117
|
+
except RuntimeError as exc:
|
|
118
|
+
self.notify(str(exc), severity="error")
|
|
119
|
+
|
|
120
|
+
async def _do_stop(self) -> None:
|
|
121
|
+
await self._manager.close()
|
|
122
|
+
self._update_container_status()
|
|
123
|
+
self.notify("Container stopped")
|
|
124
|
+
|
|
125
|
+
async def _do_recreate(self) -> None:
|
|
126
|
+
self.notify("Recreating container...")
|
|
127
|
+
try:
|
|
128
|
+
await self._manager.recreate()
|
|
129
|
+
self._update_container_status()
|
|
130
|
+
self.notify("Container recreated")
|
|
131
|
+
except RuntimeError as exc:
|
|
132
|
+
self.notify(str(exc), severity="error")
|
|
133
|
+
self._update_container_status()
|
|
134
|
+
|
|
135
|
+
def _save(self) -> None:
|
|
136
|
+
from .config import SandboxConfig
|
|
137
|
+
|
|
138
|
+
image = self.query_one("#docker-image", Input).value.strip()
|
|
139
|
+
memory = self.query_one("#docker-memory", Input).value.strip()
|
|
140
|
+
cpus = self.query_one("#docker-cpus", Input).value.strip()
|
|
141
|
+
network = self.query_one("#docker-network", Checkbox).value
|
|
142
|
+
|
|
143
|
+
cfg = SandboxConfig(
|
|
144
|
+
image=image or "python:latest",
|
|
145
|
+
memory=memory or "256m",
|
|
146
|
+
cpus=cpus or "1.0",
|
|
147
|
+
network=network,
|
|
148
|
+
)
|
|
149
|
+
self._manager.config = cfg
|
|
150
|
+
self.app.run_worker(self._persist(cfg))
|
|
151
|
+
self.dismiss(None)
|
|
152
|
+
|
|
153
|
+
async def _persist(self, cfg: SandboxConfig) -> None:
|
|
154
|
+
db = self._config or self._global_config
|
|
155
|
+
if db is None:
|
|
156
|
+
return
|
|
157
|
+
await db.delete_prefix("docker.")
|
|
158
|
+
for key, value in cfg.to_dict().items():
|
|
159
|
+
await db.set(f"docker.{key}", value)
|
|
160
|
+
|
|
161
|
+
def action_cancel(self) -> None:
|
|
162
|
+
self.dismiss(None)
|
|
163
|
+
|
|
164
|
+
else:
|
|
165
|
+
|
|
166
|
+
class DockerSettingsScreen: # type: ignore[no-redef]
|
|
167
|
+
"""Stub when textual is not installed."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
170
|
+
raise ImportError("textual is required for Docker settings screen")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Tests for SandboxConfig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from axio_tools_docker.config import SandboxConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_defaults() -> None:
|
|
10
|
+
cfg = SandboxConfig()
|
|
11
|
+
assert cfg.image == "python:latest"
|
|
12
|
+
assert cfg.memory == "256m"
|
|
13
|
+
assert cfg.cpus == "1.0"
|
|
14
|
+
assert cfg.network is False
|
|
15
|
+
assert cfg.workdir == "/workspace"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_frozen() -> None:
|
|
19
|
+
cfg = SandboxConfig()
|
|
20
|
+
with pytest.raises(AttributeError):
|
|
21
|
+
cfg.image = "ubuntu" # type: ignore[misc]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_to_dict_omits_defaults() -> None:
|
|
25
|
+
cfg = SandboxConfig()
|
|
26
|
+
assert cfg.to_dict() == {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_to_dict_non_defaults() -> None:
|
|
30
|
+
cfg = SandboxConfig(image="ubuntu:22.04", memory="512m", network=True)
|
|
31
|
+
d = cfg.to_dict()
|
|
32
|
+
assert d == {"image": "ubuntu:22.04", "memory": "512m", "network": "True"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_roundtrip() -> None:
|
|
36
|
+
original = SandboxConfig(image="node:20", cpus="2.0", workdir="/app", network=True)
|
|
37
|
+
restored = SandboxConfig.from_dict(original.to_dict())
|
|
38
|
+
assert restored == original
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_from_dict_empty() -> None:
|
|
42
|
+
cfg = SandboxConfig.from_dict({})
|
|
43
|
+
assert cfg == SandboxConfig()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_network_bool_serialization() -> None:
|
|
47
|
+
for truthy in ("True", "true", "1", "yes"):
|
|
48
|
+
cfg = SandboxConfig.from_dict({"network": truthy})
|
|
49
|
+
assert cfg.network is True
|
|
50
|
+
|
|
51
|
+
for falsy in ("False", "false", "0", "no"):
|
|
52
|
+
cfg = SandboxConfig.from_dict({"network": falsy})
|
|
53
|
+
assert cfg.network is False
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Tests for handler builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, patch
|
|
6
|
+
|
|
7
|
+
from axio_tools_docker.handler import build_sandbox_exec, build_sandbox_read, build_sandbox_write
|
|
8
|
+
from axio_tools_docker.manager import SandboxManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_exec_handler_forwards_to_manager() -> None:
|
|
12
|
+
manager = SandboxManager()
|
|
13
|
+
manager.exec = AsyncMock(return_value="output") # type: ignore[method-assign]
|
|
14
|
+
|
|
15
|
+
handler_cls = build_sandbox_exec(manager)
|
|
16
|
+
instance = handler_cls(command="echo hi", timeout=10)
|
|
17
|
+
|
|
18
|
+
with patch.object(SandboxManager, "docker_available", return_value=True):
|
|
19
|
+
result = await instance()
|
|
20
|
+
|
|
21
|
+
assert result == "output"
|
|
22
|
+
manager.exec.assert_awaited_once_with("echo hi", timeout=10)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def test_write_handler_forwards_to_manager() -> None:
|
|
26
|
+
manager = SandboxManager()
|
|
27
|
+
manager.write_file = AsyncMock(return_value="Wrote /workspace/test.py") # type: ignore[method-assign]
|
|
28
|
+
|
|
29
|
+
handler_cls = build_sandbox_write(manager)
|
|
30
|
+
instance = handler_cls(path="/workspace/test.py", content="print('hi')")
|
|
31
|
+
|
|
32
|
+
with patch.object(SandboxManager, "docker_available", return_value=True):
|
|
33
|
+
result = await instance()
|
|
34
|
+
|
|
35
|
+
assert result == "Wrote /workspace/test.py"
|
|
36
|
+
manager.write_file.assert_awaited_once_with("/workspace/test.py", "print('hi')")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_read_handler_forwards_to_manager() -> None:
|
|
40
|
+
manager = SandboxManager()
|
|
41
|
+
manager.read_file = AsyncMock(return_value="file content") # type: ignore[method-assign]
|
|
42
|
+
|
|
43
|
+
handler_cls = build_sandbox_read(manager)
|
|
44
|
+
instance = handler_cls(path="/workspace/test.py")
|
|
45
|
+
|
|
46
|
+
with patch.object(SandboxManager, "docker_available", return_value=True):
|
|
47
|
+
result = await instance()
|
|
48
|
+
|
|
49
|
+
assert result == "file content"
|
|
50
|
+
manager.read_file.assert_awaited_once_with("/workspace/test.py")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_exec_handler_returns_error_when_docker_missing() -> None:
|
|
54
|
+
manager = SandboxManager()
|
|
55
|
+
handler_cls = build_sandbox_exec(manager)
|
|
56
|
+
instance = handler_cls(command="echo hi")
|
|
57
|
+
|
|
58
|
+
with patch.object(SandboxManager, "docker_available", return_value=False):
|
|
59
|
+
result = await instance()
|
|
60
|
+
|
|
61
|
+
assert "not installed" in result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def test_write_handler_returns_error_when_docker_missing() -> None:
|
|
65
|
+
manager = SandboxManager()
|
|
66
|
+
handler_cls = build_sandbox_write(manager)
|
|
67
|
+
instance = handler_cls(path="/test", content="x")
|
|
68
|
+
|
|
69
|
+
with patch.object(SandboxManager, "docker_available", return_value=False):
|
|
70
|
+
result = await instance()
|
|
71
|
+
|
|
72
|
+
assert "not installed" in result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def test_read_handler_returns_error_when_docker_missing() -> None:
|
|
76
|
+
manager = SandboxManager()
|
|
77
|
+
handler_cls = build_sandbox_read(manager)
|
|
78
|
+
instance = handler_cls(path="/test")
|
|
79
|
+
|
|
80
|
+
with patch.object(SandboxManager, "docker_available", return_value=False):
|
|
81
|
+
result = await instance()
|
|
82
|
+
|
|
83
|
+
assert "not installed" in result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_exec_handler_is_tool_handler_subclass() -> None:
|
|
87
|
+
from axio.tool import ToolHandler
|
|
88
|
+
|
|
89
|
+
manager = SandboxManager()
|
|
90
|
+
handler_cls = build_sandbox_exec(manager)
|
|
91
|
+
assert issubclass(handler_cls, ToolHandler)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_handlers_have_docstrings() -> None:
|
|
95
|
+
manager = SandboxManager()
|
|
96
|
+
for builder in (build_sandbox_exec, build_sandbox_read, build_sandbox_write):
|
|
97
|
+
handler_cls = builder(manager)
|
|
98
|
+
assert handler_cls.__doc__ is not None
|
|
99
|
+
assert len(handler_cls.__doc__) > 10
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Tests for SandboxManager."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from axio_tools_docker.config import SandboxConfig
|
|
9
|
+
from axio_tools_docker.manager import SandboxManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _mock_proc(stdout: bytes = b"", stderr: bytes = b"", returncode: int = 0) -> MagicMock:
|
|
13
|
+
proc = MagicMock()
|
|
14
|
+
proc.communicate = AsyncMock(return_value=(stdout, stderr))
|
|
15
|
+
proc.returncode = returncode
|
|
16
|
+
proc.kill = MagicMock()
|
|
17
|
+
return proc
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture()
|
|
21
|
+
def manager() -> SandboxManager:
|
|
22
|
+
return SandboxManager()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def test_docker_available_true() -> None:
|
|
26
|
+
with patch("axio_tools_docker.manager.shutil.which", return_value="/usr/bin/docker"):
|
|
27
|
+
assert SandboxManager.docker_available() is True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def test_docker_available_false() -> None:
|
|
31
|
+
with patch("axio_tools_docker.manager.shutil.which", return_value=None):
|
|
32
|
+
assert SandboxManager.docker_available() is False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def test_container_created_once(manager: SandboxManager) -> None:
|
|
36
|
+
"""Container is created on first call and reused."""
|
|
37
|
+
create_proc = _mock_proc(stdout=b"abc123def456xyz\n")
|
|
38
|
+
exec_proc = _mock_proc(stdout=b"hello\n")
|
|
39
|
+
|
|
40
|
+
call_count = 0
|
|
41
|
+
|
|
42
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
43
|
+
nonlocal call_count
|
|
44
|
+
call_count += 1
|
|
45
|
+
if call_count == 1:
|
|
46
|
+
return create_proc
|
|
47
|
+
return exec_proc
|
|
48
|
+
|
|
49
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
50
|
+
result1 = await manager.exec("echo hello")
|
|
51
|
+
result2 = await manager.exec("echo hello")
|
|
52
|
+
|
|
53
|
+
assert result1 == "hello"
|
|
54
|
+
assert result2 == "hello"
|
|
55
|
+
# 1 create + 2 exec = 3 total calls
|
|
56
|
+
assert call_count == 3
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def test_exec_stdout_stderr(manager: SandboxManager) -> None:
|
|
60
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
61
|
+
exec_proc = _mock_proc(stdout=b"out\n", stderr=b"err\n", returncode=0)
|
|
62
|
+
|
|
63
|
+
calls = [create_proc, exec_proc]
|
|
64
|
+
|
|
65
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
66
|
+
return calls.pop(0)
|
|
67
|
+
|
|
68
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
69
|
+
result = await manager.exec("cmd")
|
|
70
|
+
|
|
71
|
+
assert "out" in result
|
|
72
|
+
assert "[stderr]" in result
|
|
73
|
+
assert "err" in result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def test_exec_nonzero_exit(manager: SandboxManager) -> None:
|
|
77
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
78
|
+
exec_proc = _mock_proc(stdout=b"", stderr=b"fail\n", returncode=1)
|
|
79
|
+
|
|
80
|
+
calls = [create_proc, exec_proc]
|
|
81
|
+
|
|
82
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
83
|
+
return calls.pop(0)
|
|
84
|
+
|
|
85
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
86
|
+
result = await manager.exec("bad_cmd")
|
|
87
|
+
|
|
88
|
+
assert "[exit code: 1]" in result
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_exec_timeout(manager: SandboxManager) -> None:
|
|
92
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
93
|
+
timeout_proc = MagicMock()
|
|
94
|
+
timeout_proc.communicate = AsyncMock(side_effect=TimeoutError)
|
|
95
|
+
timeout_proc.kill = MagicMock()
|
|
96
|
+
|
|
97
|
+
calls = [create_proc, timeout_proc]
|
|
98
|
+
|
|
99
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
100
|
+
return calls.pop(0)
|
|
101
|
+
|
|
102
|
+
with (
|
|
103
|
+
patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess),
|
|
104
|
+
patch("axio_tools_docker.manager.asyncio.wait_for", side_effect=TimeoutError),
|
|
105
|
+
):
|
|
106
|
+
result = await manager.exec("sleep 100", timeout=5)
|
|
107
|
+
|
|
108
|
+
assert "[timeout after 5s]" in result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def test_write_file(manager: SandboxManager) -> None:
|
|
112
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
113
|
+
write_proc = _mock_proc()
|
|
114
|
+
|
|
115
|
+
calls = [create_proc, write_proc]
|
|
116
|
+
|
|
117
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
118
|
+
return calls.pop(0)
|
|
119
|
+
|
|
120
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
121
|
+
result = await manager.write_file("/workspace/test.py", "print('hi')")
|
|
122
|
+
|
|
123
|
+
assert "Wrote /workspace/test.py" in result
|
|
124
|
+
# Verify stdin was passed
|
|
125
|
+
write_proc.communicate.assert_awaited_once_with(input=b"print('hi')")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def test_read_file(manager: SandboxManager) -> None:
|
|
129
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
130
|
+
read_proc = _mock_proc(stdout=b"file content here")
|
|
131
|
+
|
|
132
|
+
calls = [create_proc, read_proc]
|
|
133
|
+
|
|
134
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
135
|
+
return calls.pop(0)
|
|
136
|
+
|
|
137
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
138
|
+
result = await manager.read_file("/workspace/test.py")
|
|
139
|
+
|
|
140
|
+
assert result == "file content here"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def test_close_removes_container(manager: SandboxManager) -> None:
|
|
144
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
145
|
+
rm_proc = _mock_proc()
|
|
146
|
+
|
|
147
|
+
calls = [create_proc, rm_proc]
|
|
148
|
+
captured_args: list[tuple[object, ...]] = []
|
|
149
|
+
|
|
150
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
151
|
+
captured_args.append(args)
|
|
152
|
+
return calls.pop(0)
|
|
153
|
+
|
|
154
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
155
|
+
await manager._ensure_container()
|
|
156
|
+
await manager.close()
|
|
157
|
+
|
|
158
|
+
# Second call should be docker rm -f
|
|
159
|
+
assert "rm" in captured_args[1]
|
|
160
|
+
assert "-f" in captured_args[1]
|
|
161
|
+
assert manager._container_id is None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_close_noop_without_container(manager: SandboxManager) -> None:
|
|
165
|
+
"""close() is a no-op when no container was ever created."""
|
|
166
|
+
await manager.close() # Should not raise
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def test_creation_failure_raises(manager: SandboxManager) -> None:
|
|
170
|
+
fail_proc = _mock_proc(stderr=b"no such image", returncode=1)
|
|
171
|
+
|
|
172
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
173
|
+
return fail_proc
|
|
174
|
+
|
|
175
|
+
with (
|
|
176
|
+
patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess),
|
|
177
|
+
pytest.raises(RuntimeError, match="Failed to create container"),
|
|
178
|
+
):
|
|
179
|
+
await manager.exec("echo hi")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def test_container_running_property(manager: SandboxManager) -> None:
|
|
183
|
+
"""container_running reflects internal state."""
|
|
184
|
+
assert manager.container_running is False
|
|
185
|
+
|
|
186
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
187
|
+
|
|
188
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
189
|
+
return create_proc
|
|
190
|
+
|
|
191
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
192
|
+
await manager._ensure_container()
|
|
193
|
+
|
|
194
|
+
assert manager.container_running is True
|
|
195
|
+
manager._container_id = None
|
|
196
|
+
assert manager.container_running is False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_recreate_destroys_and_creates(manager: SandboxManager) -> None:
|
|
200
|
+
"""recreate() closes the old container and creates a new one."""
|
|
201
|
+
create_proc_1 = _mock_proc(stdout=b"old_container\n")
|
|
202
|
+
rm_proc = _mock_proc()
|
|
203
|
+
create_proc_2 = _mock_proc(stdout=b"new_container\n")
|
|
204
|
+
|
|
205
|
+
calls = [create_proc_1, rm_proc, create_proc_2]
|
|
206
|
+
captured_args: list[tuple[object, ...]] = []
|
|
207
|
+
|
|
208
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
209
|
+
captured_args.append(args)
|
|
210
|
+
return calls.pop(0)
|
|
211
|
+
|
|
212
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
213
|
+
await manager._ensure_container()
|
|
214
|
+
assert manager._container_id == "old_containe" # truncated to 12 chars
|
|
215
|
+
new_id = await manager.recreate()
|
|
216
|
+
|
|
217
|
+
assert new_id == "new_containe"
|
|
218
|
+
assert manager._container_id == "new_containe"
|
|
219
|
+
# Second call was docker rm -f
|
|
220
|
+
assert "rm" in captured_args[1]
|
|
221
|
+
assert "-f" in captured_args[1]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def test_recreate_without_running_container(manager: SandboxManager) -> None:
|
|
225
|
+
"""recreate() creates a container even when none was running."""
|
|
226
|
+
create_proc = _mock_proc(stdout=b"fresh_container\n")
|
|
227
|
+
|
|
228
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
229
|
+
return create_proc
|
|
230
|
+
|
|
231
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
232
|
+
new_id = await manager.recreate()
|
|
233
|
+
|
|
234
|
+
assert new_id == "fresh_contai"
|
|
235
|
+
assert manager.container_running is True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def test_config_applied(manager: SandboxManager) -> None:
|
|
239
|
+
"""Container creation uses config values."""
|
|
240
|
+
manager.config = SandboxConfig(image="ubuntu:22.04", memory="512m", cpus="2.0", network=True)
|
|
241
|
+
create_proc = _mock_proc(stdout=b"container123\n")
|
|
242
|
+
|
|
243
|
+
captured_args: list[tuple[object, ...]] = []
|
|
244
|
+
|
|
245
|
+
async def mock_subprocess(*args: object, **kwargs: object) -> MagicMock:
|
|
246
|
+
captured_args.append(args)
|
|
247
|
+
return create_proc
|
|
248
|
+
|
|
249
|
+
with patch("axio_tools_docker.manager.asyncio.create_subprocess_exec", side_effect=mock_subprocess):
|
|
250
|
+
await manager._ensure_container()
|
|
251
|
+
|
|
252
|
+
create_args = captured_args[0]
|
|
253
|
+
assert "ubuntu:22.04" in create_args
|
|
254
|
+
assert "512m" in create_args
|
|
255
|
+
assert "2.0" in create_args
|
|
256
|
+
# network=True means --network none should NOT be present
|
|
257
|
+
assert "--network" not in create_args
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Tests for DockerPlugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
6
|
+
|
|
7
|
+
from axio_tools_docker.config import SandboxConfig
|
|
8
|
+
from axio_tools_docker.plugin import DockerPlugin
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_init_without_config() -> None:
|
|
12
|
+
plugin = DockerPlugin()
|
|
13
|
+
await plugin.init()
|
|
14
|
+
assert len(plugin.all_tools) == 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def test_init_with_config_db() -> None:
|
|
18
|
+
mock_db = MagicMock()
|
|
19
|
+
mock_db.get_prefix = AsyncMock(
|
|
20
|
+
return_value={
|
|
21
|
+
"docker.image": "ubuntu:22.04",
|
|
22
|
+
"docker.memory": "512m",
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
plugin = DockerPlugin()
|
|
27
|
+
await plugin.init(config=mock_db)
|
|
28
|
+
|
|
29
|
+
assert plugin._manager.config.image == "ubuntu:22.04"
|
|
30
|
+
assert plugin._manager.config.memory == "512m"
|
|
31
|
+
mock_db.get_prefix.assert_awaited_once_with("docker.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_init_project_takes_priority() -> None:
|
|
35
|
+
project_db = MagicMock()
|
|
36
|
+
project_db.get_prefix = AsyncMock(return_value={"docker.image": "node:20"})
|
|
37
|
+
|
|
38
|
+
global_db = MagicMock()
|
|
39
|
+
global_db.get_prefix = AsyncMock(return_value={"docker.image": "python:3.11"})
|
|
40
|
+
|
|
41
|
+
plugin = DockerPlugin()
|
|
42
|
+
await plugin.init(config=project_db, global_config=global_db)
|
|
43
|
+
|
|
44
|
+
assert plugin._manager.config.image == "node:20"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def test_init_falls_back_to_global() -> None:
|
|
48
|
+
project_db = MagicMock()
|
|
49
|
+
project_db.get_prefix = AsyncMock(return_value={})
|
|
50
|
+
|
|
51
|
+
global_db = MagicMock()
|
|
52
|
+
global_db.get_prefix = AsyncMock(return_value={"docker.image": "python:3.11"})
|
|
53
|
+
|
|
54
|
+
plugin = DockerPlugin()
|
|
55
|
+
await plugin.init(config=project_db, global_config=global_db)
|
|
56
|
+
|
|
57
|
+
assert plugin._manager.config.image == "python:3.11"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def test_all_tools_returns_three() -> None:
|
|
61
|
+
plugin = DockerPlugin()
|
|
62
|
+
await plugin.init()
|
|
63
|
+
|
|
64
|
+
tools = plugin.all_tools
|
|
65
|
+
assert len(tools) == 3
|
|
66
|
+
names = {t.name for t in tools}
|
|
67
|
+
assert names == {"sandbox_exec", "sandbox_write", "sandbox_read"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_close_delegates_to_manager() -> None:
|
|
71
|
+
plugin = DockerPlugin()
|
|
72
|
+
plugin._manager.close = AsyncMock() # type: ignore[method-assign]
|
|
73
|
+
await plugin.close()
|
|
74
|
+
plugin._manager.close.assert_awaited_once()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_label() -> None:
|
|
78
|
+
plugin = DockerPlugin()
|
|
79
|
+
assert plugin.label == "Docker Sandbox"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def test_default_config_when_no_db() -> None:
|
|
83
|
+
plugin = DockerPlugin()
|
|
84
|
+
await plugin.init()
|
|
85
|
+
assert plugin._manager.config == SandboxConfig()
|