Skip to content

Feat/workspace upload - #3104

Closed
antoniocarlos97ss wants to merge 2 commits into
nesquena:masterfrom
antoniocarlos97ss:feat/workspace-upload
Closed

antoniocarlos97ss wants to merge 2 commits into
nesquena:masterfrom
antoniocarlos97ss:feat/workspace-upload

Conversation

@antoniocarlos97ss

@antoniocarlos97ss antoniocarlos97ss commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a file upload button to the workspace file-panel toolbar that POSTs to a new POST /api/workspace/upload endpoint. The handler:

  • Writes uploaded files into the session's workspace directory (resolved via resolve_trusted_workspace)
  • Deduplicates filenames with -1/-2 suffixes
  • Auto-extracts archives (.zip/.tar.*) into the target subdirectory with zip-bomb and zip-slip protections
  • Extraction errors are surfaced to the frontend (not silently swallowed)
  • Failed archive extractions clean up the archive file

Changes

  • api/upload.py: New handle_workspace_upload handler (109 lines)
  • api/routes.py: Route registration
  • static/workspace.js: Upload button + drag-drop onto file tree
  • CHANGELOG.md: Entry under [Unreleased]
  • tests/test_workspace_upload.py: 12 tests covering happy path, dedup, path traversal, oversized rejection, archive extraction containment, zip-slip blocking, corrupt archive handling, and zip-bomb cap

Review fixes (addressing @nesquena-hermes feedback)

  1. ✅ Archive extraction target: now extracts into target_dir (not workspace root)
  2. ✅ Error handling: replaced overbroad except (ValueError, Exception) with specific exceptions (zipfile.BadZipFile, tarfile.TarError, ValueError) + fallback Exception; errors are surfaced to frontend
  3. ✅ Zip-bomb guard: extract_archive already enforced _MAX_EXTRACTED_BYTES; extraction failures now clean up the archive file
  4. ✅ Path traversal: documented safe_resolve_ws as authoritative guard
  5. ✅ Tests: 12 comprehensive tests added
  6. ✅ CHANGELOG entry added

Add ability to upload files directly into the active workspace directory
from the workspace panel, completing the file management workflow.

Backend (api/upload.py):
- New endpoint POST /api/workspace/upload
- Multipart upload with session_id + target path
- Path traversal protection via safe_resolve_ws()
- Auto-dedup filenames (append -1, -2, etc.)
- Archive extraction for .zip/.tar.gz/.tgz/.tar.bz2/.tar.xz

Frontend (static/):
- Upload button in workspace panel toolbar (next to New File/Folder/Refresh)
- Hidden file input with multi-file support
- triggerWorkspaceUpload() + uploadToWorkspace() handlers
- Drag-and-drop on file tree area with visual feedback
- CSS outline animation for drag-over state

Closes nesquena#1 (feature request)
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Review — workspace upload feature, security shape needs tightening

Reading the diff against api/upload.py:323-429 (new handle_workspace_upload), api/routes.py:5051-5052 (route registration), and static/workspace.js:636-707 (file picker + drag-drop). The feature shape is right — workspace gets a file uploader, drag-drop onto the file tree works, archives are extracted, image MIME is flagged. Three concerns before this should land.

1. Multi-file uploads return 403 on a single bad name and abort the whole batch

At api/upload.py:368-371:

for field_name, (filename, file_bytes) in files.items():
    if not filename:
        continue
    safe_name = _sanitize_upload_name(filename)
    dest = safe_resolve_ws(target_dir, safe_name)
    if not dest.resolve().is_relative_to(workspace.resolve()):
        return j(handler, {'error': f'Path traversal blocked: {safe_name}'}, status=403)

If a user drags 10 files onto the tree and any one has a name that _sanitize_upload_name somehow can't pin inside the workspace, the loop returns mid-iteration with no record of which earlier files already wrote to disk. The frontend at static/workspace.js:670 then shows a single 403 toast and loadDir(S.currentDir) repaints, but the user has no idea half their upload succeeded. Either:

  • Accumulate per-file errors into results and always return a 200 with {files: [...], errors: [...]}, or
  • Buffer all dest paths up front, validate them all, then write — atomic-ish.

The agent-side safe_resolve_ws at api/workspace.py:653-681 already raises ValueError on traversal, so the explicit .is_relative_to re-check at upload.py:369 is belt-and-suspenders. The real failure mode here is the partial-write, not the resolve.

2. Archive extraction doesn't validate that _session_attachment_dir semantics match resolve_trusted_workspace

At api/upload.py:386-401:

is_archive = safe_name.lower().endswith(('.zip', '.tar.gz', '.tgz', '.tar.bz2', '.tar.xz'))
if is_archive:
    try:
        extract_archive(file_bytes, safe_name, workspace)
        dest.unlink(missing_ok=True)

extract_archive is defined at api/upload.py:141-200 and calls safe_resolve_ws(workspace, stem) to compute an extraction subdirectory. But this PR passes workspace (the trusted root from resolve_trusted_workspace), not the per-target subdirectory the user dragged onto. So if a user is browsing ~/repo/src/components and drops vendor.zip, the archive extracts to ~/repo/vendor/ instead of ~/repo/src/components/vendor/. That's a confusing UX regression vs. the regular file-write path, which respects target_dir.

