cowork-dash 0.1.9__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/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": "1", "display": "flex", "flexDirection": "column",
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]