cowork-dash 0.1.9__py3-none-any.whl → 0.2.1__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 +33 -17
- cowork_dash/app.py +1056 -160
- cowork_dash/assets/app.js +34 -0
- cowork_dash/assets/styles.css +804 -693
- cowork_dash/cli.py +9 -0
- cowork_dash/components.py +573 -59
- cowork_dash/config.py +12 -1
- cowork_dash/file_utils.py +43 -4
- cowork_dash/layout.py +43 -2
- cowork_dash/sandbox.py +361 -0
- cowork_dash/tools.py +656 -69
- {cowork_dash-0.1.9.dist-info → cowork_dash-0.2.1.dist-info}/METADATA +1 -1
- cowork_dash-0.2.1.dist-info/RECORD +23 -0
- cowork_dash-0.1.9.dist-info/RECORD +0 -22
- {cowork_dash-0.1.9.dist-info → cowork_dash-0.2.1.dist-info}/WHEEL +0 -0
- {cowork_dash-0.1.9.dist-info → cowork_dash-0.2.1.dist-info}/entry_points.txt +0 -0
- {cowork_dash-0.1.9.dist-info → cowork_dash-0.2.1.dist-info}/licenses/LICENSE +0 -0
cowork_dash/config.py
CHANGED
|
@@ -23,9 +23,15 @@ when no environment variables or CLI arguments are provided.
|
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
25
|
import os
|
|
26
|
+
import platform
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
def is_linux() -> bool:
|
|
31
|
+
"""Check if running on Linux."""
|
|
32
|
+
return platform.system() == "Linux"
|
|
33
|
+
|
|
34
|
+
|
|
29
35
|
def get_config(key: str, default=None, type_cast=None):
|
|
30
36
|
"""
|
|
31
37
|
Get configuration value with priority:
|
|
@@ -111,18 +117,23 @@ WELCOME_MESSAGE = get_config("welcome_message", default=_default_welcome)
|
|
|
111
117
|
# Virtual filesystem mode (for multi-user deployments)
|
|
112
118
|
# Environment variable: DEEPAGENT_VIRTUAL_FS
|
|
113
119
|
# Accepts: true/1/yes to enable
|
|
120
|
+
# IMPORTANT: Only available on Linux due to sandboxing requirements
|
|
114
121
|
# When enabled:
|
|
115
122
|
# - Each browser session gets isolated in-memory file storage
|
|
116
123
|
# - Files, canvas, and uploads are not shared between sessions
|
|
117
124
|
# - All data is ephemeral (cleared when session ends)
|
|
125
|
+
# - Bash commands run in bubblewrap sandbox for security
|
|
118
126
|
# When disabled (default):
|
|
119
127
|
# - All sessions share the same workspace directory on disk
|
|
120
128
|
# - Files persist on disk
|
|
121
|
-
|
|
129
|
+
_virtual_fs_requested = get_config(
|
|
122
130
|
"virtual_fs",
|
|
123
131
|
default=False,
|
|
124
132
|
type_cast=lambda x: str(x).lower() in ("true", "1", "yes")
|
|
125
133
|
)
|
|
134
|
+
# Virtual FS is only supported on Linux (requires bubblewrap for bash sandboxing)
|
|
135
|
+
VIRTUAL_FS = _virtual_fs_requested and is_linux()
|
|
136
|
+
VIRTUAL_FS_UNAVAILABLE_REASON = None if is_linux() else "Virtual filesystem mode requires Linux (uses bubblewrap for bash sandboxing)"
|
|
126
137
|
|
|
127
138
|
# Session timeout in seconds (only used when VIRTUAL_FS is True)
|
|
128
139
|
# Environment variable: DEEPAGENT_SESSION_TIMEOUT
|
cowork_dash/file_utils.py
CHANGED
|
@@ -136,8 +136,18 @@ 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 = "", expanded_folders: List[str] = None) -> 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
|
|
143
153
|
expanded_folders = expanded_folders or []
|
|
@@ -199,7 +209,7 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
|
|
|
199
209
|
|
|
200
210
|
if children:
|
|
201
211
|
# Children are loaded, render them
|
|
202
|
-
child_content = render_file_tree(children, colors, styles, level + 1, item["path"], expanded_folders)
|
|
212
|
+
child_content = render_file_tree(children, colors, styles, level + 1, item["path"], expanded_folders, workspace_root)
|
|
203
213
|
elif not has_children:
|
|
204
214
|
# Folder is known to be empty
|
|
205
215
|
child_content = [
|
|
@@ -210,8 +220,37 @@ def render_file_tree(items: List[Dict], colors: Dict, styles: Dict, level: int =
|
|
|
210
220
|
"fontStyle": "italic",
|
|
211
221
|
})
|
|
212
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
|
+
]
|
|
213
252
|
else:
|
|
214
|
-
# Children not yet loaded (lazy loading)
|
|
253
|
+
# Children not yet loaded (lazy loading) and folder is collapsed
|
|
215
254
|
child_content = [
|
|
216
255
|
html.Div("Loading...",
|
|
217
256
|
id={"type": "folder-loading", "path": folder_id},
|
cowork_dash/layout.py
CHANGED
|
@@ -49,9 +49,11 @@ 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
|
|
56
|
+
dcc.Store(id="sidebar-collapsed", data=False), # Track if sidebar is collapsed
|
|
55
57
|
dcc.Download(id="file-download"),
|
|
56
58
|
|
|
57
59
|
# Interval for polling agent updates (disabled by default)
|
|
@@ -133,6 +135,22 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
133
135
|
opened=False,
|
|
134
136
|
),
|
|
135
137
|
|
|
138
|
+
# Fullscreen preview modal for HTML/PDF
|
|
139
|
+
dmc.Modal(
|
|
140
|
+
id="fullscreen-preview-modal",
|
|
141
|
+
title="Preview",
|
|
142
|
+
size="100%",
|
|
143
|
+
children=[
|
|
144
|
+
html.Div(id="fullscreen-preview-content", style={"height": "calc(100vh - 120px)"})
|
|
145
|
+
],
|
|
146
|
+
opened=False,
|
|
147
|
+
styles={
|
|
148
|
+
"content": {"height": "95vh", "maxHeight": "95vh"},
|
|
149
|
+
"body": {"height": "calc(100% - 60px)", "padding": "0"},
|
|
150
|
+
},
|
|
151
|
+
),
|
|
152
|
+
dcc.Store(id="fullscreen-preview-data", data=None),
|
|
153
|
+
|
|
136
154
|
html.Div([
|
|
137
155
|
# Compact Header
|
|
138
156
|
html.Header([
|
|
@@ -209,7 +227,7 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
209
227
|
"background": "var(--mantine-color-body)",
|
|
210
228
|
}),
|
|
211
229
|
], id="chat-panel", style={
|
|
212
|
-
"flex": "
|
|
230
|
+
"flex": "3", "display": "flex", "flexDirection": "column",
|
|
213
231
|
"background": "var(--mantine-color-body)", "minWidth": "0",
|
|
214
232
|
}),
|
|
215
233
|
|
|
@@ -263,6 +281,13 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
263
281
|
variant="default",
|
|
264
282
|
size="md",
|
|
265
283
|
),
|
|
284
|
+
dmc.ActionIcon(
|
|
285
|
+
DashIconify(icon="mdi:chevron-right", width=18),
|
|
286
|
+
id="collapse-sidebar-btn",
|
|
287
|
+
variant="subtle",
|
|
288
|
+
size="md",
|
|
289
|
+
**{"aria-label": "Collapse sidebar"},
|
|
290
|
+
),
|
|
266
291
|
], id="files-actions", gap=5)
|
|
267
292
|
], id="sidebar-header", style={
|
|
268
293
|
"display": "flex", "justifyContent": "space-between",
|
|
@@ -295,7 +320,6 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
295
320
|
], className="breadcrumb-bar", style={
|
|
296
321
|
"padding": "6px 10px",
|
|
297
322
|
"borderBottom": "1px solid var(--mantine-color-default-border)",
|
|
298
|
-
"background": "var(--mantine-color-gray-0)",
|
|
299
323
|
}),
|
|
300
324
|
html.Div(
|
|
301
325
|
id="file-tree",
|
|
@@ -346,6 +370,23 @@ def create_layout(workspace_root, app_title, app_subtitle, colors, styles, agent
|
|
|
346
370
|
"background": "var(--mantine-color-body)",
|
|
347
371
|
"borderLeft": "1px solid var(--mantine-color-default-border)",
|
|
348
372
|
}),
|
|
373
|
+
|
|
374
|
+
# Expand button (shown when sidebar is collapsed)
|
|
375
|
+
html.Div([
|
|
376
|
+
dmc.ActionIcon(
|
|
377
|
+
DashIconify(icon="mdi:chevron-left", width=18),
|
|
378
|
+
id="expand-sidebar-btn",
|
|
379
|
+
variant="subtle",
|
|
380
|
+
size="md",
|
|
381
|
+
**{"aria-label": "Expand sidebar"},
|
|
382
|
+
),
|
|
383
|
+
], id="sidebar-expand-btn", style={
|
|
384
|
+
"display": "none",
|
|
385
|
+
"alignItems": "flex-start",
|
|
386
|
+
"paddingTop": "10px",
|
|
387
|
+
"borderLeft": "1px solid var(--mantine-color-default-border)",
|
|
388
|
+
"background": "var(--mantine-color-body)",
|
|
389
|
+
}),
|
|
349
390
|
], id="main-container", style={"display": "flex", "flex": "1", "overflow": "hidden"}),
|
|
350
391
|
], id="app-container", style={"display": "flex", "flexDirection": "column", "height": "100vh"})
|
|
351
392
|
])
|
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]
|