axio-tools-docker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,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,11 @@
1
+ axio_tools_docker/__init__.py,sha256=VlydIu0yrzEObMaquRpTTRRqyPIf2BgUJcmZtfRUJRs,204
2
+ axio_tools_docker/config.py,sha256=5SeqSnvszaVtpSJ_JU_cap5EC_suqnX8VH47oQ-W0RA,1546
3
+ axio_tools_docker/handler.py,sha256=gRXEVU5_r3RvzTkzEdaBwk_lYNeXT8xsIMy70wgZNaw,2472
4
+ axio_tools_docker/manager.py,sha256=RnmDM66doxYynTL4rnEAKa_h7lZ1Bqr0VCpDxVRwOIY,5390
5
+ axio_tools_docker/plugin.py,sha256=XSYx-55MGfYNcxf6FJ-hZOiunuijMJRFBwimuUcYAp8,2824
6
+ axio_tools_docker/settings.py,sha256=68GsTim219YyK9-2ono2E5x6Cf9-EHidLxS3VomcJyY,6914
7
+ axio_tools_docker-0.1.0.dist-info/METADATA,sha256=b2FyGPX2a6-s0kaPQ1YaETiK-lkS0zhVoYnCKixdpbA,179
8
+ axio_tools_docker-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ axio_tools_docker-0.1.0.dist-info/entry_points.txt,sha256=IMGBwH0xSL1wAvarV1r8s8sVMW4NjgxW-_6S8uywOys,69
10
+ axio_tools_docker-0.1.0.dist-info/licenses/LICENSE,sha256=ddOkAXgIM2QzvUDPydeis3tJXfhHxt1wKnmGjbwPPxQ,1074
11
+ axio_tools_docker-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [axio.tools.settings]
2
+ docker = axio_tools_docker.plugin:DockerPlugin
@@ -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.