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.
@@ -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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: axio-tools-docker
3
+ Version: 0.1.0
4
+ Summary: Docker sandbox tools for Axio
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: axio
@@ -0,0 +1,15 @@
1
+ # axio-tools-docker
2
+
3
+ Docker sandbox tools for Axio.
4
+
5
+ Part of the [axio-agent](https://github.com/axio-agent) ecosystem.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install axio-tools-docker
11
+ ```
12
+
13
+ ## License
14
+
15
+ MIT
@@ -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,7 @@
1
+ """Docker sandbox tools for Axio."""
2
+
3
+ from .config import SandboxConfig
4
+ from .manager import SandboxManager
5
+ from .plugin import DockerPlugin
6
+
7
+ __all__ = ["DockerPlugin", "SandboxConfig", "SandboxManager"]
@@ -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()