sky-dev 0.0.5__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.
sky/tools/fs_tools.py ADDED
@@ -0,0 +1,139 @@
1
+ """Filesystem manipulation tools."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ import patch
9
+
10
+ from sky.tools.registry import RiskTier, register_tool
11
+
12
+
13
+ def _resolve_and_validate_path(path_str: str) -> Path:
14
+ """Resolve a path and ensure it stays within the current working directory."""
15
+ cwd = Path.cwd().resolve()
16
+ target_path = Path(path_str).expanduser().resolve()
17
+
18
+ try:
19
+ # Check if the target_path is relative to cwd (prevents traversal)
20
+ target_path.relative_to(cwd)
21
+ except ValueError:
22
+ raise PermissionError(f"Path traversal attempt blocked: {path_str} resolves outside of project root {cwd}")
23
+
24
+ return target_path
25
+
26
+
27
+ @register_tool("read_file", "Read content from a file safely.", RiskTier.SAFE)
28
+ def read_file(path: str, offset: Optional[int] = None, limit: Optional[int] = None) -> str:
29
+ """Read contents of a file with optional line ranges.
30
+
31
+ Args:
32
+ path: Path to the file to read.
33
+ offset: Optional starting line number (1-indexed).
34
+ limit: Optional maximum number of lines to read.
35
+ """
36
+ target = _resolve_and_validate_path(path)
37
+ if not target.exists() or not target.is_file():
38
+ raise FileNotFoundError(f"File not found: {path}")
39
+
40
+ with open(target, "r", encoding="utf-8", errors="replace") as f:
41
+ lines = f.readlines()
42
+
43
+ if offset is not None:
44
+ if offset < 1:
45
+ raise ValueError("Offset must be >= 1 (1-indexed).")
46
+ lines = lines[offset - 1:]
47
+
48
+ if limit is not None:
49
+ if limit < 1:
50
+ raise ValueError("Limit must be >= 1.")
51
+ lines = lines[:limit]
52
+
53
+ return "".join(lines)
54
+
55
+
56
+ @register_tool("write_file", "Write content to a file atomically.", RiskTier.DESTRUCTIVE)
57
+ def write_file(path: str, content: str) -> Dict[str, Any]:
58
+ """Write complete content to a file.
59
+
60
+ Args:
61
+ path: Path to the file to write.
62
+ content: The complete content to write.
63
+ """
64
+ target = _resolve_and_validate_path(path)
65
+ target.parent.mkdir(parents=True, exist_ok=True)
66
+
67
+ fd, temp_path = tempfile.mkstemp(dir=target.parent, prefix=".tmp_sky_")
68
+ try:
69
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
70
+ f.write(content)
71
+ os.replace(temp_path, target)
72
+ except Exception as e:
73
+ if os.path.exists(temp_path):
74
+ os.remove(temp_path)
75
+ raise OSError(f"Failed to write file atomically: {e}")
76
+
77
+ return {
78
+ "path": str(target.relative_to(Path.cwd())),
79
+ "bytes_written": len(content.encode("utf-8")),
80
+ "success": True
81
+ }
82
+
83
+
84
+ @register_tool("edit_file", "Apply a unified diff to a file.", RiskTier.DESTRUCTIVE)
85
+ def edit_file(path: str, diff: str) -> Dict[str, Any]:
86
+ """Edit a file by applying a unified diff patch.
87
+
88
+ Args:
89
+ path: Path to the file to edit.
90
+ diff: The unified diff string to apply.
91
+ """
92
+ target = _resolve_and_validate_path(path)
93
+ if not target.exists():
94
+ raise FileNotFoundError(f"Cannot edit non-existent file: {path}")
95
+
96
+ patch_set = patch.fromstring(diff.encode("utf-8"))
97
+ if not patch_set or not patch_set.items:
98
+ raise ValueError("Invalid unified diff provided.")
99
+
100
+ if len(patch_set.items) > 1:
101
+ raise ValueError("Diff contains multiple files. Use edit_file for one file at a time.")
102
+
103
+ import shutil
104
+ with tempfile.TemporaryDirectory(dir=target.parent) as temp_dir:
105
+ temp_dir_path = Path(temp_dir)
106
+ temp_target = temp_dir_path / target.name
107
+
108
+ # Copy original to temp
109
+ shutil.copy2(target, temp_target)
110
+
111
+ # Ensure the patch set uses the basename so it patches the file inside temp_dir
112
+ p = patch_set.items[0]
113
+ p.source = target.name.encode("utf-8")
114
+ p.target = target.name.encode("utf-8")
115
+
116
+ # Apply patch in temp directory
117
+ success = patch_set.apply(strip=0, root=str(temp_dir_path))
118
+ if not success:
119
+ raise RuntimeError("Failed to apply diff patch cleanly.")
120
+
121
+ # Atomic replace
122
+ os.replace(temp_target, target)
123
+
124
+ added = 0
125
+ removed = 0
126
+ for item in patch_set.items:
127
+ for hunk in item.hunks:
128
+ for line in hunk.text:
129
+ if line.startswith(b"+") and not line.startswith(b"+++"):
130
+ added += 1
131
+ elif line.startswith(b"-") and not line.startswith(b"---"):
132
+ removed += 1
133
+
134
+ return {
135
+ "path": str(target.relative_to(Path.cwd())),
136
+ "lines_added": added,
137
+ "lines_removed": removed,
138
+ "success": True
139
+ }
sky/tools/git_tools.py ADDED
@@ -0,0 +1,139 @@
1
+ """Git integration tools."""
2
+
3
+ import os
4
+ import subprocess
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from sky.tools.registry import RiskTier, register_tool
8
+
9
+
10
+ def _run_git(args: List[str]) -> subprocess.CompletedProcess:
11
+ """Helper to run git commands safely."""
12
+ try:
13
+ return subprocess.run(["git"] + args, capture_output=True, text=True, check=True)
14
+ except subprocess.CalledProcessError as e:
15
+ raise RuntimeError(f"Git command failed: {e.stderr}")
16
+ except FileNotFoundError:
17
+ raise RuntimeError("Git executable not found in PATH.")
18
+
19
+
20
+ @register_tool("git_diff", "Get git diffs for staged and unstaged changes.", RiskTier.SAFE)
21
+ def git_diff() -> Dict[str, Any]:
22
+ """Get current git diff (unstaged and staged changes)."""
23
+ unstaged = subprocess.run(["git", "diff"], capture_output=True, text=True).stdout
24
+ staged = subprocess.run(["git", "diff", "--staged"], capture_output=True, text=True).stdout
25
+
26
+ f_changed = subprocess.run(["git", "diff", "--name-only"], capture_output=True, text=True).stdout.splitlines()
27
+ f_staged = subprocess.run(["git", "diff", "--staged", "--name-only"], capture_output=True, text=True).stdout.splitlines()
28
+
29
+ return {
30
+ "unstaged": unstaged,
31
+ "staged": staged,
32
+ "files_changed": [f for f in f_changed if f],
33
+ "files_staged": [f for f in f_staged if f],
34
+ }
35
+
36
+
37
+ @register_tool("git_log", "Get recent git commit history.", RiskTier.SAFE)
38
+ def git_log(limit: int = 10) -> List[Dict[str, Any]]:
39
+ """Get git log history.
40
+
41
+ Args:
42
+ limit: Number of commits to return.
43
+ """
44
+ fmt = "%H|%an|%ae|%at|%s"
45
+ try:
46
+ result = _run_git(["log", f"--pretty=format:{fmt}", f"-n{limit}"])
47
+ except RuntimeError:
48
+ return []
49
+
50
+ commits = []
51
+ for line in result.stdout.splitlines():
52
+ if not line:
53
+ continue
54
+ parts = line.split("|", 4)
55
+ if len(parts) == 5:
56
+ commits.append({
57
+ "hash": parts[0],
58
+ "author_name": parts[1],
59
+ "author_email": parts[2],
60
+ "timestamp": parts[3],
61
+ "subject": parts[4]
62
+ })
63
+ return commits
64
+
65
+
66
+ @register_tool("git_commit", "Commit changes to git repository.", RiskTier.DESTRUCTIVE)
67
+ def git_commit(message: str, files: Optional[List[str]] = None) -> Dict[str, Any]:
68
+ """Commit files to the repository.
69
+
70
+ Args:
71
+ message: Commit message.
72
+ files: Optional list of files to commit. If None, commits currently staged changes.
73
+ """
74
+ if files:
75
+ _run_git(["add"] + files)
76
+
77
+ try:
78
+ _run_git(["commit", "-m", message])
79
+ hash_result = _run_git(["rev-parse", "HEAD"])
80
+ return {
81
+ "hash": hash_result.stdout.strip(),
82
+ "message": message,
83
+ "files_committed": files or [],
84
+ "success": True
85
+ }
86
+ except RuntimeError as e:
87
+ return {
88
+ "hash": "",
89
+ "message": message,
90
+ "files_committed": [],
91
+ "success": False,
92
+ "error": str(e)
93
+ }
94
+
95
+
96
+ @register_tool("git_branch", "Create, switch, or view branches.", RiskTier.DESTRUCTIVE)
97
+ def git_branch(name: str, checkout: bool = False) -> Dict[str, Any]:
98
+ """Create or checkout a git branch.
99
+
100
+ Args:
101
+ name: Name of the branch.
102
+ checkout: Whether to switch to the branch.
103
+ """
104
+ if not os.path.exists(".git"):
105
+ raise RuntimeError("Not a git repository (no .git directory found).")
106
+
107
+ branches = _run_git(["branch", "--list", name]).stdout.strip()
108
+ branch_exists = bool(branches)
109
+
110
+ created = False
111
+ switched = False
112
+
113
+ try:
114
+ if checkout:
115
+ if branch_exists:
116
+ _run_git(["checkout", name])
117
+ else:
118
+ _run_git(["checkout", "-b", name])
119
+ created = True
120
+ switched = True
121
+ else:
122
+ if not branch_exists:
123
+ _run_git(["branch", name])
124
+ created = True
125
+
126
+ return {
127
+ "branch": name,
128
+ "created": created,
129
+ "switched": switched,
130
+ "success": True
131
+ }
132
+ except RuntimeError as e:
133
+ return {
134
+ "branch": name,
135
+ "created": False,
136
+ "switched": False,
137
+ "success": False,
138
+ "error": str(e)
139
+ }
sky/tools/registry.py ADDED
@@ -0,0 +1,219 @@
1
+ """Tool registry for defining and managing agent capabilities."""
2
+
3
+ import inspect
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any, Callable, Dict, List, Optional, get_args, get_origin
8
+
9
+
10
+ class RiskTier(str, Enum):
11
+ """Classification of tool execution risk."""
12
+
13
+ SAFE = "safe"
14
+ DESTRUCTIVE = "destructive"
15
+
16
+
17
+ @dataclass
18
+ class ToolDefinition:
19
+ """Definition of an agent tool."""
20
+
21
+ name: str
22
+ description: str
23
+ risk_tier: RiskTier
24
+ func: Callable[..., Any]
25
+ args_schema: Dict[str, Any]
26
+ required_args: List[str] = field(default_factory=list)
27
+
28
+ def to_openai_schema(self) -> Dict[str, Any]:
29
+ """Convert the tool definition to OpenAI's function calling schema."""
30
+ return {
31
+ "type": "function",
32
+ "function": {
33
+ "name": self.name,
34
+ "description": self.description,
35
+ "parameters": {
36
+ "type": "object",
37
+ "properties": self.args_schema,
38
+ "required": self.required_args,
39
+ },
40
+ },
41
+ }
42
+
43
+
44
+ class ToolRegistry:
45
+ """Singleton registry for all available tools."""
46
+
47
+ _instance: Optional["ToolRegistry"] = None
48
+ _tools: Dict[str, ToolDefinition]
49
+
50
+ def __new__(cls) -> "ToolRegistry":
51
+ if cls._instance is None:
52
+ cls._instance = super(ToolRegistry, cls).__new__(cls)
53
+ cls._instance._tools = {}
54
+ return cls._instance
55
+
56
+ @classmethod
57
+ def _type_to_json_schema(cls, type_hint: Any) -> str:
58
+ """Convert a Python type hint to a JSON Schema type string."""
59
+ if type_hint == str:
60
+ return "string"
61
+ elif type_hint == int:
62
+ return "integer"
63
+ elif type_hint == float:
64
+ return "number"
65
+ elif type_hint == bool:
66
+ return "boolean"
67
+ elif type_hint == dict or type_hint == Dict:
68
+ return "object"
69
+ elif type_hint == list or type_hint == List:
70
+ return "array"
71
+ elif type_hint == Any:
72
+ return "string" # Fallback for Any
73
+
74
+ origin = get_origin(type_hint)
75
+ if origin is not None:
76
+ # Handle Optional[...] which is Union[..., NoneType]
77
+ import typing
78
+ is_union = False
79
+ try:
80
+ import types
81
+ if hasattr(types, "UnionType") and isinstance(type_hint, types.UnionType):
82
+ is_union = True
83
+ except ImportError:
84
+ pass
85
+
86
+ if origin is typing.Union or is_union or type_hint.__class__.__name__ in ('_UnionGenericAlias', 'UnionType', 'Union') or "Union" in str(type_hint):
87
+ args = get_args(type_hint)
88
+ # Find the non-None type
89
+ for arg in args:
90
+ if arg is not type(None):
91
+ return cls._type_to_json_schema(arg)
92
+
93
+ # Handle List, Dict generics
94
+ if origin == list or origin == typing.List or str(origin).endswith("list"):
95
+ return "array"
96
+ if origin == dict or origin == typing.Dict or str(origin).endswith("dict"):
97
+ return "object"
98
+
99
+ return "string" # Default fallback
100
+
101
+ @classmethod
102
+ def _extract_param_description(cls, docstring: Optional[str], param_name: str) -> Optional[str]:
103
+ """Extract parameter description from the docstring Args: section."""
104
+ if not docstring:
105
+ return None
106
+
107
+ # Look for 'Args:' or 'Parameters:' section
108
+ args_match = re.search(r"(?:Args|Parameters):\s*\n(.*?)(?:\n\s*\n|\Z)", docstring, re.DOTALL)
109
+ if not args_match:
110
+ return None
111
+
112
+ args_section = args_match.group(1)
113
+
114
+ # Look for the specific parameter description
115
+ param_pattern = re.compile(rf"^\s*{param_name}\s*(?:\([^)]+\))?\s*:\s*(.*?)(?=\n\s*\w+\s*(?:\([^)]+\))?\s*:|\Z)", re.DOTALL | re.MULTILINE)
116
+
117
+ param_match = param_pattern.search(args_section)
118
+ if param_match:
119
+ desc = param_match.group(1).replace("\n", " ").strip()
120
+ return re.sub(r"\s+", " ", desc)
121
+
122
+ return None
123
+
124
+ def register(self, name: str, description: str, risk_tier: RiskTier) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
125
+ """Decorator to register a function as a tool."""
126
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
127
+ sig = inspect.signature(func)
128
+ docstring = inspect.getdoc(func)
129
+
130
+ args_schema: Dict[str, Any] = {}
131
+ required_args: List[str] = []
132
+
133
+ for param_name, param in sig.parameters.items():
134
+ if param_name == "self" or param_name == "cls":
135
+ continue
136
+
137
+ json_type = self._type_to_json_schema(param.annotation)
138
+ param_desc = self._extract_param_description(docstring, param_name)
139
+
140
+ param_schema: Dict[str, Any] = {"type": json_type}
141
+ if param_desc:
142
+ param_schema["description"] = param_desc
143
+
144
+ args_schema[param_name] = param_schema
145
+
146
+ if param.default == inspect.Parameter.empty:
147
+ required_args.append(param_name)
148
+
149
+ tool_def = ToolDefinition(
150
+ name=name,
151
+ description=description,
152
+ risk_tier=risk_tier,
153
+ func=func,
154
+ args_schema=args_schema,
155
+ required_args=required_args,
156
+ )
157
+
158
+ self._tools[name] = tool_def
159
+
160
+ import os
161
+ if os.getenv("SKY_VERBOSE", "").lower() == "true":
162
+ from rich.console import Console
163
+ import json
164
+ Console().print(f"[dim]Schema for {name}: {json.dumps(tool_def.to_openai_schema(), indent=2)}[/dim]")
165
+
166
+ return func
167
+
168
+ return decorator
169
+
170
+ def get(self, name: str) -> Optional[ToolDefinition]:
171
+ """Get a tool definition by name."""
172
+ return self._tools.get(name)
173
+
174
+ def list(self, risk_tier: Optional[RiskTier] = None) -> List[ToolDefinition]:
175
+ """List all tools, optionally filtered by risk tier."""
176
+ if risk_tier:
177
+ return [t for t in self._tools.values() if t.risk_tier == risk_tier]
178
+ return list(self._tools.values())
179
+
180
+ def get_schemas(self, risk_tier: Optional[RiskTier] = None) -> List[Dict[str, Any]]:
181
+ """Get OpenAI function schemas for all tools, optionally filtered."""
182
+ tools = self.list(risk_tier)
183
+ return [t.to_openai_schema() for t in tools]
184
+
185
+ def execute(self, name: str, **kwargs: Any) -> Any:
186
+ """Execute a tool by name with the given arguments."""
187
+ tool = self.get(name)
188
+ if not tool:
189
+ raise ValueError(f"Tool not found: {name}")
190
+ return tool.func(**kwargs)
191
+
192
+
193
+ # Global registry instance and helper functions
194
+ _registry = ToolRegistry()
195
+
196
+
197
+ def register_tool(name: str, description: str, risk_tier: RiskTier) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
198
+ """Register a tool with the global registry."""
199
+ return _registry.register(name, description, risk_tier)
200
+
201
+
202
+ def get_tool(name: str) -> Optional[ToolDefinition]:
203
+ """Get a tool from the global registry."""
204
+ return _registry.get(name)
205
+
206
+
207
+ def list_tools(risk_tier: Optional[RiskTier] = None) -> List[ToolDefinition]:
208
+ """List tools from the global registry."""
209
+ return _registry.list(risk_tier)
210
+
211
+
212
+ def get_tools_by_risk(risk_tier: RiskTier) -> List[ToolDefinition]:
213
+ """Get tools matching a specific risk tier."""
214
+ return _registry.list(risk_tier)
215
+
216
+
217
+ def get_tool_schemas(risk_tier: Optional[RiskTier] = None) -> List[Dict[str, Any]]:
218
+ """Get OpenAI schemas from the global registry."""
219
+ return _registry.get_schemas(risk_tier)
@@ -0,0 +1,128 @@
1
+ """Search tools utilizing ripgrep."""
2
+
3
+ import json
4
+ import subprocess
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from sky.tools.registry import RiskTier, register_tool
9
+
10
+
11
+ @register_tool("grep", "Search for a pattern in files using ripgrep.", RiskTier.SAFE)
12
+ def grep(pattern: str, path: Optional[str] = None, glob_filter: Optional[str] = None) -> List[Dict[str, Any]]:
13
+ """Search for text patterns using ripgrep.
14
+
15
+ Args:
16
+ pattern: The regex pattern to search for.
17
+ path: Optional specific path or directory to search in.
18
+ glob_filter: Optional glob pattern to filter files (e.g. '*.py').
19
+ """
20
+ cmd = ["rg", "--json", pattern]
21
+ if glob_filter:
22
+ cmd.extend(["-g", glob_filter])
23
+ if path:
24
+ cmd.append(path)
25
+
26
+ try:
27
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
28
+
29
+ matches = []
30
+ for line in result.stdout.splitlines():
31
+ if not line.strip():
32
+ continue
33
+ try:
34
+ data = json.loads(line)
35
+ if data.get("type") == "match":
36
+ match_data = data.get("data", {})
37
+ matches.append({
38
+ "file": match_data.get("path", {}).get("text", ""),
39
+ "line_number": match_data.get("line_number"),
40
+ "line_text": match_data.get("lines", {}).get("text", ""),
41
+ "submatches": match_data.get("submatches", [])
42
+ })
43
+ except json.JSONDecodeError:
44
+ continue
45
+
46
+ return matches
47
+ except FileNotFoundError:
48
+ raise RuntimeError("rg (ripgrep) not found. Please install ripgrep.")
49
+
50
+
51
+ @register_tool("glob", "Find files matching a glob pattern.", RiskTier.SAFE)
52
+ def glob(pattern: str) -> List[str]:
53
+ """Find files using a glob pattern, respecting .gitignore.
54
+
55
+ Args:
56
+ pattern: Glob pattern to match files.
57
+ """
58
+ try:
59
+ cmd = ["rg", "--files", "-g", pattern]
60
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
61
+ paths = [p for p in result.stdout.splitlines() if p.strip()]
62
+ return paths
63
+ except (FileNotFoundError, subprocess.CalledProcessError):
64
+ paths = []
65
+ cwd = Path.cwd()
66
+ exclude = {".git", "node_modules", "venv", "__pycache__", ".sky"}
67
+
68
+ for p in cwd.rglob(pattern):
69
+ if p.is_file():
70
+ if not any(part in exclude for part in p.parts):
71
+ try:
72
+ paths.append(str(p.relative_to(cwd)))
73
+ except ValueError:
74
+ pass
75
+ return paths
76
+
77
+ import logging
78
+
79
+ logger = logging.getLogger(__name__)
80
+
81
+ @register_tool(
82
+ name="search_codebase",
83
+ description="Semantically search the repository for relevant code snippets, functions, or concepts. Use this for high-level queries like 'where is Docker handled' or 'how does approval work'.",
84
+ risk_tier=RiskTier.SAFE
85
+ )
86
+ def search_codebase(query: str, top_k: int = 3) -> dict:
87
+ """
88
+ Search the codebase semantically using vector embeddings.
89
+
90
+ Args:
91
+ query: The search query or concept to find
92
+ top_k: Number of results to return (default: 3)
93
+
94
+ Returns:
95
+ dict with 'results' list and 'total' count
96
+ """
97
+ try:
98
+ from sky.memory.vectorstore import get_vector_store
99
+ # Get vector store
100
+ store = get_vector_store(Path.home() / ".sky" / "lancedb")
101
+
102
+ # Search
103
+ results = store.search(query, top_k=top_k)
104
+
105
+ # Format results
106
+ formatted_results = []
107
+ for r in results:
108
+ formatted_results.append({
109
+ "file_path": r["file_path"],
110
+ "content": r["content"][:500] + ("..." if len(r["content"]) > 500 else ""),
111
+ "score": round(1 - r["score"], 3), # Convert distance to similarity
112
+ "language": r.get("language", "text"),
113
+ })
114
+
115
+ return {
116
+ "query": query,
117
+ "total": len(formatted_results),
118
+ "results": formatted_results,
119
+ }
120
+
121
+ except Exception as e:
122
+ logger.error(f"Search codebase error: {e}")
123
+ return {
124
+ "query": query,
125
+ "total": 0,
126
+ "results": [],
127
+ "error": str(e),
128
+ }
@@ -0,0 +1,73 @@
1
+ """Shell execution tools with Docker sandboxing."""
2
+
3
+ import os
4
+ import subprocess
5
+ from typing import Any, Dict
6
+
7
+ from sky.config import load_config
8
+ from sky.tools.registry import RiskTier, register_tool
9
+
10
+
11
+ @register_tool("bash", "Execute a bash command in a sandboxed Docker container.", RiskTier.DESTRUCTIVE)
12
+ def bash(command: str, timeout: int = 60) -> Dict[str, Any]:
13
+ """Run a bash command in a secure Docker sandbox.
14
+
15
+ Args:
16
+ command: The bash command to execute.
17
+ timeout: Maximum execution time in seconds.
18
+ """
19
+ config = load_config()
20
+
21
+ docker_image = config.docker_image
22
+ # HARD-BLOCK constraint
23
+ docker_network = "none"
24
+
25
+ workspace_dir = os.path.abspath(os.getcwd())
26
+
27
+ docker_cmd = [
28
+ "docker", "run", "--rm",
29
+ f"--network={docker_network}",
30
+ "--cap-drop=ALL",
31
+ "--user=1000:1000",
32
+ "-v", f"{workspace_dir}:/workspace:rw",
33
+ "-w", "/workspace",
34
+ docker_image,
35
+ "bash", "-c", command
36
+ ]
37
+
38
+ try:
39
+ result = subprocess.run(
40
+ docker_cmd,
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=timeout,
44
+ check=False
45
+ )
46
+ return {
47
+ "stdout": result.stdout,
48
+ "stderr": result.stderr,
49
+ "exit_code": result.returncode,
50
+ "success": result.returncode == 0
51
+ }
52
+ except subprocess.TimeoutExpired as e:
53
+ return {
54
+ "stdout": e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or ""),
55
+ "stderr": e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or ""),
56
+ "exit_code": 124,
57
+ "success": False,
58
+ "error": "Timeout expired"
59
+ }
60
+ except FileNotFoundError:
61
+ return {
62
+ "stdout": "",
63
+ "stderr": "Docker executable not found. Ensure Docker is installed and in PATH.",
64
+ "exit_code": -1,
65
+ "success": False
66
+ }
67
+ except Exception as e:
68
+ return {
69
+ "stdout": "",
70
+ "stderr": str(e),
71
+ "exit_code": -1,
72
+ "success": False
73
+ }