Fix: pass target_dir instead of workspace to extract_archive. The existing safe_resolve_ws(workspace, stem) inside extract_archive resolves relative to its arg, so if we pass it target_dir it'll naturally extract into the subfolder the user was browsing. And safe_resolve_ws will still block traversal because target_dir itself was resolved through safe_resolve_ws(workspace, subpath) at line 357.

# Suggested fix at line 388
extract_archive(file_bytes, safe_name, target_dir)

Also: the bare except (ValueError, Exception) at line 399 silently swallows extraction errors and reports extracted: False with no diagnostic. Users dragging a corrupt zip will see "uploaded successfully" but no extracted contents and no error toast. At minimum log the exception; ideally surface it on the result entry.

3. The retry-name loop blocks legitimate concurrent uploads

At api/upload.py:373-385:

if dest.exists():
    stem = dest.stem
    suffix = dest.suffix
    for idx in range(1, 1000):
        candidate = safe_resolve_ws(target_dir, f'{stem}-{idx}{suffix}')
        ...
        if not candidate.exists():
            dest = candidate
            break
    else:
        return j(handler, {'error': 'Too many uploads with the same filename'}, status=400)

The exists()write_bytes() is a TOCTOU race — two parallel uploads of report.pdf can both see report.pdf doesn't exist after report-3.pdf was the previous max, both pick report-4.pdf, both write, one wins. For workspace uploads this is unlikely but the safer pattern is os.open(path, O_CREAT | O_EXCL) in the loop body to atomically claim a name.

Smaller things

  • mime = mimetypes.guess_type(safe_name)[0] or 'application/octet-stream' is also computed inside the is_archive branch (line 391) but the resulting mime is then included in a payload that says extracted: True — which is contradictory. If the file was extracted (and the archive deleted), the MIME of the deleted archive isn't useful info. Drop mime from the extracted-archive result, or at least mark it null.
  • target_dir.mkdir(parents=True, exist_ok=True) at line 359 will create directory trees outside what the user requested if subpath contains intermediate directories that don't exist. Worth confirming this is intentional — if someone POSTs path=a/b/c to an empty workspace, you'll create three nested dirs. Probably fine for a file upload feature but worth documenting.
  • No CHANGELOG entry. The PR body is empty.
  • No tests. Compare to PR fix: forward Gateway tool activity to WebUI #3098 / feat: add advanced model options #3097 which both ship comprehensive regression tests. This file adds 109 lines of upload-handling code and a new POST route with security implications — needs at least:
    • Path-traversal-via-filename test (file named ../foo.txt)
    • Path-traversal-via-subpath test (subpath = ../../etc)
    • Archive-with-zip-slip test
    • Dedup-name collision test
    • Drag-drop multi-file test for the frontend (mock FormData + drop event)

Verdict

The user-facing feature is genuinely nice — drag-drop into the workspace panel has been a missing affordance. But this needs the three concrete fixes above (archive target-dir, partial-write, race) plus tests before it's safe to merge. Happy to re-review once those land.

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Thanks for this, @antoniocarlos97ss — a workspace-panel upload button is a genuinely useful affordance. Before we can move it toward review, though, it needs several changes. Marking changes-requested + hold until these are addressed.

What this PR does (for context)

It adds an "Upload file" button to the workspace file-panel toolbar that POSTs to a new POST /api/workspace/upload endpoint. The handler writes the uploaded file(s) into the session's workspace directory (resolved via resolve_trusted_workspace), de-duplicates filenames with -1/-2 suffixes, and auto-extracts archives (.zip/.tar.*).

Required changes before review

  1. Don't swallow extraction errors with except (ValueError, Exception). That clause is equivalent to bare except Exception (the ValueError is redundant), and the repo style bans over-broad excepts. Catch the specific archive errors (zipfile.BadZipFile, tarfile.TarError, ValueError) and surface a real error to the user instead of silently keeping the archive with no feedback.

  2. Archive extraction target looks wrong. extract_archive(file_bytes, safe_name, workspace) extracts to the workspace root, ignoring the subpath the user uploaded into. A zip dropped into subdir/ should extract under subdir/, not the workspace root. Please extract into target_dir (and confirm extract_archive still enforces the workspace-root containment check for each member).

  3. Cap extracted size (zip-bomb guard). The sibling handle_upload_extract path enforces _MAX_EXTRACTED_BYTES (10× MAX_UPLOAD_BYTES). This new path has no equivalent cap, so a small archive can write unbounded bytes into the workspace. Please enforce the same total-extracted-bytes limit and reject (clean up partial output) when exceeded.

  4. Add tests. Uploads and path-traversal/archive handling are security-sensitive; the repo requires tests for non-trivial logic. Please cover: (a) happy-path upload writes the file into the workspace, (b) filename de-dup produces -1/-2, (c) a ../-style traversal name is blocked with 403, (d) oversized body → 413, (e) archive extraction stays within the workspace (no member escapes the root), (f) the zip-bomb cap from fix(frontend): use URL origin for fetch/EventSource to support revers… #3 trips. There's an existing harness pattern in tests/test_*upload* you can mirror.

  5. Add a PR description + CHANGELOG entry. The PR currently has no description. Please add a short summary and a user-visible CHANGELOG.md [Unreleased] bullet (per AGENTS.md).

  6. Path-traversal guard nit. safe_resolve_ws already resolves within the workspace, so the extra dest.resolve().is_relative_to(...) checks are belt-and-suspenders — that's fine to keep, but please confirm safe_resolve_ws is the single authoritative guard and the manual checks aren't masking a case where safe_resolve_ws would otherwise raise.

