feat: make upload size limit runtime-configurable

Signed-off-by: Yao Ning <zay11022@gmail.com>
This commit is contained in:
Yao Ning
2026-05-15 10:20:05 +08:00
parent 5e518b1c10
commit b1bf800fa4
8 changed files with 54 additions and 14 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+31 -1
View File
@@ -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"}
+5 -1
View File
@@ -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),
+2 -5
View File
@@ -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.
+1
View File
@@ -26,6 +26,7 @@
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__};</script>
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
+1 -1
View File
@@ -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
+12 -4
View File
@@ -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