@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0

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.
Files changed (99) hide show
  1. package/package.json +2 -2
  2. package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
  3. package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
  4. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
  5. package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
  6. package/vendor/agent-frontend-shell/api/config.py +145 -104
  7. package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
  8. package/vendor/agent-frontend-shell/api/helpers.py +4 -2
  9. package/vendor/agent-frontend-shell/api/models.py +202 -20
  10. package/vendor/agent-frontend-shell/api/paths.py +77 -0
  11. package/vendor/agent-frontend-shell/api/plugins.py +185 -0
  12. package/vendor/agent-frontend-shell/api/profiles.py +95 -16
  13. package/vendor/agent-frontend-shell/api/routes.py +831 -30
  14. package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
  15. package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
  16. package/vendor/agent-frontend-shell/api/streaming.py +211 -56
  17. package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
  18. package/vendor/agent-frontend-shell/api/updates.py +30 -3
  19. package/vendor/agent-frontend-shell/api/upload.py +251 -18
  20. package/vendor/agent-frontend-shell/api/workspace.py +323 -65
  21. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
  22. package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
  23. package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
  24. package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
  25. package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
  26. package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
  27. package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
  28. package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
  29. package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
  30. package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
  31. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
  32. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
  33. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
  34. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
  35. package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
  36. package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
  37. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
  38. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
  39. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
  40. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
  41. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
  42. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
  43. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
  44. package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
  45. package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
  46. package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
  47. package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
  48. package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
  49. package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
  50. package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
  51. package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
  52. package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
  53. package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
  54. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
  55. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
  56. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
  57. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
  58. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
  59. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
  60. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
  61. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
  62. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
  63. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
  64. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
  65. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
  66. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
  67. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
  68. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
  69. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
  70. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
  71. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
  72. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
  73. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
  74. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
  75. package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
  76. package/vendor/agent-frontend-shell/build-release.sh +62 -0
  77. package/vendor/agent-frontend-shell/ctl.sh +1 -0
  78. package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
  79. package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
  80. package/vendor/agent-frontend-shell/docker_init.bash +1 -0
  81. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
  82. package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
  83. package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
  84. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
  85. package/vendor/agent-frontend-shell/readme-simple.md +103 -0
  86. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  87. package/vendor/agent-frontend-shell/server.py +7 -0
  88. package/vendor/agent-frontend-shell/static/boot.js +53 -1
  89. package/vendor/agent-frontend-shell/static/commands.js +20 -10
  90. package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
  91. package/vendor/agent-frontend-shell/static/index.html +13 -3
  92. package/vendor/agent-frontend-shell/static/messages.js +48 -3
  93. package/vendor/agent-frontend-shell/static/panels.js +199 -30
  94. package/vendor/agent-frontend-shell/static/sessions.js +249 -39
  95. package/vendor/agent-frontend-shell/static/style.css +46 -2
  96. package/vendor/agent-frontend-shell/static/ui.js +323 -79
  97. package/vendor/agent-frontend-shell/static/workspace.js +185 -7
  98. package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
  99. package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
@@ -11,18 +11,59 @@ from pathlib import Path
11
11
  from api.config import MAX_UPLOAD_BYTES, STATE_DIR
12
12
  from api.helpers import j, bad
13
13
  from api.models import get_session
14
- from api.workspace import safe_resolve_ws
14
+ from api.workspace import safe_resolve_ws, resolve_trusted_workspace, open_anchored_create_fd, make_anchored_dir
15
15
 
16
+
17
+ def _max_extracted_bytes() -> int:
18
+ """Total-extracted-bytes cap for archive uploads (zip/tar-bomb guard).
19
+
20
+ Independently tunable from the upload size cap via
21
+ HERMES_WEBUI_MAX_EXTRACTED_MB; defaults to 10x the upload cap. Read at call
22
+ time (not import) so the value reflects the running process's environment
23
+ and is exercisable by tests against the out-of-process test server.
24
+ """
25
+ raw = os.getenv("HERMES_WEBUI_MAX_EXTRACTED_MB", "").strip()
26
+ if raw:
27
+ try:
28
+ mb = float(raw)
29
+ if mb > 0:
30
+ return int(mb * 1024 * 1024)
31
+ except ValueError:
32
+ pass
33
+ return 10 * MAX_UPLOAD_BYTES
34
+
35
+
36
+ # Back-compat module constant (some call sites / tests reference it). The
37
+ # authoritative value is _max_extracted_bytes(), read at extraction time.
16
38
  _MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES
17
39
 
18
40
 
19
41
  def parse_multipart(rfile, content_type, content_length) -> tuple:
20
42
  import re as _re, email.parser as _ep
43
+ # Imported locally (not just module-level) so the function stays
44
+ # self-contained — some tests exec() this function's source in an isolated
45
+ # namespace, and a bare module global would NameError there.
46
+ try:
47
+ from api.config import MAX_UPLOAD_BYTES as _MAX_UPLOAD_BYTES
48
+ except Exception:
49
+ _MAX_UPLOAD_BYTES = 20 * 1024 * 1024
21
50
  m = _re.search(r'boundary=([^;\s]+)', content_type)
22
51
  if not m:
23
52
  raise ValueError('No boundary in Content-Type')
24
53
  boundary = m.group(1).strip('"').encode()
25
- raw = rfile.read(content_length)
54
+ # Centralized length guard for ALL upload callers: a missing/garbage or
55
+ # NEGATIVE Content-Length must never reach rfile.read(<0), which reads the
56
+ # stream unbounded (read(-1) == read-to-EOF) and bypasses the per-handler
57
+ # size cap. Reject anything not in [0, MAX_UPLOAD_BYTES].
58
+ try:
59
+ length = int(content_length)
60
+ except (TypeError, ValueError):
61
+ raise ValueError('Invalid Content-Length') from None
62
+ if length < 0:
63
+ raise ValueError('Invalid Content-Length (negative)')
64
+ if length > _MAX_UPLOAD_BYTES:
65
+ raise ValueError(f'Upload too large (max {_MAX_UPLOAD_BYTES} bytes)')
66
+ raw = rfile.read(length)
26
67
  fields = {}
27
68
  files = {}
28
69
  delimiter = b'--' + boundary
