Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Fixed

- **PR #2520** by @OneFat3 (refs #2247) — Route archive extraction (`/api/upload/extract`) through the per-session attachment inbox (`_session_attachment_dir`) instead of hardcoded `Path(s.workspace)`, matching the single-file upload path. Extracted archives now land at `<attachment_root>/<session_id>/<archive_stem>/` so session deletion cleanup covers them and per-session isolation is preserved when `HERMES_WEBUI_ATTACHMENT_DIR` is configured.
## [v0.51.90] — 2026-05-18 — Release BN (stage-383 — 10-PR full sweep batch — empty-gateway messaging history fix + previous-messaging-sessions setting + Kanban board switcher layout + UI/UX demo theme controls + Slice 3c queue/goal RFC gate + keyless custom endpoints + custom-provider remote model catalog parity + auto-compression elapsed timer + new-conversation cold-start guard + Kanban drag-drop detail open fix)

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions api/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,9 @@ def handle_upload_extract(handler):
s = get_session(session_id)
except KeyError:
return j(handler, {'error': 'Session not found'}, status=404)
workspace = Path(s.workspace)
result = extract_archive(file_bytes, filename, workspace)
session_dir = _session_attachment_dir(session_id)
session_dir.mkdir(parents=True, exist_ok=True)
result = extract_archive(file_bytes, filename, session_dir)
return j(handler, {'ok': True, **result})
except ValueError as e:
return j(handler, {'error': str(e)}, status=400)
Expand Down
94 changes: 94 additions & 0 deletions tests/test_pr2520_extract_attachment_dir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""PR #2520: archive extraction respects HERMES_WEBUI_ATTACHMENT_DIR.

Verifies that extract_archive() lands files in the per-session attachment
inbox when HERMES_WEBUI_ATTACHMENT_DIR is set, matching the single-file
upload path and ensuring session cleanup covers extracted archives.
"""
import io
import shutil
import zipfile
from pathlib import Path

import pytest

from api.upload import extract_archive, _session_attachment_dir


def _make_zip(members: dict[str, bytes]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, data in members.items():
zf.writestr(name, data)
return buf.getvalue()


class TestExtractArchiveAttachmentDir:

def test_extraction_lands_in_session_dir(self, tmp_path, monkeypatch):
inbox = tmp_path / "att-inbox"
monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(inbox))

session_id = "sess-42"
session_dir = _session_attachment_dir(session_id)
session_dir.mkdir(parents=True, exist_ok=True)

zip_bytes = _make_zip({
"hello.txt": b"Hello, world!",
"sub/nested.txt": b"Nested file",
})

result = extract_archive(zip_bytes, "demo.zip", session_dir)

assert result["extracted"] == 2
dest = Path(result["dest"])
assert dest.is_relative_to(session_dir)
assert dest.name == "demo"
assert (dest / "hello.txt").read_text() == "Hello, world!"
assert (dest / "sub" / "nested.txt").read_text() == "Nested file"

def test_session_cleanup_covers_extracted_archives(self, tmp_path, monkeypatch):
inbox = tmp_path / "att-inbox"
monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(inbox))

session_id = "sess-cleanup"
session_dir = _session_attachment_dir(session_id)
session_dir.mkdir(parents=True, exist_ok=True)

zip_bytes = _make_zip({"a.txt": b"data"})
result = extract_archive(zip_bytes, "pkg.zip", session_dir)
dest = Path(result["dest"])
assert dest.exists()

shutil.rmtree(session_dir, ignore_errors=True)
assert not dest.exists()
assert not session_dir.exists()

def test_extraction_not_at_bare_attachment_root(self, tmp_path, monkeypatch):
inbox = tmp_path / "att-inbox"
monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(inbox))

session_id = "sess-scoped"
session_dir = _session_attachment_dir(session_id)
session_dir.mkdir(parents=True, exist_ok=True)

zip_bytes = _make_zip({"file.txt": b"content"})
result = extract_archive(zip_bytes, "archive.zip", session_dir)
dest = Path(result["dest"])

assert dest.parent == session_dir
assert dest.parent != inbox.resolve()

def test_relative_files_are_relative_to_session_dir(self, tmp_path, monkeypatch):
inbox = tmp_path / "att-inbox"
monkeypatch.setenv("HERMES_WEBUI_ATTACHMENT_DIR", str(inbox))

session_dir = _session_attachment_dir("sess-rel")
session_dir.mkdir(parents=True, exist_ok=True)

zip_bytes = _make_zip({"doc.md": b"# Title"})
result = extract_archive(zip_bytes, "docs.zip", session_dir)

assert len(result["files"]) == 1
rel = result["files"][0]
assert rel == "docs/doc.md"
assert (session_dir / rel).exists()
Loading