From 2fe0ece991f34aefcb483dbcf265eb740c840645 Mon Sep 17 00:00:00 2001 From: "r.kulbaev" Date: Mon, 18 May 2026 21:22:02 +0300 Subject: [PATCH] fix(upload): scope archive extraction to per-session attachment dir handle_upload_extract() used Path(s.workspace) as the extraction root, bypassing HERMES_WEBUI_ATTACHMENT_DIR entirely. Route through _session_attachment_dir(session_id) so archives land alongside single-file uploads and session cleanup covers them. Add tests and CHANGELOG entry. Ref #2247 --- CHANGELOG.md | 4 + api/upload.py | 5 +- tests/test_pr2520_extract_attachment_dir.py | 94 +++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 tests/test_pr2520_extract_attachment_dir.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e938441..40eded9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [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 `///` so session deletion cleanup covers them and per-session isolation is preserved when `HERMES_WEBUI_ATTACHMENT_DIR` is configured. + ## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index) ### Changed diff --git a/api/upload.py b/api/upload.py index d85e2f13..87c14924 100644 --- a/api/upload.py +++ b/api/upload.py @@ -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) diff --git a/tests/test_pr2520_extract_attachment_dir.py b/tests/test_pr2520_extract_attachment_dir.py new file mode 100644 index 00000000..fd802ffc --- /dev/null +++ b/tests/test_pr2520_extract_attachment_dir.py @@ -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()