From b1bf800fa488f474417586382a9367fbecdc857a Mon Sep 17 00:00:00 2001 From: Yao Ning Date: Fri, 15 May 2026 10:20:05 +0800 Subject: [PATCH] feat: make upload size limit runtime-configurable Signed-off-by: Yao Ning --- ARCHITECTURE.md | 2 +- TESTING.md | 2 +- api/config.py | 32 ++++++++++++++++++- api/routes.py | 6 +++- api/upload.py | 7 ++-- static/index.html | 1 + static/ui.js | 2 +- tests/test_issue1867_upload_size_preflight.py | 16 +++++++--- 8 files changed, 54 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a0f92b9f..bad8dea6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -322,7 +322,7 @@ POST /api/approval/respond: ### 4.6 File Upload Parser parse_multipart(rfile, content_type, content_length): - - Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES = 20MB) + - Reads all content_length bytes from rfile into memory (up to MAX_UPLOAD_BYTES, default 20MB, env-overridable via HERMES_WEBUI_MAX_UPLOAD_MB) - Extracts boundary from Content-Type header - Splits raw bytes on b'--' + boundary - For each part: parses MIME headers via email.parser.HeaderParser diff --git a/TESTING.md b/TESTING.md index b472570a..24c8c546 100644 --- a/TESTING.md +++ b/TESTING.md @@ -558,7 +558,7 @@ FAIL: Multiple messages sent while one is in flight. ### T12.2: Upload Failure Shows Status SETUP: Active session. STEPS: - 1. Try to attach a file larger than 20MB (if available) + 1. Try to attach a file larger than the configured upload limit (20MB by default; overridden by `HERMES_WEBUI_MAX_UPLOAD_MB` if set) EXPECT: - Status bar shows an error message about file size or the upload is rejected - The chat is not broken (can still send messages) diff --git a/api/config.py b/api/config.py index da132ead..5f614c16 100644 --- a/api/config.py +++ b/api/config.py @@ -55,6 +55,36 @@ PROJECTS_FILE = STATE_DIR / "projects.json" logger = logging.getLogger(__name__) +def _env_mb_bytes(name: str, default_mb: int) -> int: + """Parse an optional megabyte environment variable into bytes. + + Accepts values like ``200``, ``200MB``, or ``200MiB``. Invalid or + non-positive values fall back to the provided default. + """ + raw = os.getenv(name, "").strip() + if not raw: + return default_mb * 1024 * 1024 + m = re.match(r"^(\d+)\s*(?:m|mb|mib)?$", raw, re.IGNORECASE) + if not m: + logger.warning( + "Invalid %s=%r; expected a positive integer in MB. Falling back to %sMB.", + name, + raw, + default_mb, + ) + return default_mb * 1024 * 1024 + value_mb = int(m.group(1)) + if value_mb <= 0: + logger.warning( + "Invalid %s=%r; expected a value greater than zero. Falling back to %sMB.", + name, + raw, + default_mb, + ) + return default_mb * 1024 * 1024 + return value_mb * 1024 * 1024 + + # ── Hermes agent directory discovery ───────────────────────────────────────── def _discover_agent_dir() -> Path: """ @@ -485,7 +515,7 @@ def verify_hermes_imports() -> tuple: # ── Limits ─────────────────────────────────────────────────────────────────── MAX_FILE_BYTES = 200_000 -MAX_UPLOAD_BYTES = 20 * 1024 * 1024 +MAX_UPLOAD_BYTES = _env_mb_bytes("HERMES_WEBUI_MAX_UPLOAD_MB", 20) # ── File type maps ─────────────────────────────────────────────────────────── IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".bmp"} diff --git a/api/routes.py b/api/routes.py index 58a87345..e81571a9 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3143,7 +3143,11 @@ def handle_get(handler, parsed) -> bool: version_token = quote(WEBUI_VERSION, safe="") from api.extensions import inject_extension_tags - html = _INDEX_HTML_PATH.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", version_token) + html = ( + _INDEX_HTML_PATH.read_text(encoding="utf-8") + .replace("__WEBUI_VERSION__", version_token) + .replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES)) + ) return t( handler, inject_extension_tags(html), diff --git a/api/upload.py b/api/upload.py index abe88c6b..cc3b5322 100644 --- a/api/upload.py +++ b/api/upload.py @@ -12,6 +12,8 @@ from api.helpers import j, bad from api.models import get_session from api.workspace import safe_resolve_ws +_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES + def parse_multipart(rfile, content_type, content_length) -> tuple: import re as _re, email.parser as _ep @@ -96,11 +98,6 @@ def handle_upload(handler): return j(handler, {'error': 'Upload failed'}, status=500) -# Maximum total extracted bytes — guards against zip/tar bombs. -# Set to 10x the upload limit; a legitimate archive rarely exceeds 3-4x. -_MAX_EXTRACTED_BYTES = 10 * 20 * 1024 * 1024 # 200 MB - - def extract_archive(file_bytes: bytes, filename: str, workspace: Path): """Extract a zip or tar archive into the workspace. diff --git a/static/index.html b/static/index.html index 4a33e1a2..b5ccbbdd 100644 --- a/static/index.html +++ b/static/index.html @@ -26,6 +26,7 @@ + diff --git a/static/ui.js b/static/ui.js index f270909f..0583c902 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1,7 +1,7 @@ const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false}; const INFLIGHT={}; // keyed by session_id while request in-flight const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns -const MAX_UPLOAD_BYTES=20*1024*1024; +const MAX_UPLOAD_BYTES=(window.__HERMES_CONFIG__&&window.__HERMES_CONFIG__.maxUploadBytes)||20*1024*1024; const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024); // Tracks which session's queue to drain in setBusy(false). // Set to activeSid just before setBusy(false) in done/error handlers so the diff --git a/tests/test_issue1867_upload_size_preflight.py b/tests/test_issue1867_upload_size_preflight.py index ae187672..b169208a 100644 --- a/tests/test_issue1867_upload_size_preflight.py +++ b/tests/test_issue1867_upload_size_preflight.py @@ -5,6 +5,7 @@ ROOT = Path(__file__).resolve().parents[1] UI_JS = ROOT / "static" / "ui.js" I18N_JS = ROOT / "static" / "i18n.js" CONFIG_PY = ROOT / "api" / "config.py" +UPLOAD_PY = ROOT / "api" / "upload.py" def _function_body(src: str, name: str) -> str: @@ -23,12 +24,12 @@ def _function_body(src: str, name: str) -> str: def test_upload_limit_constant_matches_server_limit(): - """The browser preflight limit must match api.config.MAX_UPLOAD_BYTES.""" + """The browser preflight should read the runtime upload limit.""" ui = UI_JS.read_text(encoding="utf-8") config = CONFIG_PY.read_text(encoding="utf-8") - assert "const MAX_UPLOAD_BYTES=20*1024*1024;" in ui - assert "MAX_UPLOAD_BYTES = 20 * 1024 * 1024" in config + assert "window.__HERMES_CONFIG__.maxUploadBytes" in ui + assert 'MAX_UPLOAD_BYTES = _env_mb_bytes("HERMES_WEBUI_MAX_UPLOAD_MB", 20)' in config def test_file_picker_rejects_oversize_files_before_queueing(): @@ -58,10 +59,17 @@ def test_pending_uploads_skip_fetch_for_oversize_files(): def test_upload_too_large_has_user_facing_message(): - """The status toast should explain the 20 MB limit instead of a network reset.""" + """The status toast should explain the upload limit instead of a network reset.""" i18n = I18N_JS.read_text(encoding="utf-8") ui = UI_JS.read_text(encoding="utf-8") assert "upload_too_large" in i18n assert "Maximum upload size is" in i18n assert "_uploadTooLargeMessage(file)" in ui + + +def test_archive_extraction_limit_tracks_upload_limit(): + """Archive extraction guard should scale with the configured upload limit.""" + upload = UPLOAD_PY.read_text(encoding="utf-8") + + assert "_MAX_EXTRACTED_BYTES = 10 * MAX_UPLOAD_BYTES" in upload