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