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/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
- VIRTUAL_FS = get_config(
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": "1", "display": "flex", "flexDirection": "column",
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]