Once these are in (especially the tests and the extraction-target/zip-bomb fixes), re-request review and we'll take another look. Appreciate the contribution!

@nesquena-hermes nesquena-hermes added hold changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address labels May 30, 2026
- Fix archive extraction target: extract into target_dir, not workspace root
- Replace overbroad except (ValueError, Exception) with specific exceptions
  (zipfile.BadZipFile, tarfile.TarError, ValueError) + fallback Exception
- Surface extraction errors to frontend (no more silent swallowing)
- Remove archive file on extraction failure (no partial content left behind)
- Document safe_resolve_ws as authoritative path-traversal guard
- Add CHANGELOG entry in [Unreleased]
- Add comprehensive test suite covering:
  - Happy path (single file, subdirectory, image MIME)
  - Filename dedup (-1/-2 suffixes, multiple duplicates)
  - Path traversal blocked (dotdot filename, subpath traversal)
  - Oversized file rejection (413)
  - Archive extraction containment (subdirectory, root, zip-slip, corrupt zip)
  - Zip-bomb cap enforcement
@antoniocarlos97ss

Copy link
Copy Markdown
Contributor Author

@nesquena-hermes thanks for the thorough review! All six concerns have been addressed:

  1. Extraction errors: Replaced except (ValueError, Exception) with specific catches (zipfile.BadZipFile, tarfile.TarError, ValueError) + fallback Exception. Errors are now surfaced to the frontend via extract_error field instead of being silently swallowed. The archive file is removed on failure (no partial content left behind).

  2. Archive target: Extraction now uses target_dir instead of workspace root — archives drop where the user intends, matching the regular file-write path.

  3. Zip-bomb cap: The underlying extract_archive() already enforced _MAX_EXTRACTED_BYTES per-chunk for both zip and tar paths (the check exists at lines 184-203 and 217-238). The new handler path now properly cleans up the archive and surfaces the error on cap trip (test included).

  4. Tests: Added test_workspace_upload.py with 12 tests covering: happy path (single file, subdirectory, image MIME), filename dedup, path traversal (dotdot filename, subpath traversal), oversized rejection (413), archive extraction containment, zip-slip blocking, corrupt zip error surfacing, and zip-bomb cap enforcement.

  5. PR description + CHANGELOG: Done.

  6. Path traversal guard: Added a comment documenting that safe_resolve_ws is the authoritative guard and the manual .is_relative_to is belt-and-suspenders.

Ready for re-review!

nesquena-hermes pushed a commit that referenced this pull request Jun 2, 2026
…p (Opus SHOULD-FIX)

Opus review SHOULD-FIX on the upload surface:
- Add _MAX_ARCHIVE_MEMBERS=10000 cap in extract_archive (both zip + tar loops):
  a tiny archive with millions of members slips under the byte cap but can
  exhaust inodes/fds. Trips before extraction, cleaned up via the existing
  rmtree-on-exception. Regression test added.
- Bound the extraction-dir collision-suffix loop (was while-True) to 1000 tries.
Other Opus SHOULD-FIX items (member-count #1 done; #2 done) filed as follow-up
or N/A: same-field multi-file collapse doesn't apply (frontend sends one request
per file); .tar.gz stem cosmetic.
nesquena-hermes pushed a commit that referenced this pull request Jun 2, 2026
…ocale

