cowork-dash 0.1.8__py3-none-any.whl → 0.2.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.
- cowork_dash/agent.py +32 -11
- cowork_dash/app.py +888 -81
- cowork_dash/assets/app.js +34 -0
- cowork_dash/assets/styles.css +788 -697
- cowork_dash/canvas.py +8 -0
- cowork_dash/cli.py +9 -0
- cowork_dash/components.py +398 -55
- cowork_dash/config.py +12 -1
- cowork_dash/file_utils.py +65 -9
- cowork_dash/layout.py +2 -2
- cowork_dash/sandbox.py +361 -0
- cowork_dash/tools.py +734 -79
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/METADATA +1 -1
- cowork_dash-0.2.0.dist-info/RECORD +23 -0
- cowork_dash-0.1.8.dist-info/RECORD +0 -22
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.8.dist-info → cowork_dash-0.2.0.dist-info}/licenses/LICENSE +0 -0
cowork_dash/file_utils.py
CHANGED
|
@@ -136,15 +136,27 @@ def load_folder_contents(
|
|
|
136
136
|
return build_file_tree(full_path, workspace_root, lazy_load=True)
|
|
137
137
|
|
|
138
138
|
|
|
139
|
-
def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int = 0, parent_path: str = "") -> List:
|
|
140
|
-
"""Render file tree with collapsible folders using CSS classes for theming.
|
|
139
|
+
def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int = 0, parent_path: str = "", expanded_folders: List[str] = None, workspace_root: AnyRoot = None) -> List:
|
|
140
|
+
"""Render file tree with collapsible folders using CSS classes for theming.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
items: List of file/folder items from build_file_tree
|
|
144
|
+
colors: Theme colors dict
|
|
145
|
+
styles: Style dict
|
|
146
|
+
level: Current nesting level
|
|
147
|
+
parent_path: Path of parent folder
|
|
148
|
+
expanded_folders: List of folder IDs that should be expanded
|
|
149
|
+
workspace_root: Workspace root for loading expanded folder contents
|
|
150
|
+
"""
|
|
141
151
|
components = []
|
|
142
152
|
indent = level * 15 # Scaled up indent
|
|
153
|
+
expanded_folders = expanded_folders or []
|
|
143
154
|
|
|
144
155
|
for item in items:
|
|
145
156
|
if item["type"] == "folder":
|
|
146
157
|
folder_id = item["path"].replace("/", "_").replace("\\", "_")
|
|
147
158
|
children = item.get("children", [])
|
|
159
|
+
is_expanded = folder_id in expanded_folders
|
|
148
160
|
|
|
149
161
|
# Folder header with expand icon and selectable name
|
|
150
162
|
components.append(
|
|
@@ -160,6 +172,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
|
|
|
160
172
|
"transition": "transform 0.15s",
|
|
161
173
|
"display": "inline-block",
|
|
162
174
|
"padding": "2px",
|
|
175
|
+
"transform": "rotate(90deg)" if is_expanded else "rotate(0deg)",
|
|
163
176
|
}
|
|
164
177
|
),
|
|
165
178
|
# Folder name (clickable for selection)
|
|
@@ -196,7 +209,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
|
|
|
196
209
|
|
|
197
210
|
if children:
|
|
198
211
|
# Children are loaded, render them
|
|
199
|
-
child_content = render_file_tree(children, colors, styles, level + 1, item["path"])
|
|
212
|
+
child_content = render_file_tree(children, colors, styles, level + 1, item["path"], expanded_folders, workspace_root)
|
|
200
213
|
elif not has_children:
|
|
201
214
|
# Folder is known to be empty
|
|
202
215
|
child_content = [
|
|
@@ -207,8 +220,37 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
|
|
|
207
220
|
"fontStyle": "italic",
|
|
208
221
|
})
|
|
209
222
|
]
|
|
223
|
+
elif is_expanded and workspace_root is not None:
|
|
224
|
+
# Folder is expanded but children not loaded - load them now
|
|
225
|
+
# This happens when rebuilding the tree with preserved expanded_folders state
|
|
226
|
+
try:
|
|
227
|
+
folder_items = load_folder_contents(item["path"], workspace_root)
|
|
228
|
+
child_content = render_file_tree(folder_items, colors, styles, level + 1, item["path"], expanded_folders, workspace_root)
|
|
229
|
+
if not child_content:
|
|
230
|
+
child_content = [
|
|
231
|
+
html.Div("(empty)", className="file-tree-empty", style={
|
|
232
|
+
"padding": "4px 10px",
|
|
233
|
+
"paddingLeft": f"{25 + (level + 1) * 15}px",
|
|
234
|
+
"fontSize": "12px",
|
|
235
|
+
"fontStyle": "italic",
|
|
236
|
+
})
|
|
237
|
+
]
|
|
238
|
+
except Exception:
|
|
239
|
+
# Fall back to loading placeholder if loading fails
|
|
240
|
+
child_content = [
|
|
241
|
+
html.Div("Loading...",
|
|
242
|
+
id={"type": "folder-loading", "path": folder_id},
|
|
243
|
+
className="file-tree-loading",
|
|
244
|
+
style={
|
|
245
|
+
"padding": "4px 10px",
|
|
246
|
+
"paddingLeft": f"{25 + (level + 1) * 15}px",
|
|
247
|
+
"fontSize": "12px",
|
|
248
|
+
"fontStyle": "italic",
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
]
|
|
210
252
|
else:
|
|
211
|
-
# Children not yet loaded (lazy loading)
|
|
253
|
+
# Children not yet loaded (lazy loading) and folder is collapsed
|
|
212
254
|
child_content = [
|
|
213
255
|
html.Div("Loading...",
|
|
214
256
|
id={"type": "folder-loading", "path": folder_id},
|
|
@@ -226,7 +268,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
|
|
|
226
268
|
html.Div(
|
|
227
269
|
child_content,
|
|
228
270
|
id={"type": "folder-children", "path": folder_id},
|
|
229
|
-
style={"display": "
|
|
271
|
+
style={"display": "block" if is_expanded else "none"}
|
|
230
272
|
)
|
|
231
273
|
)
|
|
232
274
|
else:
|
|
@@ -288,11 +330,25 @@ def get_file_download_data(
|
|
|
288
330
|
# Determine MIME type
|
|
289
331
|
ext = PurePosixPath(path).suffix.lower()
|
|
290
332
|
mime_types = {
|
|
333
|
+
# Text
|
|
291
334
|
".txt": "text/plain", ".py": "text/x-python", ".js": "text/javascript",
|
|
292
|
-
".json": "application/json", ".html": "text/html", ".
|
|
293
|
-
".
|
|
294
|
-
".
|
|
295
|
-
|
|
335
|
+
".json": "application/json", ".html": "text/html", ".htm": "text/html",
|
|
336
|
+
".css": "text/css", ".md": "text/markdown", ".csv": "text/csv",
|
|
337
|
+
".xml": "text/xml", ".yaml": "text/yaml", ".yml": "text/yaml",
|
|
338
|
+
# Images
|
|
339
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
340
|
+
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml",
|
|
341
|
+
".ico": "image/x-icon", ".bmp": "image/bmp",
|
|
342
|
+
# Video
|
|
343
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".ogg": "video/ogg",
|
|
344
|
+
".mov": "video/quicktime",
|
|
345
|
+
# Audio
|
|
346
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
|
347
|
+
".flac": "audio/flac",
|
|
348
|
+
# Documents
|
|
349
|
+
".pdf": "application/pdf",
|
|
350
|
+
# Archives
|
|
351
|
+
".zip": "application/zip",
|
|
296
352
|
}
|
|
297
353
|
mime = mime_types.get(ext, "application/octet-stream")
|
|
298
354
|
|
cowork_dash/layout.py
CHANGED
|
@@ -49,6 +49,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
49
49
|
dcc.Store(id="expanded-folders", data=[]),
|
|
50
50
|
dcc.Store(id="file-to-view", data=None),
|
|
51
51
|
dcc.Store(id="file-click-tracker", data={}),
|
|
52
|
+
dcc.Store(id="csv-pagination", data={"page": 0, "total_pages": 0, "rows_per_page": 50}),
|
|
52
53
|
dcc.Store(id="theme-store", data="light", storage_type="local"),
|
|
53
54
|
dcc.Store(id="current-workspace-path", data=""), # Relative path from original workspace root
|
|
54
55
|
dcc.Store(id="collapsed-canvas-items", data=[]), # Track which canvas items are collapsed
|
|
@@ -209,7 +210,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
209
210
|
"background": "var(--mantine-color-body)",
|
|
210
211
|
}),
|
|
211
212
|
], id="chat-panel", style={
|
|
212
|
-
"flex": "
|
|
213
|
+
"flex": "3", "display": "flex", "flexDirection": "column",
|
|
213
214
|
"background": "var(--mantine-color-body)", "minWidth": "0",
|
|
214
215
|
}),
|
|
215
216
|
|
|
@@ -295,7 +296,6 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
295
296
|
], className="breadcrumb-bar", style={
|
|
296
297
|
"padding": "6px 10px",
|
|
297
298
|
"borderBottom": "1px solid var(--mantine-color-default-border)",
|
|
298
|
-
"background": "var(--mantine-color-gray-0)",
|
|
299
299
|
}),
|
|
300
300
|
html.Div(
|
|
301
301
|
id="file-tree",
|
cowork_dash/sandbox.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Sandbox execution for bash commands in virtual filesystem mode.
|
|
2
|
+
|
|
3
|
+
Provides secure command execution using bubblewrap (Linux) or Docker as fallback.
|
|
4
|
+
This module is only used when VIRTUAL_FS mode is enabled.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .virtual_fs import VirtualFilesystem
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SandboxError(Exception):
|
|
18
|
+
"""Error during sandbox execution."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SandboxUnavailableError(SandboxError):
|
|
23
|
+
"""No sandbox backend available."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_bubblewrap_available() -> bool:
|
|
28
|
+
"""Check if bubblewrap (bwrap) is available on the system."""
|
|
29
|
+
return shutil.which("bwrap") is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_docker_available() -> bool:
|
|
33
|
+
"""Check if Docker is available and running."""
|
|
34
|
+
if not shutil.which("docker"):
|
|
35
|
+
return False
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["docker", "info"],
|
|
39
|
+
capture_output=True,
|
|
40
|
+
timeout=5
|
|
41
|
+
)
|
|
42
|
+
return result.returncode == 0
|
|
43
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_available_sandbox() -> Optional[str]:
|
|
48
|
+
"""Get the best available sandbox backend.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
"bubblewrap", "docker", or None if no sandbox available
|
|
52
|
+
"""
|
|
53
|
+
if check_bubblewrap_available():
|
|
54
|
+
return "bubblewrap"
|
|
55
|
+
if check_docker_available():
|
|
56
|
+
return "docker"
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SandboxedBashExecutor:
|
|
61
|
+
"""Execute bash commands in a sandboxed environment.
|
|
62
|
+
|
|
63
|
+
Syncs files between VirtualFilesystem and a temp directory,
|
|
64
|
+
runs commands in a sandbox, and syncs results back.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, fs: VirtualFilesystem, session_id: str):
|
|
68
|
+
"""Initialize executor with a virtual filesystem.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
fs: The VirtualFilesystem to sync with
|
|
72
|
+
session_id: Unique session identifier for temp dir naming
|
|
73
|
+
"""
|
|
74
|
+
self.fs = fs
|
|
75
|
+
self.session_id = session_id
|
|
76
|
+
self._temp_dir: Optional[Path] = None
|
|
77
|
+
self._sandbox_backend = get_available_sandbox()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def temp_dir(self) -> Path:
|
|
81
|
+
"""Get or create the persistent temp directory for this session."""
|
|
82
|
+
if self._temp_dir is None or not self._temp_dir.exists():
|
|
83
|
+
# Create session-specific temp directory
|
|
84
|
+
base_temp = Path(tempfile.gettempdir()) / "cowork-sandbox"
|
|
85
|
+
base_temp.mkdir(exist_ok=True)
|
|
86
|
+
self._temp_dir = base_temp / f"session-{self.session_id}"
|
|
87
|
+
self._temp_dir.mkdir(exist_ok=True)
|
|
88
|
+
# Initial sync from virtual FS to temp dir
|
|
89
|
+
self._sync_to_disk()
|
|
90
|
+
return self._temp_dir
|
|
91
|
+
|
|
92
|
+
def _sync_to_disk(self) -> None:
|
|
93
|
+
"""Sync virtual filesystem contents to the temp directory."""
|
|
94
|
+
# Clear existing files in temp dir (but keep the dir)
|
|
95
|
+
for item in self.temp_dir.iterdir():
|
|
96
|
+
if item.is_file():
|
|
97
|
+
item.unlink()
|
|
98
|
+
elif item.is_dir():
|
|
99
|
+
shutil.rmtree(item)
|
|
100
|
+
|
|
101
|
+
# Copy all files from virtual FS
|
|
102
|
+
for path_str, content in self.fs._files.items():
|
|
103
|
+
# Convert virtual path to relative path
|
|
104
|
+
rel_path = path_str.lstrip("/")
|
|
105
|
+
if rel_path.startswith("workspace/"):
|
|
106
|
+
rel_path = rel_path[len("workspace/"):]
|
|
107
|
+
|
|
108
|
+
target = self.temp_dir / rel_path
|
|
109
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
target.write_bytes(content)
|
|
111
|
+
|
|
112
|
+
def _sync_from_disk(self) -> None:
|
|
113
|
+
"""Sync temp directory contents back to virtual filesystem."""
|
|
114
|
+
# Walk the temp directory and update virtual FS
|
|
115
|
+
for root, dirs, files in os.walk(self.temp_dir):
|
|
116
|
+
rel_root = Path(root).relative_to(self.temp_dir)
|
|
117
|
+
|
|
118
|
+
# Create directories in virtual FS
|
|
119
|
+
for d in dirs:
|
|
120
|
+
vpath = f"/workspace/{rel_root / d}" if str(rel_root) != "." else f"/workspace/{d}"
|
|
121
|
+
vpath = vpath.replace("//", "/")
|
|
122
|
+
try:
|
|
123
|
+
self.fs.mkdir(vpath, parents=True, exist_ok=True)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
# Copy files to virtual FS
|
|
128
|
+
for f in files:
|
|
129
|
+
file_path = Path(root) / f
|
|
130
|
+
vpath = f"/workspace/{rel_root / f}" if str(rel_root) != "." else f"/workspace/{f}"
|
|
131
|
+
vpath = vpath.replace("//", "/")
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
content = file_path.read_bytes()
|
|
135
|
+
# Ensure parent exists
|
|
136
|
+
parent = "/".join(vpath.split("/")[:-1])
|
|
137
|
+
if parent:
|
|
138
|
+
self.fs.mkdir(parent, parents=True, exist_ok=True)
|
|
139
|
+
self.fs.write_bytes(vpath, content)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# Remove files from virtual FS that no longer exist on disk
|
|
144
|
+
existing_files = set()
|
|
145
|
+
for root, _, files in os.walk(self.temp_dir):
|
|
146
|
+
rel_root = Path(root).relative_to(self.temp_dir)
|
|
147
|
+
for f in files:
|
|
148
|
+
vpath = f"/workspace/{rel_root / f}" if str(rel_root) != "." else f"/workspace/{f}"
|
|
149
|
+
existing_files.add(vpath.replace("//", "/"))
|
|
150
|
+
|
|
151
|
+
# Get list of files to remove (avoid modifying dict during iteration)
|
|
152
|
+
to_remove = []
|
|
153
|
+
for vpath in list(self.fs._files.keys()):
|
|
154
|
+
if vpath.startswith("/workspace/") and vpath not in existing_files:
|
|
155
|
+
# Skip .canvas directory
|
|
156
|
+
if not vpath.startswith("/workspace/.canvas"):
|
|
157
|
+
to_remove.append(vpath)
|
|
158
|
+
|
|
159
|
+
for vpath in to_remove:
|
|
160
|
+
try:
|
|
161
|
+
self.fs.unlink(vpath, missing_ok=True)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
def execute(
|
|
166
|
+
self,
|
|
167
|
+
command: str,
|
|
168
|
+
timeout: int = 60,
|
|
169
|
+
env: Optional[Dict[str, str]] = None
|
|
170
|
+
) -> Dict[str, Any]:
|
|
171
|
+
"""Execute a bash command in the sandbox.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
command: The bash command to execute
|
|
175
|
+
timeout: Maximum execution time in seconds
|
|
176
|
+
env: Additional environment variables
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Dict with stdout, stderr, return_code, status
|
|
180
|
+
"""
|
|
181
|
+
if self._sandbox_backend is None:
|
|
182
|
+
return {
|
|
183
|
+
"stdout": "",
|
|
184
|
+
"stderr": "No sandbox available. Install bubblewrap (bwrap) or Docker.",
|
|
185
|
+
"return_code": 1,
|
|
186
|
+
"status": "error"
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# Sync virtual FS to disk before execution
|
|
190
|
+
self._sync_to_disk()
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
if self._sandbox_backend == "bubblewrap":
|
|
194
|
+
result = self._execute_bubblewrap(command, timeout, env)
|
|
195
|
+
else:
|
|
196
|
+
result = self._execute_docker(command, timeout, env)
|
|
197
|
+
|
|
198
|
+
# Sync changes back to virtual FS after execution
|
|
199
|
+
self._sync_from_disk()
|
|
200
|
+
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
except subprocess.TimeoutExpired:
|
|
204
|
+
return {
|
|
205
|
+
"stdout": "",
|
|
206
|
+
"stderr": f"Command timed out after {timeout} seconds",
|
|
207
|
+
"return_code": 124,
|
|
208
|
+
"status": "error"
|
|
209
|
+
}
|
|
210
|
+
except Exception as e:
|
|
211
|
+
return {
|
|
212
|
+
"stdout": "",
|
|
213
|
+
"stderr": str(e),
|
|
214
|
+
"return_code": 1,
|
|
215
|
+
"status": "error"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
def _execute_bubblewrap(
|
|
219
|
+
self,
|
|
220
|
+
command: str,
|
|
221
|
+
timeout: int,
|
|
222
|
+
env: Optional[Dict[str, str]] = None
|
|
223
|
+
) -> Dict[str, Any]:
|
|
224
|
+
"""Execute command using bubblewrap sandbox."""
|
|
225
|
+
# Build bubblewrap command
|
|
226
|
+
bwrap_cmd = [
|
|
227
|
+
"bwrap",
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
# Mount system directories read-only (only if they exist)
|
|
231
|
+
# Different distros have different layouts (e.g., Debian doesn't have /lib64)
|
|
232
|
+
system_dirs = ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc"]
|
|
233
|
+
for sysdir in system_dirs:
|
|
234
|
+
if Path(sysdir).exists():
|
|
235
|
+
bwrap_cmd.extend(["--ro-bind", sysdir, sysdir])
|
|
236
|
+
|
|
237
|
+
# Mount workspace read-write
|
|
238
|
+
bwrap_cmd.extend([
|
|
239
|
+
"--bind", str(self.temp_dir), "/workspace",
|
|
240
|
+
# Create minimal /tmp
|
|
241
|
+
"--tmpfs", "/tmp",
|
|
242
|
+
# Create /dev with minimal devices
|
|
243
|
+
"--dev", "/dev",
|
|
244
|
+
# Create /proc (needed by some tools)
|
|
245
|
+
"--proc", "/proc",
|
|
246
|
+
# Isolate network
|
|
247
|
+
"--unshare-net",
|
|
248
|
+
# Isolate PID namespace
|
|
249
|
+
"--unshare-pid",
|
|
250
|
+
# Set working directory
|
|
251
|
+
"--chdir", "/workspace",
|
|
252
|
+
# Clear environment and set minimal
|
|
253
|
+
"--clearenv",
|
|
254
|
+
"--setenv", "PATH", "/usr/local/bin:/usr/bin:/bin",
|
|
255
|
+
"--setenv", "HOME", "/workspace",
|
|
256
|
+
"--setenv", "TERM", "xterm-256color",
|
|
257
|
+
])
|
|
258
|
+
|
|
259
|
+
# Add custom environment variables
|
|
260
|
+
if env:
|
|
261
|
+
for key, value in env.items():
|
|
262
|
+
bwrap_cmd.extend(["--setenv", key, value])
|
|
263
|
+
|
|
264
|
+
# Add the actual command
|
|
265
|
+
bwrap_cmd.extend(["--", "/bin/bash", "-c", command])
|
|
266
|
+
|
|
267
|
+
# Execute
|
|
268
|
+
result = subprocess.run(
|
|
269
|
+
bwrap_cmd,
|
|
270
|
+
capture_output=True,
|
|
271
|
+
timeout=timeout,
|
|
272
|
+
text=True
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
"stdout": result.stdout,
|
|
277
|
+
"stderr": result.stderr,
|
|
278
|
+
"return_code": result.returncode,
|
|
279
|
+
"status": "success" if result.returncode == 0 else "error"
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
def _execute_docker(
|
|
283
|
+
self,
|
|
284
|
+
command: str,
|
|
285
|
+
timeout: int,
|
|
286
|
+
env: Optional[Dict[str, str]] = None
|
|
287
|
+
) -> Dict[str, Any]:
|
|
288
|
+
"""Execute command using Docker container."""
|
|
289
|
+
docker_cmd = [
|
|
290
|
+
"docker", "run",
|
|
291
|
+
"--rm",
|
|
292
|
+
"--network", "none", # No network access
|
|
293
|
+
"--memory", "512m", # Memory limit
|
|
294
|
+
"--cpus", "1", # CPU limit
|
|
295
|
+
"-v", f"{self.temp_dir}:/workspace",
|
|
296
|
+
"-w", "/workspace",
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
# Add environment variables
|
|
300
|
+
if env:
|
|
301
|
+
for key, value in env.items():
|
|
302
|
+
docker_cmd.extend(["-e", f"{key}={value}"])
|
|
303
|
+
|
|
304
|
+
# Use a minimal Python image (widely available)
|
|
305
|
+
docker_cmd.extend([
|
|
306
|
+
"python:3.11-slim",
|
|
307
|
+
"/bin/bash", "-c", command
|
|
308
|
+
])
|
|
309
|
+
|
|
310
|
+
# Execute
|
|
311
|
+
result = subprocess.run(
|
|
312
|
+
docker_cmd,
|
|
313
|
+
capture_output=True,
|
|
314
|
+
timeout=timeout,
|
|
315
|
+
text=True
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
"stdout": result.stdout,
|
|
320
|
+
"stderr": result.stderr,
|
|
321
|
+
"return_code": result.returncode,
|
|
322
|
+
"status": "success" if result.returncode == 0 else "error"
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
def cleanup(self) -> None:
|
|
326
|
+
"""Clean up the temp directory for this session."""
|
|
327
|
+
if self._temp_dir and self._temp_dir.exists():
|
|
328
|
+
try:
|
|
329
|
+
shutil.rmtree(self._temp_dir)
|
|
330
|
+
except Exception:
|
|
331
|
+
pass
|
|
332
|
+
self._temp_dir = None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# Session executor cache
|
|
336
|
+
_session_executors: Dict[str, SandboxedBashExecutor] = {}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def get_executor_for_session(
|
|
340
|
+
session_id: str,
|
|
341
|
+
fs: VirtualFilesystem
|
|
342
|
+
) -> SandboxedBashExecutor:
|
|
343
|
+
"""Get or create a sandboxed executor for a session.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
session_id: The session identifier
|
|
347
|
+
fs: The VirtualFilesystem for this session
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
SandboxedBashExecutor instance
|
|
351
|
+
"""
|
|
352
|
+
if session_id not in _session_executors:
|
|
353
|
+
_session_executors[session_id] = SandboxedBashExecutor(fs, session_id)
|
|
354
|
+
return _session_executors[session_id]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cleanup_session_executor(session_id: str) -> None:
|
|
358
|
+
"""Clean up executor for a session."""
|
|
359
|
+
if session_id in _session_executors:
|
|
360
|
+
_session_executors[session_id].cleanup()
|
|
361
|
+
del _session_executors[session_id]
|