@@ -146,6 +187,7 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
146
187
  """
147
188
  import zipfile, tarfile, io, os, shutil
148
189
 
190
+ cap = _max_extracted_bytes()
149
191
  name = Path(filename).name
150
192
  stem = Path(filename).stem # strip .zip / .tar.gz etc.
151
193
 
@@ -158,13 +200,23 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
158
200
 
159
201
  # Determine destination directory — use archive stem as folder name
160
202
  dest_dir = safe_resolve_ws(workspace, stem)
161
- # Avoid overwriting existing files by appending a suffix
203
+ # Avoid overwriting existing files by appending a suffix (bounded — astronomically
204
+ # unlikely to collide, but never spin forever).
162
205
  if dest_dir.exists():
163
206
  import string, random
164
- while dest_dir.exists():
207
+ for _ in range(1000):
208
+ if not dest_dir.exists():
209
+ break
165
210
  suffix = ''.join(random.choices(string.digits, k=3))
166
- dest_dir = dest_dir.with_name(stem + '_' + suffix)
167
- dest_dir.mkdir(parents=True, exist_ok=True)
211
+ dest_dir = safe_resolve_ws(workspace, stem).with_name(stem + '_' + suffix)
212
+ else:
213
+ raise ValueError('Could not allocate a unique extraction directory')
214
+ # #3398: create the extraction root race-safely under the true workspace root.
215
+ make_anchored_dir(workspace, dest_dir)
216
+
217
+ # Member-count cap: a tiny archive with millions of (possibly empty) members
218
+ # slips under the byte cap but can exhaust inodes / file descriptors. Bound it.
219
+ _MAX_ARCHIVE_MEMBERS = 10000
168
220
 
169
221
  extracted_files = []
170
222
  total_extracted = 0
@@ -176,29 +228,38 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
176
228
  # Skip directories
177
229
  if member.is_dir():
178
230
  continue
231
+ if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
232
+ raise ValueError(
233
+ f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
234
+ f'Possible archive bomb.'
235
+ )
179
236
  # Zip-slip protection
180
237
  member_path = (dest_dir / member.filename).resolve()
181
238
  if not member_path.is_relative_to(dest_dir.resolve()):
182
239
  raise ValueError(f'Zip-slip blocked: {member.filename}')
183
240
  # Zip-bomb protection: track actual extracted bytes (not declared file_size)
184
- if total_extracted > _MAX_EXTRACTED_BYTES:
241
+ if total_extracted > cap:
185
242
  raise ValueError(
186
243
  f'Extraction too large ({total_extracted // (1024*1024)} MB > '
187
- f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
244
+ f'{cap // (1024*1024)} MB limit). '
188
245
  f'Possible zip bomb.'
189
246
  )
190
- member_path.parent.mkdir(parents=True, exist_ok=True)
191
- with zf.open(member) as src, open(member_path, 'wb') as dst:
247
+ # #3398: open_anchored_create_fd creates intermediate dirs
248
+ # race-safely under the true workspace root (anchored mkdirat),
249
+ # so no pathname member_path.parent.mkdir() before it (which
250
+ # could be redirected outside by a raced symlink component).
251
+ _mfd = open_anchored_create_fd(workspace, member_path)
252
+ with zf.open(member) as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
192
253
  _chunk_size = 65536
193
254
  while True:
194
255
  chunk = src.read(_chunk_size)
195
256
  if not chunk:
196
257
  break
197
258
  total_extracted += len(chunk)
198
- if total_extracted > _MAX_EXTRACTED_BYTES:
259
+ if total_extracted > cap:
199
260
  raise ValueError(
200
261
  f'Extraction too large (> '
201
- f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
262
+ f'{cap // (1024*1024)} MB limit). '
202
263
  f'Possible zip bomb.'
203
264
  )
204
265
  dst.write(chunk)
@@ -209,31 +270,39 @@ def extract_archive(file_bytes: bytes, filename: str, workspace: Path):
209
270
  for member in tf.getmembers():
210
271
  if not member.isfile():
211
272
  continue
273
+ if len(extracted_files) >= _MAX_ARCHIVE_MEMBERS:
274
+ raise ValueError(
275
+ f'Archive has too many files (> {_MAX_ARCHIVE_MEMBERS}). '
276
+ f'Possible archive bomb.'
277
+ )
212
278
  # Tar-slip protection
213
279
  member_path = (dest_dir / member.name).resolve()
214
280
  if not member_path.is_relative_to(dest_dir.resolve()):
215
281
  raise ValueError(f'Tar-slip blocked: {member.name}')
216
282
  # Tar-bomb protection: track actual extracted bytes (not declared size)
217
- if total_extracted > _MAX_EXTRACTED_BYTES:
283
+ if total_extracted > cap:
218
284
  raise ValueError(
219
285
  f'Extraction too large ({total_extracted // (1024*1024)} MB > '
220
- f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
286
+ f'{cap // (1024*1024)} MB limit). '
221
287
  f'Possible zip bomb.'
222
288
  )
223
- member_path.parent.mkdir(parents=True, exist_ok=True)
289
+ # #3398: anchored member create makes intermediate dirs
290
+ # race-safely; no pathname member_path.parent.mkdir() first.
224
291
  src_obj = tf.extractfile(member)
225
292
  if src_obj:
226
- with src_obj as src, open(member_path, 'wb') as dst:
293
+ # #3398: fd-anchored member create under the TRUE workspace root.
294
+ _mfd = open_anchored_create_fd(workspace, member_path)
295
+ with src_obj as src, os.fdopen(_mfd, 'wb', closefd=True) as dst:
227
296
  _chunk_size = 65536
228
297
  while True:
229
298
  chunk = src.read(_chunk_size)
230
299
  if not chunk:
231
300
  break
232
301
  total_extracted += len(chunk)
233
- if total_extracted > _MAX_EXTRACTED_BYTES:
302
+ if total_extracted > cap:
234
303
  raise ValueError(
235
304
  f'Extraction too large (> '
236
- f'{_MAX_EXTRACTED_BYTES // (1024*1024)} MB limit). '
305
+ f'{cap // (1024*1024)} MB limit). '
237
306
  f'Possible zip bomb.'
238
307
  )
239
308
  dst.write(chunk)
@@ -320,3 +389,167 @@ def handle_transcribe(handler):
320
389
  Path(temp_path).unlink(missing_ok=True)
321
390
  except Exception:
322
391
  pass
392
+
393
+
394
+ def handle_workspace_upload(handler):
395
+ """Upload a file into a session's workspace directory.
396
+
397
+ Form fields:
398
+ session_id – target session
399
+ path – subdirectory within the workspace (default: '')
400
+ File:
401
+ file – the uploaded file(s)
402
+ """
403
+ import traceback as _tb
404
+ try:
405
+ content_type = handler.headers.get('Content-Type', '')
406
+ content_length = int(handler.headers.get('Content-Length', 0) or 0)
407
+ if content_length > MAX_UPLOAD_BYTES:
408
+ return j(handler, {'error': f'File too large (max {MAX_UPLOAD_BYTES//1024//1024}MB)'}, status=413)
409
+
410
+ fields, files = parse_multipart(handler.rfile, content_type, content_length)
411
+ session_id = fields.get('session_id', '')
412
+ subpath = fields.get('path', '')
413
+
414
+ if not session_id:
415
+ return j(handler, {'error': 'Missing session_id'}, status=400)
416
+
417
+ if not files:
418
+ return j(handler, {'error': 'No file field in request'}, status=400)
419
+
420
+ # Validate session
421
+ try:
422
+ session = get_session(session_id)
423
+ except KeyError:
424
+ return j(handler, {'error': 'Session not found'}, status=404)
425
+
426
+ # Resolve workspace root from session
427
+ workspace = resolve_trusted_workspace(session.workspace)
428
+
429
+ # Resolve target subdirectory within workspace
430
+ target_dir = safe_resolve_ws(workspace, subpath) if subpath else workspace
431
+ # safe_resolve_ws intentionally permits in-workspace symlinks pointing
432
+ # outside the root (read trust model). For an UPLOAD target that's not
433
+ # acceptable: a planted symlink subpath would let mkdir() + writes create
434
+ # files OUTSIDE the workspace. Require the resolved target to be inside
435
+ # the workspace before creating anything. (is_relative_to is True for the
436
+ # workspace==target equality case, so the normal subpath='' path passes.)
437
+ if not target_dir.resolve().is_relative_to(workspace.resolve()):
438
+ return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
439
+ # #3398: create the upload target dir race-safely under the workspace root
440
+ # (anchored mkdirat) so a raced symlink subpath can't mkdir outside.
441
+ try:
442
+ make_anchored_dir(workspace, target_dir)
443
+ except (ValueError, OSError):
444
+ return j(handler, {'error': 'Upload target escapes workspace'}, status=403)
445
+
446
+ results = []
447
+ for _field_name, (filename, file_bytes) in files.items():
448
+ if not filename:
449
+ continue
450
+
451
+ safe_name = _sanitize_upload_name(filename)
452
+ dest = safe_resolve_ws(target_dir, safe_name)
453
+
454
+ # Path traversal guard (belt-and-suspenders: safe_resolve_ws above is
455
+ # the authoritative guard and raises ValueError on traversal; this
456
+ # check catches any edge case where the resolved path escapes).
457
+ if not dest.resolve().is_relative_to(workspace.resolve()):
458
+ return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
459
+
460
+ # Deduplicate: append -1, -2, etc. if file already exists
461
+ if dest.exists():
462
+ stem = dest.stem
463
+ suffix = dest.suffix
464
+ for idx in range(1, 1000):
465
+ candidate = safe_resolve_ws(target_dir, f'{stem}-{idx}{suffix}')
466
+ if not candidate.resolve().is_relative_to(workspace.resolve()):
467
+ return j(handler, {'error': 'Path traversal blocked'}, status=403)
468
+ if not candidate.exists():
469
+ dest = candidate
470
+ break
471
+ else:
472
+ return j(handler, {'error': 'Too many uploads with the same filename'}, status=400)
473
+
474
+ # #3398 TOCTOU hardening: create the destination via an anchored
475
+ # openat-walk from the true workspace root with O_CREAT|O_EXCL|
476
+ # O_NOFOLLOW, so a symlink raced into any path component after the
477
+ # containment checks above cannot redirect the write outside the
478
+ # workspace. The dedup loop guarantees `dest` does not exist.
479
+ try:
480
+ _wfd = open_anchored_create_fd(workspace, dest.resolve())
481
+ except FileExistsError:
482
+ return j(handler, {'error': f'Upload destination already exists: {safe_name}'}, status=409)
483
+ except (ValueError, OSError):
484
+ return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)
485
+ with os.fdopen(_wfd, 'wb', closefd=True) as _wfh:
486
+ _wfh.write(file_bytes)
487
+ mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream'
488
+
489
+ # For archives, optionally extract into the target directory.
490
+ # Suffix set MUST match extract_archive()'s supported formats, else
491
+ # accepted-but-unlisted archives (.tar/.tbz2/.txz) silently land as
492
+ # raw files instead of extracting.
493
+ is_archive = safe_name.lower().endswith(('.zip', '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tar.xz', '.txz'))
494
+ if is_archive:
495
+ import zipfile, tarfile, traceback as _extract_tb
496
+ try:
497
+ extraction = extract_archive(file_bytes, safe_name, target_dir)
498
+ # Remove the archive file after successful extraction
499
+ dest.unlink(missing_ok=True)
500
+ results.append({
501
+ 'filename': safe_name,
502
+ 'path': str(extraction.get('dest', target_dir)),
503
+ 'size': len(file_bytes),
504
+ 'is_image': False,
505
+ 'extracted': True,
506
+ 'extracted_files': extraction.get('files', []),
507
+ 'extracted_count': extraction.get('extracted', 0),
508
+ })
509
+ continue
510
+ except (zipfile.BadZipFile, tarfile.TarError, ValueError) as e:
511
+ # Extraction failed — remove the archive file (no partial
512
+ # content left behind) and surface the error to the user.
513
+ dest.unlink(missing_ok=True)
514
+ print(f'[webui] workspace upload extract error: {e}', flush=True)
515
+ results.append({
516
+ 'filename': safe_name,
517
+ 'path': str(target_dir),
518
+ 'size': len(file_bytes),
519
+ 'mime': mime,
520
+ 'is_image': False,
521
+ 'extracted': False,
522
+ 'extract_error': str(e) or 'Archive extraction failed',
523
+ })
524
+ continue
525
+ except Exception:
526
+ print('[webui] workspace upload extract error: ' + _extract_tb.format_exc(), flush=True)
527
+ dest.unlink(missing_ok=True)
528
+ results.append({
529
+ 'filename': safe_name,
530
+ 'path': str(target_dir),
531
+ 'size': len(file_bytes),
532
+ 'mime': mime,
533
+ 'is_image': False,
534
+ 'extracted': False,
535
+ 'extract_error': 'Archive extraction failed',
536
+ })
537
+ continue
538
+
539
+ results.append({
540
+ 'filename': dest.name,
541
+ 'path': str(dest),
542
+ 'size': dest.stat().st_size,
543
+ 'mime': mime,
544
+ 'is_image': mime.startswith('image/'),
545
+ 'extracted': False,
546
+ })
547
+
548
+ if len(results) == 1:
549
+ return j(handler, results[0])
550
+ return j(handler, {'files': results, 'count': len(results)})
551
+ except ValueError as e:
552
+ return j(handler, {'error': str(e)}, status=400)
553
+ except Exception:
554
+ print('[webui] workspace upload error: ' + _tb.format_exc(), flush=True)
555
+ return j(handler, {'error': 'Upload failed'}, status=500)