The PR referenced t('uploading') and t('uploaded') in static/workspace.js with
JS fallbacks but never defined the keys, so test_static_literal_i18n_keys_exist_in_english_locale
(the i18n-key existence gate, also run in CI) went red. Added both to the English
locale (and the Korean block's English-placeholder upload keys for consistency).
nesquena-hermes pushed a commit that referenced this pull request Jun 2, 2026
The English-only addition broke the locale-parity tests (es/zh/ja/ru/tr/ko all
enforce full key coverage vs English). Added translated uploading/uploaded to
it/ja/ru/es/de/zh/zh-Hant/pt/ko/fr/tr so every locale covers the new keys.
nesquena-hermes added a commit that referenced this pull request Jun 2, 2026
Release FZ — v0.51.206 (#3104 workspace file upload + drag-and-drop with archive extraction)
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Shipped in v0.51.206 (Release FZ) via release PR #3367 (merge f0c3668d). Thanks @antoniocarlos97ss! The workspace Upload button + drag-and-drop are now live, with archive auto-extraction guarded by zip-slip, zip-bomb (size + member-count caps), and path-traversal protections.

Cherry-picked onto current master (the branch was ~423 commits behind) and applied a few maintainer fixes during review: corrected the dedup filename reported in the response, made the extraction size-cap env-tunable (HERMES_WEBUI_MAX_EXTRACTED_MB) + actually testable, added an archive member-count cap, and added the missing uploading/uploaded i18n keys across all locales. Your six earlier review fixes all held up. Closing as merged — credit preserved.

pull Bot pushed a commit to jw5812018/hermes-webui that referenced this pull request Jun 2, 2026
pull Bot pushed a commit to jw5812018/hermes-webui that referenced this pull request Jun 2, 2026
…cap testable

Two issues caught by the PR's own tests against the out-of-process test server:
1. Dedup reporting bug: after a filename collision the file was correctly
   written to e.g. report-1.pdf, but the JSON response reported the ORIGINAL
   name (safe_name) — now reports dest.name. (Real user-facing bug.)
2. Zip-bomb cap was untestable: the test monkeypatched _MAX_EXTRACTED_BYTES in
   the pytest process, which has no effect on the separate server process where
   extraction runs. Made the cap env-configurable (HERMES_WEBUI_MAX_EXTRACTED_MB,
   read at call time via _max_extracted_bytes(); defaults to 10x upload cap),
   set it to 5MB in the conftest server env, and rewrote the test to upload a
   compressible archive that genuinely extracts past the cap. Also asserts no
   partial extraction dir is left behind.

Plus lint: unused field_name loop var -> _field_name, unused os import in test.
mysoul12138 added a commit to mysoul12138/hermes-webui that referenced this pull request Jun 2, 2026
…rkspace.js), upstream absorbed PR nesquena#3329/nesquena#3336/nesquena#3337

Conflicts:
- static/style.css: upstream nesquena#3337 enhanced Prism preview fix (covers pre+code), adopted upstream
- static/workspace.js: upstream nesquena#3337 added stale class cleanup + lang guard, adopted upstream

Auto-merged: api/config.py, api/models.py, api/routes.py, static/i18n.js,
static/index.html, static/panels.js, static/ui.js, tests/conftest.py

Upstream absorbed our PRs:
- PR nesquena#3329 (Artifacts tab fix) → fae5ada + 1c4365c
- PR nesquena#3336 (diff coloring) → 0b7f32f
- PR nesquena#3337 (syntax highlighting) → 2d1b464 + c1156b4

New upstream features:
- Workspace file upload + drag-drop with archive extraction (nesquena#3104)
- Session title regeneration (nesquena#3223)
- Generated media artifact cards (nesquena#3220)
- Profile-scoped live models cache
- Various bug fixes and polish
gavinssr pushed a commit to gavinssr/hermes-webui that referenced this pull request Jun 2, 2026
Codex regression-gate findings on the shipped nesquena#3104 upload code, each verified
with a repro and fixed:

1. Negative Content-Length bypassed the size cap → unbounded rfile.read(-1).
   The per-handler 'content_length > MAX_UPLOAD_BYTES' check is False for a
   negative value, so the guard is now centralized in parse_multipart()
   (validates [0, MAX_UPLOAD_BYTES]) — protects all four upload handlers.
2. .tar/.tbz2/.txz uploads silently skipped extraction (is_archive suffix set
   was narrower than extract_archive's) → now matches.
3. Rejected archives (zip-slip/zip-bomb/corrupt/too-many-members) showed a
   misleading 'Uploaded' success toast → workspace.js now surfaces extract_error.
4. An in-workspace symlink subpath let mkdir/writes escape the workspace root →
   target_dir is now required to be is_relative_to(workspace) before mkdir.

Regression tests added (negative+oversize CL, .tar extraction, symlink target).
mysoul12138 added a commit to mysoul12138/hermes-webui that referenced this pull request Jun 2, 2026
Conflicts:
- static/i18n.js: took HEAD + added 3 upstream keys (plugins_enable_toggle, settings_desc_tts_engine, settings_label_tts_engine) to all 12 locales
- static/index.html: kept both mainChannels (branch) + mainPlugin (upstream nesquena#2622), kept branch Edge TTS UI
- static/panels.js: kept branch panels + added upstream plugin pages
- static/style.css: combined .showing-channels (branch) + .showing-plugin (upstream)
- static/ui.js: kept branch Edge TTS speak/stop logic + added upstream engine guard

Auto-merged: api/config.py, api/routes.py, requirements.txt, static/boot.js

New upstream features:
- Dashboard plugin system with iframe isolation (nesquena#2622)
- Edge TTS as alternative speech engine (nesquena#2931) — coexists with our decoupled implementation
- Workspace upload security hardening (nesquena#3104 follow-up)
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Jun 2, 2026
…➔ 0.51.210) (#782)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/nesquena/hermes-webui](https://github.com/nesquena/hermes-webui) | patch | `0.51.197` → `0.51.210` |

---

### Release Notes

<details>
<summary>nesquena/hermes-webui (ghcr.io/nesquena/hermes-webui)</summary>

### [`v0.51.210`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051210--2026-06-02--Release-GD-stage-batch1--model-picker-multi-slash-fix--extensionless-preview-highlighting)

[Compare Source](nesquena/hermes-webui@v0.51.209...v0.51.210)

##### Fixed

- Model picker no longer snaps to the wrong model when multiple multi-slash model IDs from the same proxy provider share the same base name. Exact-match priority in `_findModelInDropdown` and first-segment-only stripping in `_normalizeConfiguredModelKey` / `_norm_model_id` prevent collisions in selection, badge assignment, and configured-entry dedup ([#&#8203;3360](nesquena/hermes-webui#3360), [@&#8203;b3nw](https://github.com/b3nw)).
- Workspace file previews now syntax-highlight common code/config filenames without useful extensions, including `Dockerfile`, `Dockerfile.*`, `Makefile`, `GNUmakefile`, `CMakeLists.txt`, `.gitignore`, and `.dockerignore` ([#&#8203;3365](nesquena/hermes-webui#3365), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.209`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051209--2026-06-02--Release-GC-WebUI-dashboard-plugin-system-with-iframe-isolation)

[Compare Source](nesquena/hermes-webui@v0.51.208...v0.51.209)

##### Added

- WebUI dashboard plugins: plugins that ship a UI under `~/.hermes/plugins/<name>/dashboard/` (with a `manifest.json`) now appear as opt-in cards in Settings → Plugins (default off). Once enabled, an **Open** button renders the plugin page inside a sandboxed iframe (`sandbox="allow-scripts allow-forms allow-popups"` — no `allow-same-origin`, so plugin JS/CSS/modals stay fully isolated from the parent app). New `/plugins/` (shared assets) and `/dashboard-plugins/<name>/` (per-plugin assets) static routes serve only built `dist/`/`static/` files with path-traversal, dotfile, and extension-allowlist protection (plugin source/config such as `plugin_api.py`/`manifest.json`/`.env` is never served), and both the page and asset routes are gated server-side on the enable state + an HTTP `sandbox` CSP + `nosniff`. Plugin `name` and `tab.path` are validated at load. Display-only — no plugin backend/subprocess execution ([#&#8203;2622](nesquena/hermes-webui#2622), [@&#8203;pix0127](https://github.com/pix0127)).

### [`v0.51.208`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051208--2026-06-02--Release-GB-workspace-upload-hardening-hotfix)

[Compare Source](nesquena/hermes-webui@v0.51.207...v0.51.208)

##### Fixed

- Hardened the workspace file-upload surface ([#&#8203;3104](nesquena/hermes-webui#3104) follow-up): (1) a negative `Content-Length` no longer bypasses the size cap and triggers an unbounded `rfile.read(-1)` — the length is now validated `[0, MAX_UPLOAD_BYTES]` centrally in `parse_multipart` for every upload handler; (2) `.tar`, `.tbz2`, and `.txz` archives now auto-extract (the upload handler's archive-suffix set was narrower than `extract_archive`'s, so those silently landed as raw files); (3) a rejected archive (zip-slip / zip-bomb / corrupt / too-many-members) now surfaces an error toast in the workspace panel instead of a misleading "Uploaded" success; (4) an in-workspace symlink subpath can no longer make the upload target `mkdir`/write outside the workspace root. Regression tests added.

### [`v0.51.207`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051207--2026-06-02--Release-GA-Edge-TTS-as-an-alternative-speech-engine)

[Compare Source](nesquena/hermes-webui@v0.51.206...v0.51.207)

##### Added

- Added an optional server-side **Edge TTS** speech engine (Microsoft neural voices) selectable in Settings → Preferences → TTS Engine, alongside the existing browser speech synthesis. The voice list switches to the Edge neural voices when selected. A new `POST /api/tts` endpoint streams the audio, gated by the same-origin CSRF check + session auth, a per-client rate limit, a 5000-character cap, and a voice allowlist. `edge-tts` is an optional dependency — the endpoint returns a clear install hint (503) when it isn't present, so existing installs are unaffected ([#&#8203;2931](nesquena/hermes-webui#2931), [@&#8203;liuqiangweb-svg](https://github.com/liuqiangweb-svg)).

### [`v0.51.206`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051206--2026-06-02--Release-FZ-workspace-file-upload--drag-and-drop-with-archive-extraction)

[Compare Source](nesquena/hermes-webui@v0.51.205...v0.51.206)

##### Added

- Workspace file panel: an **Upload** button and drag-and-drop that POST to a new `/api/workspace/upload` endpoint. Files land in the session workspace (resolved via the trusted-workspace guard), are de-duplicated with `-1`/`-2` suffixes, and archives (`.zip`/`.tar.*`) are auto-extracted into the target subdirectory with zip-bomb (size-cap + member-count-cap) and zip-slip (path-containment) protections. The extraction size cap is tunable via `HERMES_WEBUI_MAX_EXTRACTED_MB` (defaults to 10× the upload cap). Extraction errors are surfaced to the frontend instead of being silently swallowed, and the archive is removed on failure ([#&#8203;3104](nesquena/hermes-webui#3104), [@&#8203;antoniocarlos97ss](https://github.com/antoniocarlos97ss)).

### [`v0.51.205`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051205--2026-06-01--Release-FY-stage-hi1--workspace-syntax-highlighting--generated-image-cards--manual-title-regeneration)

[Compare Source](nesquena/hermes-webui@v0.51.204...v0.51.205)

##### Added

- Workspace file previews now render with syntax highlighting via Prism.js (already loaded for chat code blocks), covering common languages (Python, JS/TS, CSS, JSON, SQL, shell, and more) and degrading gracefully to plain text for unknown/plain files and when offline. The preview code surface uses a single uniform background across light and dark themes ([#&#8203;3337](nesquena/hermes-webui#3337), [@&#8203;mysoul12138](https://github.com/mysoul12138)).
- Generated local image artifacts now render as a clean inline image (with click-to-zoom lightbox) plus a hover/focus-revealed **Download** action overlaid on the image, served through authenticated `/api/media` URLs — matching the common AI-chat pattern of letting the image be the hero rather than wrapping it in a permanent card ([#&#8203;3220](nesquena/hermes-webui#3220), [@&#8203;AJV20](https://github.com/AJV20)).
- The session action menu can regenerate conversation titles on demand from the saved transcript, updating the sidebar without touching conversation chronology and syncing the new title through to state.db when Insights sync is enabled. The menu was also streamlined to a compact icon + label layout (descriptions move to hover tooltips). Closes [#&#8203;3106](nesquena/hermes-webui#3106) ([#&#8203;3223](nesquena/hermes-webui#3223), [@&#8203;AJV20](https://github.com/AJV20)).

### [`v0.51.204`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051204--2026-06-01--Release-FX-stage-batch17--projectsession-operations-honor-the-sessions-own-profile)

[Compare Source](nesquena/hermes-webui@v0.51.203...v0.51.204)

##### Fixed

- Project and session operations (project create/rename/recolor/delete/unassign, session move, and the profile chip label) now key on the session's own profile (`S.session.profile`) instead of the global active profile, so switching between sessions from different profiles no longer causes silent 404s, misleading chip labels, or project-picker entries from the wrong profile. The project picker also filters to the session's profile and surfaces an error toast on failure instead of a silent no-op ([#&#8203;3331](nesquena/hermes-webui#3331), [@&#8203;PINKIIILQWQ](https://github.com/PINKIIILQWQ)).

### [`v0.51.203`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051203--2026-06-01--Release-FW-stage-batch15--sticky-manual-unpin-for-streaming-chat-scroll)

[Compare Source](nesquena/hermes-webui@v0.51.202...v0.51.203)

##### Changed

- Streaming chat scroll now uses a sticky manual-unpin model: once you scroll up to read earlier content during a streaming response, the view stays put and no longer auto-follows the live tail until you scroll back to the bottom (near-bottom hysteresis on downward motion) or click the scroll-to-bottom control. Tool cards, token updates, and layout growth no longer re-pin the viewport after a reading pause. This replaces the [#&#8203;3250](nesquena/hermes-webui#3250) upward-intent timeout and supersedes the v0.51.199 proximity-re-pin ([#&#8203;3330](nesquena/hermes-webui#3330)), matching the streaming-scroll behavior of ChatGPT/Claude/Codex. Fresh streams reset the follow state on attach ([#&#8203;3343](nesquena/hermes-webui#3343), [@&#8203;pamnard](https://github.com/pamnard)).

### [`v0.51.202`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051202--2026-06-01--Release-FV-stage-batch14--filter-interrupted-recovery-control-text-from-visible-transcript)

[Compare Source](nesquena/hermes-webui@v0.51.201...v0.51.202)

##### Fixed

- Interrupted SSE-recovery control text (the synthetic `stale_interrupted_event` run-journal payload) is now kept out of the visible chat transcript instead of being replayed as a message: it's marked `recovery_control` on the backend and filtered across the `msgContent()` render path, the SSE settle/error handlers, and final transcript filtering, so platform-only control state no longer leaks into the conversation ([#&#8203;3321](nesquena/hermes-webui#3321), [@&#8203;franksong2702](https://github.com/franksong2702)).

### [`v0.51.201`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051201--2026-06-01--Release-FU-stage-batch13--colored-diff-lines-in-tool-card-snippets)

[Compare Source](nesquena/hermes-webui@v0.51.200...v0.51.201)

##### Added

- Tool-card result snippets that contain a unified diff now render with the same green/red/cyan diff coloring already used for diffs in chat messages (reusing the existing `.diff-block` styles), with an expand/collapse toggle that preserves the coloring. Non-diff snippets are unchanged ([#&#8203;3336](nesquena/hermes-webui#3336), [@&#8203;mysoul12138](https://github.com/mysoul12138)).

### [`v0.51.200`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051200--2026-06-01--Release-FT-stage-batch12--remote-gateway-health-probe--ephemeral-turn-field-preservation)

[Compare Source](nesquena/hermes-webui@v0.51.199...v0.51.200)

##### Fixed

- The Tasks/Cron panel no longer shows a spurious "Gateway not configured" banner in multi-container Docker deployments where the WebUI image doesn't ship the `gateway` Python package: agent-health now probes the remote gateway via `HERMES_API_URL` before falling back to the local `gateway.status` import. Closes [#&#8203;3281](nesquena/hermes-webui#3281) ([#&#8203;3312](nesquena/hermes-webui#3312), [@&#8203;Sanjays2402](https://github.com/Sanjays2402)).
- Force-reloading the active session (`loadSession(sid, {forceReload:true})`) no longer drops ephemeral turn fields (`_turnUsage`, `_turnDuration`, `_turnTps`, `_gatewayRouting`, `_statusCard`): the ephemeral-field carry-forward now reads the prior `S.messages` before it's reset, so the token-usage badge and status cards survive an external refresh. Closes [#&#8203;3306](nesquena/hermes-webui#3306) ([#&#8203;3313](nesquena/hermes-webui#3313), [@&#8203;Sanjays2402](https://github.com/Sanjays2402)).

### [`v0.51.199`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051199--2026-06-01--Release-FS-stage-batch11--pinned-scroll-recovery--inline-math-currency-false-positive)

[Compare Source](nesquena/hermes-webui@v0.51.198...v0.51.199)

##### Fixed

- Pinned chat now recovers its scroll position after a DOM rebuild: `_setMessageScrollToBottom` retries on the next layout frame, and `scrollIfPinned` re-pins when the pane has drifted more than 500px from the bottom, so a message-list rebuild no longer leaves a pinned conversation stranded mid-scroll. Closes [#&#8203;3319](nesquena/hermes-webui#3319) ([#&#8203;3330](nesquena/hermes-webui#3330), [@&#8203;jianongHe](https://github.com/jianongHe)).
- The `$...$` inline-math renderer no longer treats currency like `$1,000 xuống ~$95` as math: the opening `$` followed by a digit is now rejected (aligning with smd's `se()` guard), so dollar amounts render as plain text. Digit-leading inline math (e.g. `$2x = 4$`) should now use the LaTeX-style `\(2x = 4\)` or display `$$2x = 4$$` delimiters ([#&#8203;3311](nesquena/hermes-webui#3311), [@&#8203;toanalien](https://github.com/toanalien)).

### [`v0.51.198`](https://github.com/nesquena/hermes-webui/blob/HEAD/CHANGELOG.md#v051198--2026-06-01--Release-FR-stage-batch10--custom-provider-reasoning-model-id-normalize--profile-skill-counts--run-adapter-RFC-slice)

[Compare Source](nesquena/hermes-webui@v0.51.197...v0.51.198)

##### Fixed

- Reasoning-effort detection for named `custom:*` providers now normalizes non-slash model ids before applying its fallback family heuristics, so separator variants such as `deepseek.v3.2`, `deepseek_v4_flash`, and vendor-namespaced ids like `vendor.deepseek.v3.2` resolve the same way as `deepseek-v4-flash`. The keyword fallback is now token-aware rather than substring-based, preserving names like `model-thinking-preview` without falsely enabling reasoning for unrelated prefixes such as `thinkinghub.llama-3.1-70b` ([#&#8203;3327](nesquena/hermes-webui#3327), [@&#8203;Carry00](https://github.com/Carry00)).
- Profile cards now show enabled vs compatible skill counts (computed with an 8s TTL cache that clears on profile switch) instead of a single ambiguous count. Closes [#&#8203;3339](nesquena/hermes-webui#3339) ([#&#8203;3341](nesquena/hermes-webui#3341), [@&#8203;b3nw](https://github.com/b3nw)).

##### Changed

- The [#&#8203;1925](nesquena/hermes-webui#1925) runtime-adapter RFC now marks the configured runner-client boundary as shipped in v0.51.188 ([#&#8203;3073](nesquena/hermes-webui#3073) / [#&#8203;3274](nesquena/hermes-webui#3274)) and defines the next Slice 4g gate for a supervised local runner process harness: real runner-owned `AIAgent` execution, restart/reattach proof, bounded runner health diagnostics, and no new WebUI runtime-surrogate globals ([#&#8203;3334](nesquena/hermes-webui#3334), [@&#8203;Michaelyklam](https://github.com/Michaelyklam)).

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xMDEuMSIsInVwZGF0ZWRJblZlciI6IjQzLjEwMS4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6WyJyZW5vdmF0ZS9jb250YWluZXIiLCJ0eXBlL3BhdGNoIl19-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/782
nesquena-hermes pushed a commit that referenced this pull request Jun 2, 2026
…#3104, #3220, #3223, #3337)

Six contributor PRs were shipped via the cherry-pick/absorb path but their
absorb commits never carried a `Co-authored-by:` trailer, so the contributors
received zero commit credit on their GitHub contribution graphs. Three of them
(@antoniocarlos97ss, @liuqiangweb-svg, @pix0127) were also missing from
CONTRIBUTORS.md entirely; the other three (@AJV20, @mysoul12138) were already
listed via CHANGELOG attribution but still lacked the graph credit.

This commit:
  - Adds the three missing contributors to CONTRIBUTORS.md (single-PR section).
  - Carries Co-authored-by trailers for all six so each gets a real commit on
    their contribution graph (the non-history-rewrite way to repair this).
  - Bumps the tracked totals (194 -> 197 contributors, 843 -> 846 credits).

The shipped work, by PR:
  #2622 (@pix0127)            WebUI dashboard plugin system w/ iframe isolation
  #2931 (@liuqiangweb-svg)    Edge TTS as an alternative speech engine
  #3104 (@antoniocarlos97ss)  workspace file upload + drag-drop w/ archive extract
  #3220 (@AJV20)              generated media artifact cards
  #3223 (@AJV20)              manual session title regeneration
  #3337 (@mysoul12138)        syntax highlighting in workspace file preview

Co-authored-by: pix0127 <8500500+pix0127@users.noreply.github.com>
Co-authored-by: Andy <281253538+liuqiangweb-svg@users.noreply.github.com>
Co-authored-by: antoniocarlos97ss <101895404+antoniocarlos97ss@users.noreply.github.com>
Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com>
Co-authored-by: mysoul12138 <203929894+mysoul12138@users.noreply.github.com>
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…cap testable

Two issues caught by the PR's own tests against the out-of-process test server:
1. Dedup reporting bug: after a filename collision the file was correctly
   written to e.g. report-1.pdf, but the JSON response reported the ORIGINAL
   name (safe_name) — now reports dest.name. (Real user-facing bug.)
2. Zip-bomb cap was untestable: the test monkeypatched _MAX_EXTRACTED_BYTES in
   the pytest process, which has no effect on the separate server process where
   extraction runs. Made the cap env-configurable (HERMES_WEBUI_MAX_EXTRACTED_MB,
   read at call time via _max_extracted_bytes(); defaults to 10x upload cap),
   set it to 5MB in the conftest server env, and rewrote the test to upload a
   compressible archive that genuinely extracts past the cap. Also asserts no
   partial extraction dir is left behind.

Plus lint: unused field_name loop var -> _field_name, unused os import in test.
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…dir dedup (Opus SHOULD-FIX)

Opus review SHOULD-FIX on the upload surface:
- Add _MAX_ARCHIVE_MEMBERS=10000 cap in extract_archive (both zip + tar loops):
  a tiny archive with millions of members slips under the byte cap but can
  exhaust inodes/fds. Trips before extraction, cleaned up via the existing
  rmtree-on-exception. Regression test added.
- Bound the extraction-dir collision-suffix loop (was while-True) to 1000 tries.
Other Opus SHOULD-FIX items (member-count nesquena#1 done; nesquena#2 done) filed as follow-up
or N/A: same-field multi-file collapse doesn't apply (frontend sends one request
per file); .tar.gz stem cosmetic.
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…nglish locale

The PR referenced t('uploading') and t('uploaded') in static/workspace.js with
JS fallbacks but never defined the keys, so test_static_literal_i18n_keys_exist_in_english_locale
(the i18n-key existence gate, also run in CI) went red. Added both to the English
locale (and the Korean block's English-placeholder upload keys for consistency).
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
…ed locales

The English-only addition broke the locale-parity tests (es/zh/ja/ru/tr/ko all
enforce full key coverage vs English). Added translated uploading/uploaded to
it/ja/ru/es/de/zh/zh-Hant/pt/ko/fr/tr so every locale covers the new keys.
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
Release FZ — v0.51.206 (nesquena#3104 workspace file upload + drag-and-drop with archive extraction)
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
Codex regression-gate findings on the shipped nesquena#3104 upload code, each verified
with a repro and fixed:

1. Negative Content-Length bypassed the size cap → unbounded rfile.read(-1).
   The per-handler 'content_length > MAX_UPLOAD_BYTES' check is False for a
   negative value, so the guard is now centralized in parse_multipart()
   (validates [0, MAX_UPLOAD_BYTES]) — protects all four upload handlers.
2. .tar/.tbz2/.txz uploads silently skipped extraction (is_archive suffix set
   was narrower than extract_archive's) → now matches.
3. Rejected archives (zip-slip/zip-bomb/corrupt/too-many-members) showed a
   misleading 'Uploaded' success toast → workspace.js now surfaces extract_error.
4. An in-workspace symlink subpath let mkdir/writes escape the workspace root →
   target_dir is now required to be is_relative_to(workspace) before mkdir.

Regression tests added (negative+oversize CL, .tar extraction, symlink target).
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
nesquena#2931, nesquena#3104, nesquena#3220, nesquena#3223, nesquena#3337)

Six contributor PRs were shipped via the cherry-pick/absorb path but their
absorb commits never carried a `Co-authored-by:` trailer, so the contributors
received zero commit credit on their GitHub contribution graphs. Three of them
(@antoniocarlos97ss, @liuqiangweb-svg, @pix0127) were also missing from
CONTRIBUTORS.md entirely; the other three (@AJV20, @mysoul12138) were already
listed via CHANGELOG attribution but still lacked the graph credit.

This commit:
  - Adds the three missing contributors to CONTRIBUTORS.md (single-PR section).
  - Carries Co-authored-by trailers for all six so each gets a real commit on
    their contribution graph (the non-history-rewrite way to repair this).
  - Bumps the tracked totals (194 -> 197 contributors, 843 -> 846 credits).

The shipped work, by PR:
  nesquena#2622 (@pix0127)            WebUI dashboard plugin system w/ iframe isolation
  nesquena#2931 (@liuqiangweb-svg)    Edge TTS as an alternative speech engine
  nesquena#3104 (@antoniocarlos97ss)  workspace file upload + drag-drop w/ archive extract
  nesquena#3220 (@AJV20)              generated media artifact cards
  nesquena#3223 (@AJV20)              manual session title regeneration
  nesquena#3337 (@mysoul12138)        syntax highlighting in workspace file preview

Co-authored-by: pix0127 <8500500+pix0127@users.noreply.github.com>
Co-authored-by: Andy <281253538+liuqiangweb-svg@users.noreply.github.com>
Co-authored-by: antoniocarlos97ss <101895404+antoniocarlos97ss@users.noreply.github.com>
Co-authored-by: AJV20 <24819659+AJV20@users.noreply.github.com>
Co-authored-by: mysoul12138 <203929894+mysoul12138@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Maintainer left detailed feedback requesting changes; PR is waiting on author to address hold

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants