diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 582a1c5b..a30cfd32 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -31,7 +31,6 @@ from nanobot.bus.outbound_events import ( ) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel -from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base from nanobot.security.workspace_access import ( WORKSPACE_SCOPE_METADATA_KEY, @@ -39,10 +38,6 @@ from nanobot.security.workspace_access import ( ) from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.webui_turns import websocket_turn_wall_started_at -from nanobot.utils.media_decode import ( - FileSizeExceeded, - save_base64_data_url, -) from nanobot.webui.cli_apps_api import normalize_cli_app_mentions from nanobot.webui.forking import handle_webui_fork_chat from nanobot.webui.gateway_services import GatewayServices @@ -221,45 +216,6 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None: return data -# Per-message media limits. The server-side guard is a touch looser than the -# client's ``Worker`` normalization target (6 MB) — tolerate client slop, but -# still cap total ingress at ``_MAX_IMAGES_PER_MESSAGE * _MAX_IMAGE_BYTES`` -# which fits comfortably inside ``max_message_bytes``. -_MAX_IMAGES_PER_MESSAGE = 4 -_MAX_IMAGE_BYTES = 8 * 1024 * 1024 -_MAX_VIDEOS_PER_MESSAGE = 1 -_MAX_VIDEO_BYTES = 20 * 1024 * 1024 - -# Image MIME whitelist — matches the Composer's ``accept`` list. SVG is -# explicitly excluded to avoid the XSS surface inside embedded scripts. -_IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({ - "image/png", - "image/jpeg", - "image/webp", - "image/gif", -}) - -_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({ - "video/mp4", - "video/webm", - "video/quicktime", -}) - -_UPLOAD_MIME_ALLOWED: frozenset[str] = _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED - -_DATA_URL_MIME_RE = re.compile(r"^data:([^;,]+)(?:;[^,]*)*;base64,", re.DOTALL) - - -def _extract_data_url_mime(url: str) -> str | None: - """Return the MIME type of a ``data:;base64,...`` URL, else ``None``.""" - if not isinstance(url, str): - return None - m = _DATA_URL_MIME_RE.match(url) - if not m: - return None - return m.group(1).strip().lower() or None - - def _is_websocket_upgrade(request: WsRequest) -> bool: """Detect an actual WS upgrade; plain HTTP GETs to the same path should fall through.""" upgrade = request.headers.get("Upgrade") or request.headers.get("upgrade") @@ -301,6 +257,7 @@ class WebSocketChannel(BaseChannel): self._http_router = gateway.http self._tokens = gateway.tokens self._media = gateway.media + self._ingress = gateway.ingress self._transcripts = gateway.transcripts self._workspaces = gateway.workspaces @@ -582,74 +539,6 @@ class WebSocketChannel(BaseChannel): # -- Inbound WebSocket envelopes --------------------------------------- - def _save_envelope_media( - self, - media: list[Any], - ) -> tuple[list[str], str | None]: - """Decode and persist ``media`` items from a ``message`` envelope. - - Returns ``(paths, None)`` on success or ``([], reason)`` on the first - failure — the caller is expected to surface ``reason`` to the client - and skip publishing so no half-formed message ever reaches the agent. - On failure, any files already written to disk earlier in the same - call are unlinked so partial ingress doesn't leak orphan files. - ``reason`` is a short, stable token suitable for UI localization. - - Shape: ``list[{"data_url": str, "name"?: str | None}]``. - """ - image_count = 0 - video_count = 0 - for item in media: - mime = _extract_data_url_mime(item.get("data_url", "")) if isinstance(item, dict) else None - if mime in _VIDEO_MIME_ALLOWED: - video_count += 1 - elif mime in _IMAGE_MIME_ALLOWED: - image_count += 1 - if image_count > _MAX_IMAGES_PER_MESSAGE: - return [], "too_many_images" - if video_count > _MAX_VIDEOS_PER_MESSAGE: - return [], "too_many_videos" - - media_dir = get_media_dir("websocket") - paths: list[str] = [] - - def _abort(reason: str) -> tuple[list[str], str]: - for p in paths: - try: - Path(p).unlink(missing_ok=True) - except OSError as exc: - self.logger.warning( - "failed to unlink partial media {}: {}", p, exc - ) - return [], reason - - for item in media: - if not isinstance(item, dict): - return _abort("malformed") - data_url = item.get("data_url") - if not isinstance(data_url, str) or not data_url: - return _abort("malformed") - mime = _extract_data_url_mime(data_url) - if mime is None: - return _abort("decode") - if mime not in _UPLOAD_MIME_ALLOWED: - return _abort("mime") - is_video = mime in _VIDEO_MIME_ALLOWED - max_bytes = _MAX_VIDEO_BYTES if is_video else _MAX_IMAGE_BYTES - try: - saved = save_base64_data_url( - data_url, media_dir, max_bytes=max_bytes, - ) - except FileSizeExceeded: - return _abort("size") - except Exception as exc: - self.logger.warning("media decode failed: {}", exc) - return _abort("decode") - if saved is None: - return _abort("decode") - paths.append(saved) - return paths, None - async def _dispatch_envelope( self, connection: Any, @@ -732,25 +621,39 @@ class WebSocketChannel(BaseChannel): if not isinstance(content, str): await self._send_event(connection, "error", detail="missing content") return + message_rejection = self._ingress.validate_text(content) + if message_rejection is not None: + await self._send_event( + connection, + "error", + chat_id=cid, + detail="message_rejected", + reason=message_rejection, + ) + return raw_media = envelope.get("media") media_paths: list[str] = [] if raw_media is not None: if not isinstance(raw_media, list): await self._send_event( - connection, "error", - detail="image_rejected", reason="malformed", + connection, + "error", + detail="attachment_rejected", + reason="malformed", ) return - media_paths, reason = self._save_envelope_media(raw_media) + media_paths, reason = self._media.store_inbound_attachments(raw_media) if reason is not None: await self._send_event( - connection, "error", - detail="image_rejected", reason=reason, + connection, + "error", + detail="attachment_rejected", + reason=reason, ) return - # Allow image-only turns (content may be empty when media is attached). + # Allow media-only turns (content may be empty when attachments are present). if not content.strip() and not media_paths: await self._send_event(connection, "error", detail="missing content") return diff --git a/nanobot/utils/media_decode.py b/nanobot/utils/media_decode.py index 0c1682e7..4e4ac50b 100644 --- a/nanobot/utils/media_decode.py +++ b/nanobot/utils/media_decode.py @@ -32,6 +32,22 @@ _MIME_EXTENSION_OVERRIDES = { "audio/x-wav": ".wav", "audio/vnd.wave": ".wav", "video/webm": ".webm", + "application/json": ".json", + "application/pdf": ".pdf", + "application/toml": ".toml", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", + "application/x-yaml": ".yaml", + "application/xhtml+xml": ".html", + "application/xml": ".xml", + "application/yaml": ".yaml", + "text/csv": ".csv", + "text/html": ".html", + "text/markdown": ".md", + "text/plain": ".txt", + "text/xml": ".xml", + "text/yaml": ".yaml", } @@ -47,6 +63,7 @@ def save_base64_data_url( media_dir: Path, *, max_bytes: int | None = None, + filename: str | None = None, ) -> str | None: """Decode a ``data:;base64,`` URL and persist it. @@ -59,14 +76,18 @@ def save_base64_data_url( return None mime_type, b64_payload = m.group(1).strip().lower(), m.group(2) try: - raw = base64.b64decode(b64_payload) + raw = base64.b64decode(b64_payload, validate=True) except Exception: return None + if not raw: + return None limit = DEFAULT_MAX_BYTES if max_bytes is None else max_bytes if len(raw) > limit: raise FileSizeExceeded(f"File exceeds {limit // (1024 * 1024)}MB limit") ext = _MIME_EXTENSION_OVERRIDES.get(mime_type) or mimetypes.guess_extension(mime_type) or ".bin" - filename = f"{uuid.uuid4().hex[:12]}{ext}" - dest = media_dir / safe_filename(filename) + base = safe_filename(filename or "") + stem = Path(base).stem[:80] if base else "" + saved_name = f"{uuid.uuid4().hex[:12]}_{stem}{ext}" if stem else f"{uuid.uuid4().hex[:12]}{ext}" + dest = media_dir / safe_filename(saved_name) dest.write_bytes(raw) return str(dest) diff --git a/nanobot/webui/attachment_ingress.py b/nanobot/webui/attachment_ingress.py new file mode 100644 index 00000000..a9e19bd2 --- /dev/null +++ b/nanobot/webui/attachment_ingress.py @@ -0,0 +1,170 @@ +"""Validation and persistence for inbound WebUI message attachments.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Literal + +from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url +from nanobot.webui.ingress_policy import ( + DEFAULT_WEBUI_INGRESS_POLICY, + AttachmentIngressLimits, +) + +AttachmentRejection = Literal[ + "malformed", + "too_many_images", + "too_many_videos", + "too_many_attachments", + "total_size", + "mime", + "size", + "decode", +] +AttachmentIngressResult = tuple[list[str], AttachmentRejection | None] + +_MAX_VIDEOS_PER_MESSAGE = 1 +_MAX_VIDEO_BYTES = 20 * 1024 * 1024 + +_IMAGE_MIME_ALLOWED: frozenset[str] = frozenset({ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", +}) + +_VIDEO_MIME_ALLOWED: frozenset[str] = frozenset({ + "video/mp4", + "video/webm", + "video/quicktime", +}) + +_DOCUMENT_MIME_ALLOWED: frozenset[str] = frozenset({ + "application/json", + "application/pdf", + "application/toml", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/x-yaml", + "application/xhtml+xml", + "application/xml", + "application/yaml", + "text/csv", + "text/html", + "text/markdown", + "text/plain", + "text/xml", + "text/yaml", +}) + +_UPLOAD_MIME_ALLOWED: frozenset[str] = ( + _IMAGE_MIME_ALLOWED | _VIDEO_MIME_ALLOWED | _DOCUMENT_MIME_ALLOWED +) + +_DATA_URL_MIME_RE = re.compile(r"^data:([^;,]+)(?:;[^,]*)*;base64,", re.DOTALL) + + +def extract_data_url_mime(url: Any) -> str | None: + """Return the normalized MIME from a base64 data URL, else ``None``.""" + if not isinstance(url, str): + return None + match = _DATA_URL_MIME_RE.match(url) + if match is None: + return None + return match.group(1).strip().lower() or None + + +def store_inbound_attachments( + media: list[Any], + *, + media_dir: Path, + logger: Any, + limits: AttachmentIngressLimits = DEFAULT_WEBUI_INGRESS_POLICY.attachments, +) -> AttachmentIngressResult: + """Validate and atomically persist one WebUI message's attachments. + + The caller owns transport-level error mapping. This function owns the + WebUI upload policy and removes files already written when a later item + makes the batch invalid. + """ + image_count = 0 + video_count = 0 + document_count = 0 + for item in media: + mime = ( + extract_data_url_mime(item.get("data_url", "")) + if isinstance(item, dict) + else None + ) + if mime in _VIDEO_MIME_ALLOWED: + video_count += 1 + elif mime in _IMAGE_MIME_ALLOWED: + image_count += 1 + elif mime in _DOCUMENT_MIME_ALLOWED: + document_count += 1 + if image_count > limits.max_count: + return [], "too_many_images" + if video_count > _MAX_VIDEOS_PER_MESSAGE: + return [], "too_many_videos" + if image_count + document_count > limits.max_count: + return [], "too_many_attachments" + + paths: list[str] = [] + total_attachment_bytes = 0 + + def abort(reason: AttachmentRejection) -> AttachmentIngressResult: + for path in paths: + try: + Path(path).unlink(missing_ok=True) + except OSError as exc: + logger.warning("failed to unlink partial media {}: {}", path, exc) + return [], reason + + for item in media: + if not isinstance(item, dict): + return abort("malformed") + data_url = item.get("data_url") + if not isinstance(data_url, str) or not data_url: + return abort("malformed") + mime = extract_data_url_mime(data_url) + if mime is None: + return abort("decode") + if mime not in _UPLOAD_MIME_ALLOWED: + return abort("mime") + is_video = mime in _VIDEO_MIME_ALLOWED + is_document = mime in _DOCUMENT_MIME_ALLOWED + max_bytes = ( + _MAX_VIDEO_BYTES if is_video + else limits.max_file_bytes + ) + name = ( + item.get("name") + if is_document and isinstance(item.get("name"), str) + else None + ) + try: + saved = save_base64_data_url( + data_url, + media_dir, + max_bytes=max_bytes, + filename=name, + ) + except FileSizeExceeded: + return abort("size") + except Exception as exc: + logger.warning("media decode failed: {}", exc) + return abort("decode") + if saved is None: + return abort("decode") + paths.append(saved) + if not is_video: + try: + total_attachment_bytes += Path(saved).stat().st_size + except OSError as exc: + logger.warning("failed to stat inbound attachment {}: {}", saved, exc) + return abort("decode") + if total_attachment_bytes > limits.max_total_bytes: + return abort("total_size") + return paths, None diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index bced1b37..4f015ee4 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -9,6 +9,7 @@ from typing import Any, Callable from loguru import logger as default_logger from nanobot.webui.gateway_tokens import GatewayTokenStore +from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.transcript import WebUITranscriptRecorder from nanobot.webui.workspaces import WebUIWorkspaceController @@ -22,6 +23,7 @@ class GatewayServices: http: GatewayHTTPHandler tokens: GatewayTokenStore media: WebUIMediaGateway + ingress: WebUIIngressPolicy transcripts: WebUITranscriptRecorder workspaces: WebUIWorkspaceController session_manager: Any | None @@ -51,9 +53,19 @@ def build_gateway_services( logger: Any = default_logger, ) -> GatewayServices: tokens = GatewayTokenStore() + ingress = DEFAULT_WEBUI_INGRESS_POLICY + minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes() + if config.max_message_bytes < minimum_frame_bytes: + logger.warning( + "WebSocket maxMessageBytes={} is below the WebUI ingress policy capacity={}; " + "policy-valid messages may still hit the transport frame guard", + config.max_message_bytes, + minimum_frame_bytes, + ) media = WebUIMediaGateway( workspace_path=workspace_path, logger=logger, + attachment_limits=ingress.attachments, ) transcripts = WebUITranscriptRecorder(log=logger) workspaces = WebUIWorkspaceController( @@ -71,6 +83,7 @@ def build_gateway_services( bus=bus, tokens=tokens, media=media, + ingress=ingress, workspaces=workspaces, skills_workspace_path=workspace_path, disabled_skills=disabled_skills, @@ -85,6 +98,7 @@ def build_gateway_services( http=http, tokens=tokens, media=media, + ingress=ingress, transcripts=transcripts, workspaces=workspaces, session_manager=session_manager, diff --git a/nanobot/webui/ingress_policy.py b/nanobot/webui/ingress_policy.py new file mode 100644 index 00000000..80ca4059 --- /dev/null +++ b/nanobot/webui/ingress_policy.py @@ -0,0 +1,70 @@ +"""Business limits for inbound WebUI messages and attachments. + +The WebSocket channel owns its raw frame limit. This module owns semantic +limits inside a decoded WebUI message so transport capacity isn't mistaken +for a text or attachment policy. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Literal + +MessageRejection = Literal["text_too_large"] + + +@dataclass(frozen=True) +class MessageIngressLimits: + max_text_bytes: int = 64 * 1024 + + +@dataclass(frozen=True) +class AttachmentIngressLimits: + max_count: int = 4 + max_file_bytes: int = 6 * 1024 * 1024 + max_total_bytes: int = 24 * 1024 * 1024 + + +@dataclass(frozen=True) +class WebUIIngressPolicy: + """Limits applied after the channel has decoded the transport envelope.""" + + message: MessageIngressLimits = field(default_factory=MessageIngressLimits) + attachments: AttachmentIngressLimits = field(default_factory=AttachmentIngressLimits) + # Covers JSON keys, IDs, attachment names, MIME prefixes, mentions, and + # other non-content fields when the browser estimates whether a frame fits. + envelope_reserve_bytes: int = 64 * 1024 + + def validate_text(self, content: str) -> MessageRejection | None: + if len(content.encode("utf-8")) > self.message.max_text_bytes: + return "text_too_large" + return None + + def bootstrap_limits(self, *, max_frame_bytes: int) -> dict[str, object]: + return { + "transport": { + "max_frame_bytes": max_frame_bytes, + "envelope_reserve_bytes": self.envelope_reserve_bytes, + }, + "message": {"max_text_bytes": self.message.max_text_bytes}, + "attachments": { + "max_count": self.attachments.max_count, + "max_file_bytes": self.attachments.max_file_bytes, + "max_total_bytes": self.attachments.max_total_bytes, + }, + } + + def minimum_full_policy_frame_bytes(self) -> int: + """Conservative frame size needed for every policy-valid message.""" + encoded_attachments = 4 * math.ceil(self.attachments.max_total_bytes / 3) + data_url_allowance = self.attachments.max_count * 128 + return ( + encoded_attachments + + data_url_allowance + + self.message.max_text_bytes + + self.envelope_reserve_bytes + ) + + +DEFAULT_WEBUI_INGRESS_POLICY = WebUIIngressPolicy() diff --git a/nanobot/webui/media_gateway.py b/nanobot/webui/media_gateway.py index 27109fa3..4483e334 100644 --- a/nanobot/webui/media_gateway.py +++ b/nanobot/webui/media_gateway.py @@ -11,6 +11,11 @@ from websockets.http11 import Request as WsRequest from websockets.http11 import Response from nanobot.config.paths import get_media_dir +from nanobot.webui.attachment_ingress import ( + AttachmentIngressResult, + store_inbound_attachments, +) +from nanobot.webui.ingress_policy import AttachmentIngressLimits from nanobot.webui.media_api import ( attach_signed_media_urls, serve_signed_media, @@ -31,11 +36,22 @@ class WebUIMediaGateway: logger: Any, media_dir: Callable[[str | None], Path] | None = None, secret: bytes | None = None, + attachment_limits: AttachmentIngressLimits | None = None, ) -> None: self.workspace_path = workspace_path self.logger = logger self._media_dir = media_dir or (lambda channel=None: get_media_dir(channel)) self.secret = secret or secrets.token_bytes(32) + self.attachment_limits = attachment_limits or AttachmentIngressLimits() + + def store_inbound_attachments(self, media: list[Any]) -> AttachmentIngressResult: + """Validate and persist attachments from an inbound WebUI message.""" + return store_inbound_attachments( + media, + media_dir=self._media_dir("websocket"), + logger=self.logger, + limits=self.attachment_limits, + ) def serve_signed_media( self, diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 664108a2..19c05b86 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -70,6 +70,7 @@ from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import ( safe_host_header as _safe_host_header, ) +from nanobot.webui.ingress_policy import WebUIIngressPolicy from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.session_automations import ( all_automations_payload, @@ -155,6 +156,7 @@ class GatewayHTTPHandler: bus: MessageBus, tokens: GatewayTokenStore, media: WebUIMediaGateway, + ingress: WebUIIngressPolicy, workspaces: WebUIWorkspaceController, skills_workspace_path: Path, disabled_skills: set[str] | None = None, @@ -172,6 +174,7 @@ class GatewayHTTPHandler: self.bus = bus self.tokens = tokens self.media = media + self.ingress = ingress self.workspaces = workspaces self.skills_workspace_path = skills_workspace_path self.disabled_skills = disabled_skills or set() @@ -340,6 +343,9 @@ class GatewayHTTPHandler: "ws_path": expected_path, "ws_url": ws_url, "expires_in": self.config.token_ttl_s, + "limits": self.ingress.bootstrap_limits( + max_frame_bytes=self.config.max_message_bytes, + ), "model_name": _resolve_bootstrap_model_name(self.runtime_model_name), "runtime_surface": self._runtime_surface, "runtime_capabilities": self._capabilities, diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index c5ec0a84..a743ce2c 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -1018,7 +1018,6 @@ async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) - def fake_media_dir(channel: str | None = None): return ws_media if channel == "websocket" else media_root - monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir) monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir) channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus)) mock_ws = AsyncMock() @@ -1263,7 +1262,6 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch, path.mkdir(parents=True, exist_ok=True) return path - monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir) monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir) channel = WebSocketChannel( {"enabled": True, "allowFrom": ["*"], "streaming": True}, @@ -1296,7 +1294,6 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp path.mkdir(parents=True, exist_ok=True) return path - monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir) monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir) channel = WebSocketChannel( {"enabled": True, "allowFrom": ["*"], "streaming": True}, diff --git a/tests/channels/test_websocket_envelope_media.py b/tests/channels/test_websocket_envelope_media.py index 88c24e47..56829e56 100644 --- a/tests/channels/test_websocket_envelope_media.py +++ b/tests/channels/test_websocket_envelope_media.py @@ -1,4 +1,4 @@ -"""Tests for WS envelope media handling (client image upload path). +"""Tests for WS envelope media handling (client attachment upload path). Exercises ``WebSocketChannel._dispatch_envelope`` for the ``message`` branch: decoding base64 data URLs, rejecting malformed / oversized / non-whitelisted @@ -11,7 +11,6 @@ from __future__ import annotations import base64 import json from pathlib import Path -from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -19,7 +18,6 @@ import pytest from nanobot.channels.websocket import ( WebSocketChannel, WebSocketConfig, - _extract_data_url_mime, ) from nanobot.webui.gateway_services import build_gateway_services @@ -61,28 +59,6 @@ def _make_channel() -> WebSocketChannel: return channel -# -- Pure helpers -------------------------------------------------------------- - - -@pytest.mark.parametrize( - ("url", "expected"), - [ - ("data:image/png;base64,AAAA", "image/png"), - ("data:image/jpeg;base64,AAAA", "image/jpeg"), - ("data:audio/webm;codecs=opus;base64,AAAA", "audio/webm"), - ("data:IMAGE/PNG;base64,AAAA", "image/png"), - ("data:image/svg+xml;base64,AAAA", "image/svg+xml"), - ("data:text/plain;base64,AAAA", "text/plain"), - ("http://evil.example/x.png", None), - ("data:image/png,AAAA", None), # missing `;base64` - ("", None), - (None, None), - ], -) -def test_extract_data_url_mime(url: Any, expected: str | None) -> None: - assert _extract_data_url_mime(url) == expected - - # -- max_message_bytes bump ---------------------------------------------------- @@ -118,6 +94,28 @@ async def test_message_without_media_backward_compatible() -> None: assert call.kwargs["media"] is None +@pytest.mark.asyncio +async def test_message_text_policy_is_independent_from_transport_limit() -> None: + channel = _make_channel() + mock_conn = AsyncMock() + envelope = { + "type": "message", + "chat_id": "abc123", + "content": "你" * 22_000, + } + + await channel._dispatch_envelope(mock_conn, "client-1", envelope) + + channel._handle_message.assert_not_awaited() + err = json.loads(mock_conn.send.call_args[0][0]) + assert err == { + "event": "error", + "chat_id": "abc123", + "detail": "message_rejected", + "reason": "text_too_large", + } + + @pytest.mark.asyncio async def test_message_forwards_normalized_cli_app_attachments() -> None: channel = _make_channel() @@ -167,7 +165,7 @@ async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -196,7 +194,7 @@ async def test_message_with_multiple_images(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -219,7 +217,7 @@ async def test_image_only_message_allows_empty_text(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -240,7 +238,7 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -248,10 +246,38 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None: mock_conn.send.assert_awaited_once() err = json.loads(mock_conn.send.call_args[0][0]) assert err["event"] == "error" - assert err["detail"] == "image_rejected" + assert err["detail"] == "attachment_rejected" assert err["reason"] == "too_many_images" +@pytest.mark.asyncio +async def test_message_rejected_when_too_many_total_attachments(tmp_path) -> None: + channel = _make_channel() + mock_conn = AsyncMock() + envelope = { + "type": "message", + "chat_id": "abc123", + "content": "mixed", + "media": [ + {"data_url": _tiny_png_data_url()}, + {"data_url": _tiny_png_data_url()}, + {"data_url": _tiny_png_data_url()}, + {"data_url": _tiny_png_data_url()}, + {"data_url": _data_url("application/pdf", b"%PDF-1.4"), "name": "report.pdf"}, + ], + } + + with patch( + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path + ): + await channel._dispatch_envelope(mock_conn, "client-1", envelope) + + channel._handle_message.assert_not_awaited() + err = json.loads(mock_conn.send.call_args[0][0]) + assert err["detail"] == "attachment_rejected" + assert err["reason"] == "too_many_attachments" + + @pytest.mark.asyncio async def test_message_rejected_on_oversize_payload(tmp_path) -> None: channel = _make_channel() @@ -265,35 +291,87 @@ async def test_message_rejected_on_oversize_payload(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) channel._handle_message.assert_not_awaited() err = json.loads(mock_conn.send.call_args[0][0]) - assert err["detail"] == "image_rejected" + assert err["detail"] == "attachment_rejected" assert err["reason"] == "size" @pytest.mark.asyncio -async def test_message_rejected_on_non_image_mime(tmp_path) -> None: +async def test_message_with_pdf_forwards_saved_path(tmp_path) -> None: channel = _make_channel() mock_conn = AsyncMock() envelope = { "type": "message", "chat_id": "abc123", "content": "pdf?", - "media": [{"data_url": _data_url("application/pdf", b"%PDF-1.4")}], + "media": [{"data_url": _data_url("application/pdf", b"%PDF-1.4"), "name": "report.pdf"}], } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path + ): + await channel._dispatch_envelope(mock_conn, "client-1", envelope) + + channel._handle_message.assert_awaited_once() + paths = channel._handle_message.call_args.kwargs["media"] + assert isinstance(paths, list) and len(paths) == 1 + saved = Path(paths[0]) + assert saved.exists() + assert saved.suffix == ".pdf" + assert saved.name.endswith("_report.pdf") + assert saved.read_bytes() == b"%PDF-1.4" + + +@pytest.mark.asyncio +async def test_message_with_csv_forwards_saved_path(tmp_path) -> None: + channel = _make_channel() + mock_conn = AsyncMock() + envelope = { + "type": "message", + "chat_id": "abc123", + "content": "summarize", + "media": [ + {"data_url": _data_url("text/csv", b"name,value\nnanobot,1"), "name": "report.csv"} + ], + } + + with patch( + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path + ): + await channel._dispatch_envelope(mock_conn, "client-1", envelope) + + channel._handle_message.assert_awaited_once() + paths = channel._handle_message.call_args.kwargs["media"] + saved = Path(paths[0]) + assert saved.suffix == ".csv" + assert saved.name.endswith("_report.csv") + assert saved.read_bytes() == b"name,value\nnanobot,1" + + +@pytest.mark.asyncio +async def test_message_rejected_on_unsupported_file_mime(tmp_path) -> None: + channel = _make_channel() + mock_conn = AsyncMock() + envelope = { + "type": "message", + "chat_id": "abc123", + "content": "zip?", + "media": [{"data_url": _data_url("application/zip", b"PK")}], + } + + with patch( + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) channel._handle_message.assert_not_awaited() err = json.loads(mock_conn.send.call_args[0][0]) - assert err["detail"] == "image_rejected" + assert err["detail"] == "attachment_rejected" assert err["reason"] == "mime" @@ -310,7 +388,7 @@ async def test_message_rejected_on_svg_mime(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -331,7 +409,7 @@ async def test_message_rejected_on_malformed_data_url(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -352,7 +430,7 @@ async def test_message_rejected_on_broken_base64(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -374,7 +452,7 @@ async def test_message_rejected_when_media_item_shape_wrong(tmp_path) -> None: } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) @@ -398,15 +476,15 @@ async def test_message_rejected_when_media_field_is_not_list() -> None: channel._handle_message.assert_not_awaited() err = json.loads(mock_conn.send.call_args[0][0]) - assert err["detail"] == "image_rejected" + assert err["detail"] == "attachment_rejected" assert err["reason"] == "malformed" @pytest.mark.asyncio async def test_failed_media_does_not_partially_persist(tmp_path) -> None: - """If the second image is invalid, the first must not be forwarded. + """If the second attachment is invalid, the first must not be forwarded. - Also: images already written in this call are cleaned up on failure, so + Also: files already written in this call are cleaned up on failure, so a mixed-valid/invalid batch never leaves orphan files in the media dir. """ channel = _make_channel() @@ -417,12 +495,12 @@ async def test_failed_media_does_not_partially_persist(tmp_path) -> None: "content": "mixed", "media": [ {"data_url": _tiny_png_data_url()}, - {"data_url": _data_url("application/pdf", b"%PDF-1.4")}, + {"data_url": _data_url("image/svg+xml", b"")}, ], } with patch( - "nanobot.channels.websocket.get_media_dir", return_value=tmp_path + "nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path ): await channel._dispatch_envelope(mock_conn, "client-1", envelope) diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index dc00af5f..a2f03a7e 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -204,7 +204,12 @@ async def test_bootstrap_returns_token_for_localhost( bus: MagicMock, tmp_path: Path ) -> None: sm = _seed_session(tmp_path) - channel = _ch(bus, session_manager=sm, port=29901) + channel = _ch( + bus, + session_manager=sm, + port=29901, + maxMessageBytes=1_048_576, + ) server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) try: @@ -217,6 +222,19 @@ async def test_bootstrap_returns_token_for_localhost( assert body["ws_path"] == "/" assert body["ws_url"] == "ws://127.0.0.1:29901/" assert body["expires_in"] > 0 + assert body["limits"] == { + "transport": { + "max_frame_bytes": 1_048_576, + "envelope_reserve_bytes": 65_536, + }, + "message": {"max_text_bytes": 65_536}, + "attachments": { + "max_count": 4, + "max_file_bytes": 6_291_456, + "max_total_bytes": 25_165_824, + }, + } + assert "max_message_bytes" not in body assert isinstance(body.get("model_name"), str) finally: await channel.stop() @@ -2422,7 +2440,7 @@ async def test_webui_thread_resigns_assistant_media_urls( def fake_media_dir(channel: str | None = None) -> Path: return websocket_media if channel == "websocket" else media_root - monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir) + monkeypatch.setattr("nanobot.webui.media_gateway.get_media_dir", fake_media_dir) append_transcript_object( "websocket:video-replay", diff --git a/tests/channels/test_websocket_protocol_boundaries.py b/tests/channels/test_websocket_protocol_boundaries.py index caed66aa..b993ba06 100644 --- a/tests/channels/test_websocket_protocol_boundaries.py +++ b/tests/channels/test_websocket_protocol_boundaries.py @@ -5,7 +5,6 @@ from __future__ import annotations import pytest from nanobot.channels.websocket import ( - _extract_data_url_mime, _is_valid_chat_id, _parse_envelope, ) @@ -56,22 +55,3 @@ def test_parse_envelope_only_accepts_typed_json_objects( else: assert parsed is not None assert parsed["type"] == expected_type - - -@pytest.mark.parametrize( - ("url", "expected"), - [ - ("data:image/png;base64,AAAA", "image/png"), - ("data:IMAGE/JPEG;charset=utf-8;base64,AAAA", "image/jpeg"), - ("data:video/webm;codecs=vp9;base64,AAAA", "video/webm"), - ("data:image/svg+xml;base64,AAAA", "image/svg+xml"), - ("data:image/png,AAAA", None), - ("data:;base64,AAAA", None), - ("https://example.invalid/image.png", None), - ], -) -def test_extract_data_url_mime_normalizes_only_base64_data_urls( - url: str, - expected: str | None, -) -> None: - assert _extract_data_url_mime(url) == expected diff --git a/tests/utils/test_media_decode.py b/tests/utils/test_media_decode.py index a0f357c4..cbe6cd18 100644 --- a/tests/utils/test_media_decode.py +++ b/tests/utils/test_media_decode.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +from pathlib import Path import pytest @@ -50,14 +51,38 @@ def test_saves_common_audio_with_api_friendly_extension( assert result.endswith(suffix) +def test_saves_document_with_safe_original_name(tmp_path) -> None: + result = save_base64_data_url( + _data_url(b"%PDF-1.4", mime="application/pdf"), + tmp_path, + filename="quarterly report.pdf", + ) + assert result is not None + assert result.endswith("_quarterly report.pdf") + assert Path(result).read_bytes() == b"%PDF-1.4" + + +def test_document_filename_cannot_escape_media_dir(tmp_path) -> None: + result = save_base64_data_url( + _data_url(b"%PDF-1.4", mime="application/pdf"), + tmp_path, + filename="../../escape.pdf", + ) + assert result is not None + saved = Path(result) + assert saved.parent == tmp_path + assert saved.suffix == ".pdf" + assert saved.read_bytes() == b"%PDF-1.4" + + def test_returns_none_for_malformed_data_url(tmp_path) -> None: assert save_base64_data_url("not-a-data-url", tmp_path) is None -def test_returns_none_for_broken_base64(tmp_path) -> None: - # Python's b64decode strips non-alphabet chars by default, so we need a - # payload whose alphabet-filtered length breaks padding. - assert save_base64_data_url("data:image/png;base64,not-valid-base64!!!", tmp_path) is None +@pytest.mark.parametrize("payload", ["not-valid-base64!!!", "@@@@"]) +def test_returns_none_for_broken_base64(tmp_path, payload: str) -> None: + assert save_base64_data_url(f"data:image/png;base64,{payload}", tmp_path) is None + assert list(tmp_path.iterdir()) == [] def test_unknown_mime_falls_back_to_bin(tmp_path) -> None: diff --git a/tests/webui/test_attachment_ingress.py b/tests/webui/test_attachment_ingress.py new file mode 100644 index 00000000..b7b78d43 --- /dev/null +++ b/tests/webui/test_attachment_ingress.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from nanobot.webui.attachment_ingress import ( + extract_data_url_mime, + store_inbound_attachments, +) +from nanobot.webui.ingress_policy import AttachmentIngressLimits + + +def _data_url(mime: str, payload: bytes) -> str: + encoded = base64.b64encode(payload).decode() + return f"data:{mime};base64,{encoded}" + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("data:image/png;base64,AAAA", "image/png"), + ("data:IMAGE/JPEG;charset=utf-8;base64,AAAA", "image/jpeg"), + ("data:video/webm;codecs=vp9;base64,AAAA", "video/webm"), + ("data:text/plain;base64,AAAA", "text/plain"), + ("data:image/svg+xml;base64,AAAA", "image/svg+xml"), + ("data:image/png,AAAA", None), + ("data:;base64,AAAA", None), + ("https://example.invalid/image.png", None), + ("", None), + (None, None), + ], +) +def test_extract_data_url_mime_normalizes_only_base64_data_urls( + url: Any, + expected: str | None, +) -> None: + assert extract_data_url_mime(url) == expected + + +def test_store_inbound_document_preserves_safe_name(tmp_path: Path) -> None: + paths, rejection = store_inbound_attachments( + [ + { + "data_url": _data_url("text/csv", b"name,value\nnanobot,1"), + "name": "report.csv", + }, + ], + media_dir=tmp_path, + logger=MagicMock(), + ) + + assert rejection is None + assert len(paths) == 1 + saved = Path(paths[0]) + assert saved.parent == tmp_path + assert saved.name.endswith("_report.csv") + assert saved.read_bytes() == b"name,value\nnanobot,1" + + +def test_invalid_batch_removes_files_already_persisted(tmp_path: Path) -> None: + paths, rejection = store_inbound_attachments( + [ + {"data_url": _data_url("image/png", b"valid-first-item")}, + {"data_url": _data_url("image/svg+xml", b"")}, + ], + media_dir=tmp_path, + logger=MagicMock(), + ) + + assert paths == [] + assert rejection == "mime" + assert list(tmp_path.iterdir()) == [] + + +def test_invalid_base64_cannot_create_an_empty_attachment(tmp_path: Path) -> None: + paths, rejection = store_inbound_attachments( + [{"data_url": "data:text/plain;base64,@@@@", "name": "empty.txt"}], + media_dir=tmp_path, + logger=MagicMock(), + ) + + assert paths == [] + assert rejection == "decode" + assert list(tmp_path.iterdir()) == [] + + +def test_single_file_limit_is_attachment_policy_not_transport(tmp_path: Path) -> None: + paths, rejection = store_inbound_attachments( + [{"data_url": _data_url("text/plain", b"12345"), "name": "large.txt"}], + media_dir=tmp_path, + logger=MagicMock(), + limits=AttachmentIngressLimits(max_file_bytes=4, max_total_bytes=20), + ) + + assert paths == [] + assert rejection == "size" + assert list(tmp_path.iterdir()) == [] + + +def test_total_attachment_policy_rolls_back_the_batch(tmp_path: Path) -> None: + paths, rejection = store_inbound_attachments( + [ + {"data_url": _data_url("text/plain", b"1234"), "name": "one.txt"}, + {"data_url": _data_url("text/plain", b"5678"), "name": "two.txt"}, + ], + media_dir=tmp_path, + logger=MagicMock(), + limits=AttachmentIngressLimits(max_file_bytes=4, max_total_bytes=6), + ) + + assert paths == [] + assert rejection == "total_size" + assert list(tmp_path.iterdir()) == [] diff --git a/tests/webui/test_ingress_policy.py b/tests/webui/test_ingress_policy.py new file mode 100644 index 00000000..8751665b --- /dev/null +++ b/tests/webui/test_ingress_policy.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from nanobot.webui.ingress_policy import WebUIIngressPolicy + + +def test_text_limit_counts_utf8_bytes() -> None: + policy = WebUIIngressPolicy() + + assert policy.validate_text("x" * policy.message.max_text_bytes) is None + assert policy.validate_text("你" * 22_000) == "text_too_large" + + +def test_bootstrap_keeps_transport_and_business_limits_separate() -> None: + policy = WebUIIngressPolicy() + + payload = policy.bootstrap_limits(max_frame_bytes=1_048_576) + + assert payload["transport"] == { + "max_frame_bytes": 1_048_576, + "envelope_reserve_bytes": 65_536, + } + assert payload["message"] == {"max_text_bytes": 65_536} + assert payload["attachments"] == { + "max_count": 4, + "max_file_bytes": 6_291_456, + "max_total_bytes": 25_165_824, + } + assert policy.minimum_full_policy_frame_bytes() < 36 * 1024 * 1024 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 870aa7e4..c038355b 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -38,6 +38,7 @@ import { deriveTitle } from "@/lib/format"; import { NanobotClient } from "@/lib/nanobot-client"; import { ClientProvider, useClient } from "@/providers/ClientProvider"; import type { + BootstrapResponse, ChatSummary, RuntimeSurface, PairingRequestInfo, @@ -71,6 +72,7 @@ type BootState = token: string; tokenExpiresAt: number; modelName: string | null; + ingressLimits: BootstrapResponse["limits"] | null; runtimeSurface: RuntimeSurface; }; @@ -801,6 +803,7 @@ export default function App() { token: boot.api_token, tokenExpiresAt, modelName: boot.model_name ?? current.modelName, + ingressLimits: boot.limits ?? current.ingressLimits, runtimeSurface, } : current, @@ -842,6 +845,7 @@ export default function App() { token: boot.api_token, tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), modelName: boot.model_name ?? null, + ingressLimits: boot.limits ?? null, runtimeSurface, }); } catch (e) { @@ -964,6 +968,7 @@ export default function App() { client={state.client} token={state.token} modelName={state.modelName} + ingressLimits={state.ingressLimits} > ``: aligned with the server's MIME whitelist. SVG is - * deliberately excluded to avoid an embedded-script XSS surface. */ -const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; const VOICE_SHORTCUT_CODE = "KeyD"; const VOICE_SHORTCUT_ARIA = "Control+Shift+D"; type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows"; @@ -143,6 +144,10 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB`; } +function utf8Bytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + function isVoiceShortcutDown(event: KeyboardEvent): boolean { return ( event.code === VOICE_SHORTCUT_CODE @@ -192,7 +197,7 @@ function getVoiceShortcutLabel(): string { } interface ThreadComposerProps { - onSend: (content: string, images?: SendImage[], options?: SendOptions) => void; + onSend: (content: string, images?: SendAttachment[], options?: SendOptions) => void; disabled?: boolean; placeholder?: string; isStreaming?: boolean; @@ -220,6 +225,7 @@ interface ThreadComposerProps { onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; pendingQueueKey?: string | null; transcriptionProvider?: string | null; + ingressLimits?: WebUIIngressLimits | null; } const COMMAND_ICONS: Record = { @@ -301,6 +307,7 @@ interface QueuedPrompt { interface QueuedPromptImage { dataUrl: string; name?: string; + kind?: AttachmentKind; } interface CliAppMentionQuery { @@ -367,16 +374,22 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul ? record.images.flatMap((image) => { if (!image || typeof image !== "object") return []; const candidate = image as Partial; - if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:image/")) { + if (typeof candidate.dataUrl !== "string" || !candidate.dataUrl.startsWith("data:")) { return []; } + const kind = candidate.kind === "file" || candidate.kind === "image" + ? candidate.kind + : candidate.dataUrl.startsWith("data:image/") + ? "image" + : "file"; return [{ dataUrl: candidate.dataUrl, + kind, ...(typeof candidate.name === "string" && candidate.name.trim() ? { name: candidate.name.trim() } : {}), }]; - }).slice(0, MAX_IMAGES_PER_MESSAGE) + }).slice(0, MAX_ATTACHMENTS_PER_MESSAGE) : []; if (!text && images.length === 0) return null; const id = typeof record.id === "string" && record.id.trim() @@ -413,7 +426,7 @@ function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void { prompts.slice(0, QUEUED_PROMPTS_LIMIT).map((prompt) => ({ id: prompt.id, text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS), - ...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_IMAGES_PER_MESSAGE) } : {}), + ...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_ATTACHMENTS_PER_MESSAGE) } : {}), })), ), ); @@ -427,11 +440,12 @@ function readyImagesToQueuedImages( ): QueuedPromptImage[] { return images.map((img) => ({ dataUrl: img.dataUrl, + kind: img.kind, name: img.file.name, })); } -function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | undefined { +function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendAttachment[] | undefined { if (!images?.length) return undefined; return images.map((img) => ({ media: { @@ -439,6 +453,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u ...(img.name ? { name: img.name } : {}), }, preview: { + kind: img.kind ?? (img.dataUrl.startsWith("data:image/") ? "image" : "file"), url: img.dataUrl, ...(img.name ? { name: img.name } : {}), }, @@ -448,7 +463,7 @@ function queuedImagesToSendImages(images?: QueuedPromptImage[]): SendImage[] | u function queuedPromptLabel(prompt: QueuedPrompt): string { const text = prompt.text.trim(); if (text) return text; - return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "Image attachment"; + return prompt.images?.map((img) => img.name).filter(Boolean).join(", ") || "File attachment"; } function suppressNativeDragPreview(dataTransfer: DataTransfer): void { @@ -837,6 +852,7 @@ export function ThreadComposer({ onWorkspaceScopeChange, pendingQueueKey = null, transcriptionProvider = null, + ingressLimits = null, }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); @@ -892,15 +908,37 @@ export function ThreadComposer({ ? t("thread.composer.placeholderStreaming") : placeholder ?? t("thread.composer.placeholderThread"); + const maxAttachments = ingressLimits?.attachments.max_count + ?? MAX_ATTACHMENTS_PER_MESSAGE; + const maxTextBytes = ingressLimits?.message.max_text_bytes ?? 64 * 1024; const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } = - useAttachedImages(); + useAttachedImages({ ingressLimits }); const formatRejection = useCallback( (reason: AttachmentError): string => { const key = `thread.composer.imageRejected.${reason}`; - return t(key, { max: MAX_IMAGES_PER_MESSAGE }); + const fallback = reason === "too_many_attachments" + ? `Max ${maxAttachments} attachments per message` + : reason === "empty_file" + ? "Empty files cannot be attached" + : reason === "total_too_large" + ? "Attachments are too large together — remove some or use smaller files" + : reason === "transport_too_large" + ? "This attachment would exceed the gateway transport limit" + : reason === "too_large" + ? "File is too large" + : "Unsupported file type"; + return t(key, { max: maxAttachments, defaultValue: fallback }); }, - [t], + [maxAttachments, t], + ); + + const textTooLargeMessage = useCallback( + () => t("thread.composer.textTooLarge", { + max: formatBytes(maxTextBytes), + defaultValue: `Message text is too large (max ${formatBytes(maxTextBytes)})`, + }), + [maxTextBytes, t], ); const addFiles = useCallback( @@ -1411,6 +1449,10 @@ export function ThreadComposer({ const queueGuidancePrompt = useCallback(() => { const text = value.trim(); if (!canQueueGuidance || (!text && readyImages.length === 0)) return; + if (utf8Bytes(text) > maxTextBytes) { + setInlineError(textTooLargeMessage()); + return; + } const queuedImages = readyImagesToQueuedImages(readyImages); queuedPromptCounterRef.current += 1; const id = `queued-prompt-${Date.now()}-${queuedPromptCounterRef.current}`; @@ -1425,7 +1467,7 @@ export function ThreadComposer({ ]); clear(); clearComposerText(); - }, [canQueueGuidance, clear, clearComposerText, readyImages, value]); + }, [canQueueGuidance, clear, clearComposerText, maxTextBytes, readyImages, textTooLargeMessage, value]); const removeQueuedPrompt = useCallback((id: string) => { secondEnterPromptIdRef.current = null; @@ -1526,18 +1568,22 @@ export function ThreadComposer({ if (!canSend) return; const trimmed = value.trim(); const content = trimmed; - // Share the same normalized ``data:`` URL with both the wire payload and - // the optimistic bubble preview: data URLs are self-contained (no blob - // lifetime, safe under React StrictMode double-mount) and keep the - // bubble in sync with whatever the backend actually sees. - const payload: SendImage[] | undefined = + if (utf8Bytes(content) > maxTextBytes) { + setInlineError(textTooLargeMessage()); + return; + } + // Share the same ``data:`` URL with both the wire payload and the + // optimistic bubble preview: data URLs are self-contained (no blob + // lifetime, safe under React StrictMode double-mount) and keep the bubble + // in sync with whatever the backend actually sees. + const payload: SendAttachment[] | undefined = readyImages.length > 0 ? readyImages.map((img) => ({ media: { data_url: img.dataUrl, name: img.file.name, }, - preview: { url: img.dataUrl, name: img.file.name }, + preview: { kind: img.kind, url: img.dataUrl, name: img.file.name }, })) : undefined; const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload); @@ -1594,12 +1640,14 @@ export function ThreadComposer({ clearComposerText, handleStop, isStreaming, + maxTextBytes, modelNeedsSetup, onModelBadgeClick, onSend, onStop, readyImages, slashCommands, + textTooLargeMessage, value, ]); @@ -2673,7 +2721,7 @@ function AttachmentChip({ data-testid="composer-chip" >
- {image.previewUrl ? ( + {image.kind === "image" && image.previewUrl ? ( ) : (
- + {image.kind === "image" ? ( + + ) : ( + + )}
)} {image.status === "encoding" ? ( diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 40f1ddde..0d38d9ff 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -9,7 +9,7 @@ import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport"; -import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream"; +import { useNanobotStream, type SendAttachment, type SendOptions } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; import { fetchInstalledCliApps, @@ -212,7 +212,7 @@ function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] { interface PendingFirstMessage { content: string; - images?: SendImage[]; + images?: SendAttachment[]; options?: SendOptions; } @@ -311,7 +311,7 @@ export function ThreadShell({ version: historyVersion, forkBoundaryMessageCount, } = useSessionHistory(historyKey); - const { client, modelName, token } = useClient(); + const { client, ingressLimits, modelName, token } = useClient(); const [booting, setBooting] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const cliApps = useInstalledSettingItems({ @@ -589,7 +589,7 @@ export function ThreadShell({ }, [token]); const handleWelcomeSend = useCallback( - async (content: string, images?: SendImage[], options?: SendOptions) => { + async (content: string, images?: SendAttachment[], options?: SendOptions) => { if (booting) return; setBooting(true); pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) }; @@ -607,7 +607,7 @@ export function ThreadShell({ ); const handleThreadSend = useCallback( - (content: string, images?: SendImage[], options?: SendOptions) => { + (content: string, images?: SendAttachment[], options?: SendOptions) => { setScrollToLatestUserPromptSignal((value) => value + 1); send(content, images, withWorkspaceScope(options)); }, @@ -754,6 +754,7 @@ export function ThreadShell({ onWorkspaceScopeChange={onWorkspaceScopeChange} pendingQueueKey={chatId} transcriptionProvider={settingsSnapshot?.transcription?.provider} + ingressLimits={ingressLimits} /> ) : ( )} diff --git a/webui/src/hooks/useAttachedImages.ts b/webui/src/hooks/useAttachedImages.ts index 51fc84b1..48d497d8 100644 --- a/webui/src/hooks/useAttachedImages.ts +++ b/webui/src/hooks/useAttachedImages.ts @@ -1,21 +1,24 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { encodeImage, type EncodeFailure } from "@/lib/imageEncode"; +import type { WebUIIngressLimits } from "@/lib/types"; /** Lifecycle stages of one attachment: * - * - ``encoding`` — posted to the Worker; chip shows a spinner + * - ``encoding`` — posted to the Worker / read from disk; chip shows a spinner * - ``ready`` — ``dataUrl`` available; safe to submit * - ``error`` — validation / decode failure; chip shows inline error */ export type AttachmentStatus = "encoding" | "ready" | "error"; +export type AttachmentKind = "image" | "file"; -export interface AttachedImage { +export interface AttachedAttachment { id: string; + kind: AttachmentKind; file: File; /** Optimistic ``blob:`` preview URL; revoked on ``remove`` / ``clear`` / * unmount. */ - previewUrl: string; + previewUrl?: string; status: AttachmentStatus; /** Populated when ``status === "ready"``. */ dataUrl?: string; @@ -27,37 +30,131 @@ export interface AttachedImage { error?: AttachmentError; } -export interface RestoredReadyImage { +export type AttachedImage = AttachedAttachment; + +export interface RestoredReadyAttachment { dataUrl: string; name?: string; + kind?: AttachmentKind; } +export type RestoredReadyImage = RestoredReadyAttachment; + /** Machine-readable rejection reasons surfaced as inline chip errors. * * Callers localize these via the ``composer.imageRejected.*`` i18n table. */ export type AttachmentError = | "unsupported_type" // server whitelist excludes this MIME - | "too_many_images" // per-message cap (4) reached before enqueue + | "empty_file" // backend data-URL decoder rejects empty payloads + | "too_many_attachments" // per-message cap (4) reached before enqueue + | "total_too_large" // decoded attachments exceed the business-policy total + | "transport_too_large" // projected JSON frame exceeds the transport guard | "magic_mismatch" // extension lies about the real content | "decode_failed" // Worker couldn't decode / re-encode | "too_large" // even after normalization we exceed the budget | "io"; // file read failed at the browser layer -export const MAX_IMAGES_PER_MESSAGE = 4; +export const MAX_ATTACHMENTS_PER_MESSAGE = 4; +export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE; +export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024; +export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024; /** MIME whitelist — mirrors the server's and the ```` attr. */ -const ACCEPTED_MIMES: ReadonlySet = new Set([ +const ACCEPTED_IMAGE_MIMES: ReadonlySet = new Set([ "image/png", "image/jpeg", "image/webp", "image/gif", ]); +const DOCUMENT_MIME_BY_EXTENSION: ReadonlyMap = new Map([ + [".pdf", "application/pdf"], + [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + [".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + [".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"], + [".txt", "text/plain"], + [".md", "text/markdown"], + [".csv", "text/csv"], + [".json", "application/json"], + [".xml", "application/xml"], + [".html", "text/html"], + [".htm", "text/html"], + [".log", "text/plain"], + [".yaml", "application/yaml"], + [".yml", "application/yaml"], + [".toml", "application/toml"], + [".ini", "text/plain"], + [".cfg", "text/plain"], +]); + +const ACCEPTED_DOCUMENT_MIMES: ReadonlySet = new Set(DOCUMENT_MIME_BY_EXTENSION.values()); + +export const ACCEPT_ATTR = [ + ...ACCEPTED_IMAGE_MIMES, + ...ACCEPTED_DOCUMENT_MIMES, + ...DOCUMENT_MIME_BY_EXTENSION.keys(), +].join(","); + +function extensionOf(name: string): string { + const dot = name.lastIndexOf("."); + return dot < 0 ? "" : name.slice(dot).toLowerCase(); +} + +function mimeForFile(file: File): string { + const byName = DOCUMENT_MIME_BY_EXTENSION.get(extensionOf(file.name)); + if (byName) return byName; + if (!file.type || file.type === "application/octet-stream") { + return "application/octet-stream"; + } + return file.type; +} + +function projectedDataUrlBytes( + file: File, + kind: AttachmentKind, + maxFileBytes: number, +): number { + const prefixBytes = `data:${mimeForFile(file)};base64,`.length; + const decodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size; + return prefixBytes + 4 * Math.ceil(decodedBytes / 3); +} + +function positiveLimit(value: number | null | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : fallback; +} + +function attachmentPayloadBudget(limits: WebUIIngressLimits | null | undefined): number | null { + const maxFrameBytes = limits?.transport.max_frame_bytes; + if (typeof maxFrameBytes !== "number" || !Number.isFinite(maxFrameBytes)) { + return null; + } + return Math.max( + 0, + Math.floor(maxFrameBytes) + - positiveLimit(limits?.message.max_text_bytes, 0) + - positiveLimit(limits?.transport.envelope_reserve_bytes, 0), + ); +} + +export function acceptedAttachmentKind(file: File): AttachmentKind | null { + if (DOCUMENT_MIME_BY_EXTENSION.has(extensionOf(file.name))) return "file"; + if (ACCEPTED_IMAGE_MIMES.has(file.type)) return "image"; + const mime = mimeForFile(file); + if (ACCEPTED_DOCUMENT_MIMES.has(mime)) return "file"; + return null; +} + function dataUrlMime(dataUrl: string): string { const match = /^data:([^;,]+)[;,]/.exec(dataUrl); return match?.[1] || "image/png"; } +function kindFromDataUrl(dataUrl: string): AttachmentKind { + return dataUrlMime(dataUrl).startsWith("image/") ? "image" : "file"; +} + function dataUrlToFile(dataUrl: string, name?: string): File { const mime = dataUrlMime(dataUrl); const fallbackName = `image.${mime.split("/")[1] || "png"}`; @@ -81,6 +178,40 @@ function uuid(): string { return `img-${Date.now()}-${Math.random().toString(36).slice(2)}`; } +function bufferToBase64(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let binary = ""; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode.apply( + null, + bytes.subarray(i, i + chunk) as unknown as number[], + ); + } + return btoa(binary); +} + +async function encodeFile(file: File, maxFileBytes: number): Promise<{ + ok: true; + dataUrl: string; + bytes: number; +} | { + ok: false; + reason: AttachmentError; +}> { + if (file.size > maxFileBytes) return { ok: false, reason: "too_large" }; + try { + const buffer = await file.arrayBuffer(); + return { + ok: true, + dataUrl: `data:${mimeForFile(file)};base64,${bufferToBase64(buffer)}`, + bytes: file.size, + }; + } catch { + return { ok: false, reason: "io" }; + } +} + function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError { switch (reason) { case "invalid_mime": @@ -97,11 +228,11 @@ function mapEncodeFailure(reason: EncodeFailure["reason"]): AttachmentError { } export interface UseAttachedImagesApi { - images: AttachedImage[]; + images: AttachedAttachment[]; /** Enqueue new files. Returns the list of rejected files so the caller can * surface inline errors. Files rejected client-side (wrong MIME, limit) are - * *not* added to ``images`` — only recoverable encoding failures show up as - * error chips. */ + * *not* added to ``images`` — only recoverable read/encoding failures show + * up as error chips. */ enqueue: (files: Iterable) => { rejected: Array<{ file: File; reason: AttachmentError }>; }; @@ -110,17 +241,21 @@ export interface UseAttachedImagesApi { * successful submit — the optimistic bubble holds onto an independent * ``data:`` URL so tearing down blob previews here is safe. */ clear: () => void; - /** Restore already-encoded images, e.g. a queued composer draft moving back - * into the input. These entries are immediately sendable and use their - * ``data:`` URL as a stable preview. */ - restoreReadyImages: (images: RestoredReadyImage[]) => void; - /** ``true`` when at least one image is still encoding — Send should wait. */ + /** Restore already-encoded attachments, e.g. a queued composer draft moving + * back into the input. These entries are immediately sendable and use image + * ``data:`` URLs as stable previews. */ + restoreReadyImages: (images: RestoredReadyAttachment[]) => void; + /** ``true`` when at least one attachment is still encoding — Send should wait. */ encoding: boolean; - /** ``true`` when we've hit ``MAX_IMAGES_PER_MESSAGE``. */ + /** ``true`` when we've hit ``MAX_ATTACHMENTS_PER_MESSAGE``. */ full: boolean; } -/** Manage the lifecycle of images attached to the Composer. +interface UseAttachedImagesOptions { + ingressLimits?: WebUIIngressLimits | null; +} + +/** Manage the lifecycle of attachments in the Composer. * * Responsibilities in one place: * - validation (MIME whitelist, count cap) @@ -128,15 +263,29 @@ export interface UseAttachedImagesApi { * - Worker orchestration * - focus bookkeeping so keyboard delete doesn't strand the user */ -export function useAttachedImages(): UseAttachedImagesApi { - const [images, setImages] = useState([]); +export function useAttachedImages({ + ingressLimits = null, +}: UseAttachedImagesOptions = {}): UseAttachedImagesApi { + const [images, setImages] = useState([]); + const maxAttachments = positiveLimit( + ingressLimits?.attachments.max_count, + MAX_ATTACHMENTS_PER_MESSAGE, + ); + const maxFileBytes = positiveLimit( + ingressLimits?.attachments.max_file_bytes, + MAX_ATTACHMENT_BYTES, + ); + const maxTotalBytes = positiveLimit( + ingressLimits?.attachments.max_total_bytes, + MAX_TOTAL_ATTACHMENT_BYTES, + ); // Ref mirror so ``enqueue`` can see the authoritative length when invoked // multiple times in a single tick (rapid file selection, drag of many // files, paste storms). ``state`` is stale for that second + call. - const imagesRef = useRef([]); + const imagesRef = useRef([]); imagesRef.current = images; - const setEntry = useCallback((id: string, patch: Partial) => { + const setEntry = useCallback((id: string, patch: Partial) => { setImages((prev) => { const next = prev.map((img) => (img.id === id ? { ...img, ...patch } : img)); imagesRef.current = next; @@ -147,23 +296,60 @@ export function useAttachedImages(): UseAttachedImagesApi { const enqueue = useCallback( (files: Iterable) => { const rejected: Array<{ file: File; reason: AttachmentError }> = []; - const toAdd: AttachedImage[] = []; - let slot = MAX_IMAGES_PER_MESSAGE - imagesRef.current.length; + const toAdd: AttachedAttachment[] = []; + let slot = maxAttachments - imagesRef.current.length; + const payloadBudget = attachmentPayloadBudget(ingressLimits); + let projectedWireBytes = imagesRef.current.reduce( + (total, image) => total + ( + image.dataUrl?.length + ?? projectedDataUrlBytes(image.file, image.kind, maxFileBytes) + ), + 0, + ); + let projectedDecodedBytes = imagesRef.current.reduce( + (total, image) => total + ( + image.encodedBytes + ?? (image.kind === "image" ? Math.min(image.file.size, maxFileBytes) : image.file.size) + ), + 0, + ); for (const file of files) { - if (!ACCEPTED_MIMES.has(file.type)) { + const kind = acceptedAttachmentKind(file); + if (!kind) { rejected.push({ file, reason: "unsupported_type" }); continue; } + if (file.size === 0) { + rejected.push({ file, reason: "empty_file" }); + continue; + } + if (kind === "file" && file.size > maxFileBytes) { + rejected.push({ file, reason: "too_large" }); + continue; + } if (slot <= 0) { - rejected.push({ file, reason: "too_many_images" }); + rejected.push({ file, reason: "too_many_attachments" }); + continue; + } + const nextDecodedBytes = kind === "image" ? Math.min(file.size, maxFileBytes) : file.size; + if (projectedDecodedBytes + nextDecodedBytes > maxTotalBytes) { + rejected.push({ file, reason: "total_too_large" }); + continue; + } + const nextWireBytes = projectedDataUrlBytes(file, kind, maxFileBytes); + if (payloadBudget !== null && projectedWireBytes + nextWireBytes > payloadBudget) { + rejected.push({ file, reason: "transport_too_large" }); continue; } slot -= 1; + projectedDecodedBytes += nextDecodedBytes; + projectedWireBytes += nextWireBytes; toAdd.push({ id: uuid(), + kind, file, - previewUrl: URL.createObjectURL(file), + ...(kind === "image" ? { previewUrl: URL.createObjectURL(file) } : {}), status: "encoding", }); } @@ -175,19 +361,24 @@ export function useAttachedImages(): UseAttachedImagesApi { // Fire the Worker after the commit so chips render first (good INP). for (const entry of toAdd) { queueMicrotask(() => { - encodeImage(entry.file).then( + const work = entry.kind === "image" + ? encodeImage(entry.file) + : encodeFile(entry.file, maxFileBytes); + work.then( (result) => { if (result.ok) { setEntry(entry.id, { status: "ready", dataUrl: result.dataUrl, encodedBytes: result.bytes, - normalized: result.normalized, + normalized: "normalized" in result ? result.normalized : false, }); } else { setEntry(entry.id, { status: "error", - error: mapEncodeFailure(result.reason), + error: entry.kind === "image" + ? mapEncodeFailure(result.reason as EncodeFailure["reason"]) + : result.reason as AttachmentError, }); } }, @@ -203,7 +394,7 @@ export function useAttachedImages(): UseAttachedImagesApi { } return { rejected }; }, - [setEntry], + [ingressLimits, maxAttachments, maxFileBytes, maxTotalBytes, setEntry], ); const remove = useCallback((id: string) => { @@ -212,10 +403,12 @@ export function useAttachedImages(): UseAttachedImagesApi { const idx = prev.findIndex((img) => img.id === id); if (idx === -1) return prev; const target = prev[idx]; - try { - URL.revokeObjectURL(target.previewUrl); - } catch { - // No-op: previewUrl revocation is best-effort. + if (target.previewUrl) { + try { + URL.revokeObjectURL(target.previewUrl); + } catch { + // No-op: previewUrl revocation is best-effort. + } } const next = [...prev.slice(0, idx), ...prev.slice(idx + 1)]; imagesRef.current = next; @@ -230,10 +423,12 @@ export function useAttachedImages(): UseAttachedImagesApi { const clear = useCallback(() => { setImages((prev) => { for (const img of prev) { - try { - URL.revokeObjectURL(img.previewUrl); - } catch { - // revoke is best-effort + if (img.previewUrl) { + try { + URL.revokeObjectURL(img.previewUrl); + } catch { + // revoke is best-effort + } } } imagesRef.current = []; @@ -241,16 +436,18 @@ export function useAttachedImages(): UseAttachedImagesApi { }); }, []); - const restoreReadyImages = useCallback((restored: RestoredReadyImage[]) => { + const restoreReadyImages = useCallback((restored: RestoredReadyAttachment[]) => { const toRestore = restored - .filter((img) => ACCEPTED_MIMES.has(dataUrlMime(img.dataUrl))) - .slice(0, MAX_IMAGES_PER_MESSAGE) - .map((img): AttachedImage => { + .filter((img) => acceptedAttachmentKind(dataUrlToFile(img.dataUrl, img.name))) + .slice(0, maxAttachments) + .map((img): AttachedAttachment => { const file = dataUrlToFile(img.dataUrl, img.name); + const kind = img.kind ?? kindFromDataUrl(img.dataUrl); return { id: uuid(), + kind, file, - previewUrl: img.dataUrl, + ...(kind === "image" ? { previewUrl: img.dataUrl } : {}), status: "ready", dataUrl: img.dataUrl, encodedBytes: file.size, @@ -258,16 +455,18 @@ export function useAttachedImages(): UseAttachedImagesApi { }); setImages((prev) => { for (const img of prev) { - try { - URL.revokeObjectURL(img.previewUrl); - } catch { - // revoke is best-effort + if (img.previewUrl) { + try { + URL.revokeObjectURL(img.previewUrl); + } catch { + // revoke is best-effort + } } } imagesRef.current = toRestore; return toRestore; }); - }, []); + }, [maxAttachments]); // Final safety net: revoke any outstanding blob URLs on unmount. Safe // under StrictMode double-invoke because revoked blob URLs are only @@ -275,17 +474,19 @@ export function useAttachedImages(): UseAttachedImagesApi { useEffect(() => { return () => { for (const img of imagesRef.current) { - try { - URL.revokeObjectURL(img.previewUrl); - } catch { - // best-effort cleanup on unmount + if (img.previewUrl) { + try { + URL.revokeObjectURL(img.previewUrl); + } catch { + // best-effort cleanup on unmount + } } } }; }, []); const encoding = images.some((img) => img.status === "encoding"); - const full = images.length >= MAX_IMAGES_PER_MESSAGE; + const full = images.length >= maxAttachments; return { images, enqueue, remove, clear, restoreReadyImages, encoding, full }; } diff --git a/webui/src/hooks/useClipboardAndDrop.ts b/webui/src/hooks/useClipboardAndDrop.ts index 39e3a68c..7d72f0c2 100644 --- a/webui/src/hooks/useClipboardAndDrop.ts +++ b/webui/src/hooks/useClipboardAndDrop.ts @@ -1,12 +1,14 @@ import { useCallback, useRef, useState } from "react"; -/** Extract image ``File``s from a paste / drop event. +import { acceptedAttachmentKind } from "@/hooks/useAttachedImages"; + +/** Extract supported attachment ``File``s from a paste / drop event. * * Deliberate behaviour: - * - Only items whose ``kind === "file"`` and ``type`` starts with - * ``image/`` are returned; ```` tags inside HTML fragments are - * ignored (defending against remote URL fetch + XSS surfaces). - * - Plain text pasted alongside images is *not* consumed by this helper, + * - Only items whose ``kind === "file"`` and match the Composer whitelist + * are returned; HTML fragments are ignored (defending against remote URL + * fetch + XSS surfaces). + * - Plain text pasted alongside attachments is *not* consumed by this helper, * so the caller can still let the textarea receive it naturally. */ export function extractImageFilesFromPaste( @@ -18,14 +20,13 @@ export function extractImageFilesFromPaste( const files: File[] = []; for (const item of Array.from(clipboard.items)) { if (item.kind !== "file") continue; - if (!item.type.startsWith("image/")) continue; const file = item.getAsFile(); - if (file) files.push(file); + if (file && acceptedAttachmentKind(file)) files.push(file); } return files; } -/** Extract dropped image files, mirroring ``extractImageFilesFromPaste``. */ +/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */ export function extractImageFilesFromDrop( event: DragEvent | React.DragEvent, ): File[] { @@ -34,7 +35,7 @@ export function extractImageFilesFromDrop( if (!dt) return []; const files: File[] = []; for (const item of Array.from(dt.files)) { - if (item.type.startsWith("image/")) files.push(item); + if (acceptedAttachmentKind(item)) files.push(item); } return files; } @@ -67,8 +68,8 @@ export function useClipboardAndDrop( (event: React.ClipboardEvent) => { const files = extractImageFilesFromPaste(event); if (files.length === 0) return; - // Consume only when an image is actually present; plain-text paste still - // reaches the textarea unmolested. + // Consume only when an attachment is actually present; plain-text paste + // still reaches the textarea unmolested. event.preventDefault(); onImageFiles(files); }, diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index e6268b8b..073af340 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -17,7 +17,7 @@ import type { OutboundMedia, GoalStateWsPayload, ToolProgressEvent, - UIImage, + UIMediaAttachment, UIFileEdit, UIMessage, UITurnPhase, @@ -464,15 +464,15 @@ function findFileEditTraceIndex( * separately (e.g. via ``fetchWebuiThread``) since the server only replays * live events. */ -/** Payload passed to ``send`` when the user attaches one or more images. +/** Payload passed to ``send`` when the user attaches one or more files. * * ``media`` is handed to the wire client verbatim; ``preview`` powers the - * optimistic user bubble (blob URLs so the preview appears before the server - * acks the frame). Keeping the two separate lets the bubble re-use the local - * blob URL even after the server persists the file under a different name. */ -export interface SendImage { + * optimistic user bubble. Keeping the two separate lets the bubble re-use the + * local data URL even after the server persists the file under a different + * name. */ +export interface SendAttachment { media: OutboundMedia; - preview: UIImage; + preview: UIMediaAttachment; } export interface SendOptions { @@ -520,7 +520,7 @@ export function useNanobotStream( runStartedAt: number | null; /** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */ goalState: GoalStateWsPayload | undefined; - send: (content: string, images?: SendImage[], options?: SendOptions) => void; + send: (content: string, images?: SendAttachment[], options?: SendOptions) => void; transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise; stop: () => void; setMessages: React.Dispatch>; @@ -1135,12 +1135,12 @@ export function useNanobotStream( ]); const send = useCallback( - (content: string, images?: SendImage[], options?: SendOptions) => { + (content: string, images?: SendAttachment[], options?: SendOptions) => { if (!chatId) return; - const hasImages = !!images && images.length > 0; - // Text is optional when images are attached — the agent will still see - // the image blocks via ``media`` paths. - if (!hasImages && !content.trim()) return; + const hasAttachments = !!images && images.length > 0; + // Text is optional when files are attached — the agent will still see + // them via ``media`` paths. + if (!hasAttachments && !content.trim()) return; const sideChannel = options?.sideChannel === true; const finalizeActiveTurn = options?.finalizeActiveTurn === true; @@ -1151,7 +1151,7 @@ export function useNanobotStream( } const turnId = crypto.randomUUID(); if (sideChannel) sideChannelTurnIdsRef.current.add(turnId); - const previews = hasImages ? images!.map((i) => i.preview) : undefined; + const previews = hasAttachments ? images!.map((i) => i.preview) : undefined; setMessages((prev) => { if (!sideChannel || finalizeActiveTurn) { buffer.current = null; @@ -1171,14 +1171,14 @@ export function useNanobotStream( turnPhase: "user", turnSeq: 0, createdAt: Date.now(), - ...(previews ? { images: previews } : {}), + ...(previews ? { media: previews } : {}), ...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}), ...(options?.mcpPresets?.length ? { mcpPresets: options.mcpPresets } : {}), }, ]; }); if (!sideChannel) setIsStreaming(true); - const wireMedia = hasImages ? images!.map((i) => i.media) : undefined; + const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined; const wireOptions = { ...options, turnId }; delete wireOptions.sideChannel; delete wireOptions.finalizeActiveTurn; diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index a0065bd2..2257f548 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -909,7 +909,7 @@ "edit": "Edit guidance", "drag": "Drag to reorder" }, - "attachImage": "Attach image", + "attachImage": "Attach files", "imageMode": { "label": "Image Generation", "toggle": "Toggle image generation mode", @@ -1034,12 +1034,17 @@ "encoding": "Encoding…", "remove": "Remove attachment", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", + "textTooLarge": "Message text is too large (max {{max}})", "imageRejected": { "unsupported_type": "Unsupported file type", + "empty_file": "Empty files cannot be attached", + "too_many_attachments": "Max {{max}} attachments per message", "too_many_images": "Max {{max}} images per message", "magic_mismatch": "File doesn't look like a real image", "decode_failed": "Couldn't decode this image", - "too_large": "Image is too large — try a smaller one", + "too_large": "File is too large — try a smaller one", + "total_too_large": "Attachments are too large together — remove some or use smaller files", + "transport_too_large": "This attachment would exceed the gateway transport limit", "io": "Couldn't read this file" }, "workspace": { diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 40bee1ae..f739b47a 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -896,7 +896,7 @@ "edit": "Editar guía", "drag": "Arrastrar para reordenar" }, - "attachImage": "Adjuntar imagen", + "attachImage": "Adjuntar archivos", "imageMode": { "label": "Generar imagen", "toggle": "Activar o desactivar modo de generación de imágenes", @@ -1011,12 +1011,17 @@ "encoding": "Procesando…", "remove": "Quitar adjunto", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", + "textTooLarge": "El texto del mensaje es demasiado grande (máximo {{max}})", "imageRejected": { "unsupported_type": "Tipo de archivo no compatible", + "empty_file": "No se pueden adjuntar archivos vacíos", + "too_many_attachments": "Máximo {{max}} adjuntos por mensaje", "too_many_images": "Máximo {{max}} imágenes por mensaje", "magic_mismatch": "El archivo no parece una imagen real", "decode_failed": "No se pudo decodificar esta imagen", - "too_large": "Imagen demasiado grande — prueba una más pequeña", + "too_large": "Archivo demasiado grande — prueba uno más pequeño", + "total_too_large": "Los archivos adjuntos son demasiado grandes en conjunto; elimina algunos o usa archivos más pequeños", + "transport_too_large": "Este archivo adjunto superaría el límite de transporte de la puerta de enlace", "io": "No se pudo leer este archivo" }, "mentions": { diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 4074dd26..96fecd57 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -895,7 +895,7 @@ "edit": "Modifier le guidage", "drag": "Faire glisser pour réordonner" }, - "attachImage": "Joindre une image", + "attachImage": "Joindre des fichiers", "imageMode": { "label": "Génération d’image", "toggle": "Activer ou désactiver le mode génération d’image", @@ -1010,12 +1010,17 @@ "encoding": "Traitement…", "remove": "Retirer la pièce jointe", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", + "textTooLarge": "Le texte du message est trop volumineux (maximum {{max}})", "imageRejected": { "unsupported_type": "Type de fichier non pris en charge", + "empty_file": "Impossible de joindre des fichiers vides", + "too_many_attachments": "Maximum {{max}} pièces jointes par message", "too_many_images": "Maximum {{max}} images par message", "magic_mismatch": "Ce fichier n'est pas une image", "decode_failed": "Impossible de décoder cette image", - "too_large": "Image trop grande — essayez-en une plus petite", + "too_large": "Fichier trop volumineux — essayez-en un plus petit", + "total_too_large": "Les pièces jointes sont trop volumineuses ensemble — supprimez-en ou utilisez des fichiers plus petits", + "transport_too_large": "Cette pièce jointe dépasserait la limite de transport de la passerelle", "io": "Impossible de lire ce fichier" }, "mentions": { diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index b1aae3e5..1667c3e7 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -895,7 +895,7 @@ "edit": "Edit panduan", "drag": "Seret untuk mengurutkan" }, - "attachImage": "Lampirkan gambar", + "attachImage": "Lampirkan file", "imageMode": { "label": "Buat gambar", "toggle": "Alihkan mode pembuatan gambar", @@ -1010,12 +1010,17 @@ "encoding": "Memproses…", "remove": "Hapus lampiran", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", + "textTooLarge": "Teks pesan terlalu besar (maksimum {{max}})", "imageRejected": { "unsupported_type": "Tipe file tidak didukung", + "empty_file": "File kosong tidak dapat dilampirkan", + "too_many_attachments": "Maksimal {{max}} lampiran per pesan", "too_many_images": "Maksimal {{max}} gambar per pesan", "magic_mismatch": "File ini tampaknya bukan gambar asli", "decode_failed": "Tidak dapat mendekode gambar ini", - "too_large": "Gambar terlalu besar — coba yang lebih kecil", + "too_large": "File terlalu besar — coba yang lebih kecil", + "total_too_large": "Total lampiran terlalu besar — hapus beberapa atau gunakan file yang lebih kecil", + "transport_too_large": "Lampiran ini akan melebihi batas transport gateway", "io": "Tidak dapat membaca file ini" }, "mentions": { diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 63c20d6e..bc4239da 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -895,7 +895,7 @@ "edit": "ガイドを編集", "drag": "ドラッグして並べ替え" }, - "attachImage": "画像を添付", + "attachImage": "ファイルを添付", "imageMode": { "label": "画像生成", "toggle": "画像生成モードを切り替え", @@ -1010,12 +1010,17 @@ "encoding": "処理中…", "remove": "添付を削除", "normalizedSizeHint": "{{orig}} → {{current}}(自動圧縮)", + "textTooLarge": "メッセージ本文が大きすぎます(最大 {{max}})", "imageRejected": { "unsupported_type": "対応していないファイル形式です", + "empty_file": "空のファイルは添付できません", + "too_many_attachments": "1 メッセージにつき最大 {{max}} 件までです", "too_many_images": "1 メッセージにつき最大 {{max}} 枚です", "magic_mismatch": "画像ファイルではないようです", "decode_failed": "この画像をデコードできません", - "too_large": "画像が大きすぎます。小さいものを選んでください", + "too_large": "ファイルが大きすぎます。小さいものを選んでください", + "total_too_large": "添付ファイルの合計サイズが大きすぎます。いくつか削除するか、より小さいファイルを使用してください", + "transport_too_large": "この添付ファイルはゲートウェイの転送上限を超えます", "io": "このファイルを読み込めません" }, "mentions": { diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 9653f839..20914c12 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -895,7 +895,7 @@ "edit": "안내 수정", "drag": "드래그하여 순서 변경" }, - "attachImage": "이미지 첨부", + "attachImage": "파일 첨부", "imageMode": { "label": "이미지 생성", "toggle": "이미지 생성 모드 전환", @@ -1010,12 +1010,17 @@ "encoding": "처리 중…", "remove": "첨부 제거", "normalizedSizeHint": "{{orig}} → {{current}} (자동 압축)", + "textTooLarge": "메시지 텍스트가 너무 큽니다(최대 {{max}})", "imageRejected": { "unsupported_type": "지원하지 않는 파일 형식입니다", + "empty_file": "빈 파일은 첨부할 수 없습니다", + "too_many_attachments": "메시지당 최대 {{max}}개까지 가능합니다", "too_many_images": "메시지당 최대 {{max}}장까지 가능합니다", "magic_mismatch": "이미지 파일이 아닌 것 같습니다", "decode_failed": "이 이미지를 디코딩할 수 없습니다", - "too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요", + "too_large": "파일이 너무 큽니다. 더 작은 파일을 선택해 주세요", + "total_too_large": "첨부 파일의 전체 크기가 너무 큽니다. 일부를 제거하거나 더 작은 파일을 사용해 주세요", + "transport_too_large": "이 첨부 파일은 게이트웨이 전송 한도를 초과합니다", "io": "이 파일을 읽을 수 없습니다" }, "mentions": { diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index e5740d49..a07bcf70 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1034,12 +1034,17 @@ "encoding": "Codificando…", "remove": "Remover anexo", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", + "textTooLarge": "O texto da mensagem é grande demais (máximo de {{max}})", "imageRejected": { "unsupported_type": "Tipo de arquivo não compatível", + "empty_file": "Arquivos vazios não podem ser anexados", + "too_many_attachments": "Máx. de {{max}} anexos por mensagem", "too_many_images": "Máx. de {{max}} imagens por mensagem", "magic_mismatch": "O arquivo não parece uma imagem real", "decode_failed": "Não foi possível decodificar esta imagem", "too_large": "A imagem é grande demais — tente uma menor", + "total_too_large": "Os anexos são grandes demais em conjunto — remova alguns ou use arquivos menores", + "transport_too_large": "Este anexo excederia o limite de transporte do gateway", "io": "Não foi possível ler este arquivo" }, "workspace": { diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 54baa6c8..614e827c 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -895,7 +895,7 @@ "edit": "Sửa hướng dẫn", "drag": "Kéo để sắp xếp" }, - "attachImage": "Đính kèm ảnh", + "attachImage": "Đính kèm tệp", "imageMode": { "label": "Tạo ảnh", "toggle": "Bật/tắt chế độ tạo ảnh", @@ -1010,12 +1010,17 @@ "encoding": "Đang xử lý…", "remove": "Xóa tệp đính kèm", "normalizedSizeHint": "{{orig}} → {{current}} (tự động)", + "textTooLarge": "Nội dung tin nhắn quá lớn (tối đa {{max}})", "imageRejected": { "unsupported_type": "Loại tệp không được hỗ trợ", + "empty_file": "Không thể đính kèm tệp trống", + "too_many_attachments": "Tối đa {{max}} tệp đính kèm mỗi tin nhắn", "too_many_images": "Tối đa {{max}} ảnh mỗi tin nhắn", "magic_mismatch": "Tệp này không phải là một ảnh thực", "decode_failed": "Không thể giải mã ảnh này", - "too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn", + "too_large": "Tệp quá lớn — hãy thử tệp nhỏ hơn", + "total_too_large": "Tổng dung lượng tệp đính kèm quá lớn — hãy xóa bớt hoặc dùng tệp nhỏ hơn", + "transport_too_large": "Tệp đính kèm này sẽ vượt quá giới hạn truyền tải của gateway", "io": "Không thể đọc tệp này" }, "mentions": { diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index d836b451..122eb366 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -908,7 +908,7 @@ "edit": "编辑引导", "drag": "拖动排序" }, - "attachImage": "添加图片", + "attachImage": "添加文件", "imageMode": { "label": "图片生成", "toggle": "切换图片生成模式", @@ -1033,12 +1033,17 @@ "encoding": "处理中…", "remove": "移除附件", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", + "textTooLarge": "消息文本过大(最大 {{max}})", "imageRejected": { "unsupported_type": "不支持的文件类型", + "empty_file": "不能附加空文件", + "too_many_attachments": "每条消息最多 {{max}} 个附件", "too_many_images": "每条消息最多 {{max}} 张图片", "magic_mismatch": "文件看起来不像真实的图片", "decode_failed": "无法解码这张图片", - "too_large": "图片太大,请换一张小一点的", + "too_large": "文件太大,请换一个小一点的", + "total_too_large": "附件总大小过大,请移除部分文件或使用更小的文件", + "transport_too_large": "该附件会超过网关的传输上限", "io": "无法读取该文件" }, "goalStateCloseAria": "关闭目标", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index c64469df..a485bad2 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -895,7 +895,7 @@ "edit": "編輯引導", "drag": "拖曳排序" }, - "attachImage": "附加圖片", + "attachImage": "附加檔案", "imageMode": { "label": "圖片生成", "toggle": "切換圖片生成模式", @@ -1010,12 +1010,17 @@ "encoding": "處理中…", "remove": "移除附件", "normalizedSizeHint": "{{orig}} → {{current}}(已自動壓縮)", + "textTooLarge": "訊息文字過大(最大 {{max}})", "imageRejected": { "unsupported_type": "不支援的檔案類型", + "empty_file": "無法附加空白檔案", + "too_many_attachments": "每則訊息最多 {{max}} 個附件", "too_many_images": "每則訊息最多 {{max}} 張圖片", "magic_mismatch": "檔案看起來不像真正的圖片", "decode_failed": "無法解碼這張圖片", - "too_large": "圖片太大,請換一張小一點的", + "too_large": "檔案太大,請換一個小一點的", + "total_too_large": "附件總大小過大,請移除部分檔案或使用更小的檔案", + "transport_too_large": "此附件會超過閘道的傳輸上限", "io": "無法讀取這個檔案" }, "mentions": { diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index 88cb3ae7..52d792ea 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -81,8 +81,8 @@ type RunStatusHandler = (chatId: string, startedAt: number | null) => void; */ export type StreamError = /** Server rejected the inbound frame as too large (WS close code 1009). - * Typically means the user attached images whose base64 size exceeded - * ``maxMessageBytes`` on the server. */ + * This is the transport fallback after text and attachment policies have + * already been checked independently. */ | { kind: "message_too_big" } | { kind: "workspace_scope_rejected"; reason?: string; chatId?: string }; diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 4312f66b..6dd5d392 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -308,11 +308,33 @@ export interface BootstrapResponse { ws_path: string; ws_url?: string | null; expires_in: number; + limits?: WebUIIngressLimits; model_name?: string | null; runtime_surface?: RuntimeSurface; runtime_capabilities?: RuntimeCapabilities; } +export interface WebUITransportLimits { + max_frame_bytes: number; + envelope_reserve_bytes: number; +} + +export interface WebUIMessageLimits { + max_text_bytes: number; +} + +export interface WebUIAttachmentLimits { + max_count: number; + max_file_bytes: number; + max_total_bytes: number; +} + +export interface WebUIIngressLimits { + transport: WebUITransportLimits; + message: WebUIMessageLimits; + attachments: WebUIAttachmentLimits; +} + export type RuntimeSurface = "browser" | "native"; export type RestartBehavior = "none" | "nextTurn" | "engineRestart" | "appRestart"; export type SettingsApplyStatus = @@ -1050,12 +1072,11 @@ export type InboundEvent = } | { event: "error"; chat_id?: string; detail?: string; reason?: string }; -/** Base64-encoded image attached to an outbound ``message`` envelope. +/** Base64-encoded file attached to an outbound ``message`` envelope. * - * ``data_url`` must be a ``data:image/;base64,...`` string - * — the server whitelists those MIME types and rejects everything else - * (including SVG, to avoid an XSS surface). ``name`` is advisory: it's - * preserved for the file on disk and surfaced as the placeholder label when + * ``data_url`` must use a server-whitelisted image, video, or document MIME + * type. SVG remains rejected on ingress to avoid an embedded-script XSS + * surface. ``name`` is advisory and is surfaced as the placeholder label when * the session is replayed. */ export interface OutboundMedia { diff --git a/webui/src/providers/ClientProvider.tsx b/webui/src/providers/ClientProvider.tsx index e97ab4cf..0a298639 100644 --- a/webui/src/providers/ClientProvider.tsx +++ b/webui/src/providers/ClientProvider.tsx @@ -1,11 +1,13 @@ import { createContext, useContext, type ReactNode } from "react"; import type { NanobotClient } from "@/lib/nanobot-client"; +import type { WebUIIngressLimits } from "@/lib/types"; interface ClientContextValue { client: NanobotClient; token: string; modelName: string | null; + ingressLimits: WebUIIngressLimits | null; } const ClientContext = createContext(null); @@ -14,15 +16,17 @@ export function ClientProvider({ client, token, modelName = null, + ingressLimits = null, children, }: { client: NanobotClient; token: string; modelName?: string | null; + ingressLimits?: WebUIIngressLimits | null; children: ReactNode; }) { return ( - + {children} ); diff --git a/webui/src/tests/thread-composer-attach.test.tsx b/webui/src/tests/thread-composer-attach.test.tsx index ed2fcd00..a20f41bb 100644 --- a/webui/src/tests/thread-composer-attach.test.tsx +++ b/webui/src/tests/thread-composer-attach.test.tsx @@ -9,6 +9,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; import type { EncodeResponse } from "@/lib/imageEncode"; +import type { WebUIIngressLimits } from "@/lib/types"; const encodeImage = vi.fn<(file: File) => Promise>(); @@ -24,6 +25,14 @@ function pngFile(name = "a.png", size = 10) { return new File([new Uint8Array(size)], name, { type: "image/png" }); } +function pdfFile(name = "report.pdf", size = 8) { + return new File([new Uint8Array(size)], name, { type: "application/pdf" }); +} + +function csvFile(name = "report.csv", type = "application/vnd.ms-excel") { + return new File(["name,value\nnanobot,1"], name, { type }); +} + function resolveReady(file: File): EncodeResponse { return { id: "stub", @@ -36,6 +45,31 @@ function resolveReady(file: File): EncodeResponse { }; } +function ingressLimits({ + maxFrameBytes = 36 * 1024 * 1024, + maxTextBytes = 64 * 1024, + maxFileBytes = 6 * 1024 * 1024, + maxTotalBytes = 24 * 1024 * 1024, +}: { + maxFrameBytes?: number; + maxTextBytes?: number; + maxFileBytes?: number; + maxTotalBytes?: number; +} = {}): WebUIIngressLimits { + return { + transport: { + max_frame_bytes: maxFrameBytes, + envelope_reserve_bytes: 64 * 1024, + }, + message: { max_text_bytes: maxTextBytes }, + attachments: { + max_count: 4, + max_file_bytes: maxFileBytes, + max_total_bytes: maxTotalBytes, + }, + }; +} + beforeEach(() => { encodeImage.mockReset(); let id = 0; @@ -50,7 +84,7 @@ beforeEach(() => { } }); -describe("ThreadComposer — image attachments", () => { +describe("ThreadComposer — attachments", () => { it("attaches a picked image and includes its data url on send", async () => { const file = pngFile("a.png"); encodeImage.mockResolvedValueOnce(resolveReady(file)); @@ -83,6 +117,228 @@ describe("ThreadComposer — image attachments", () => { expect(images[0].media.name).toBe("a.png"); }); + it("attaches a picked PDF and includes its data url on send", async () => { + const file = pdfFile(); + const onSend = vi.fn(); + + render(); + + const input = screen + .getByLabelText(/message input/i) + .closest("form")! + .querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + await waitFor(() => + expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.pdf"), + ); + + const textarea = screen.getByLabelText(/message input/i); + fireEvent.change(textarea, { target: { value: "summarize" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + expect(encodeImage).not.toHaveBeenCalled(); + const [content, attachments] = onSend.mock.calls[0]; + expect(content).toBe("summarize"); + expect(attachments).toHaveLength(1); + expect(attachments[0].media.data_url).toContain("data:application/pdf;base64,"); + expect(attachments[0].media.name).toBe("report.pdf"); + expect(attachments[0].preview.kind).toBe("file"); + }); + + it.each(["application/vnd.ms-excel", "image/png"])( + "normalizes document MIME from the file extension when the browser reports %s", + async (browserMime) => { + const file = csvFile("report.csv", browserMime); + const onSend = vi.fn(); + + render(); + + const input = screen + .getByLabelText(/message input/i) + .closest("form")! + .querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + await waitFor(() => + expect(screen.getByTestId("composer-chip")).toHaveTextContent("report.csv"), + ); + + const textarea = screen.getByLabelText(/message input/i); + fireEvent.change(textarea, { target: { value: "summarize" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + const [, attachments] = onSend.mock.calls[0]; + expect(attachments[0].media.data_url).toMatch(/^data:text\/csv;base64,/); + expect(encodeImage).not.toHaveBeenCalled(); + }, + ); + + it("rejects empty attachments before sending them to the gateway", async () => { + const file = new File([], "empty.csv", { type: "application/vnd.ms-excel" }); + const onSend = vi.fn(); + + render(); + + const input = screen + .getByLabelText(/message input/i) + .closest("form")! + .querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByText("Empty files cannot be attached")).toBeInTheDocument(); + expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument(); + expect(encodeImage).not.toHaveBeenCalled(); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("rejects an oversized document before adding a chip", async () => { + const file = pdfFile("oversized.pdf", 6 * 1024 * 1024 + 1); + + render(); + + const input = screen + .getByLabelText(/message input/i) + .closest("form")! + .querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(input, { target: { files: [file] } }); + }); + + expect(screen.getByRole("alert")).toHaveTextContent("File is too large"); + expect(screen.queryByTestId("composer-chip")).not.toBeInTheDocument(); + }); + + it("reports a transport limit separately from attachment policy", async () => { + const first = pdfFile("first.pdf", 400 * 1024); + const second = pdfFile("second.pdf", 400 * 1024); + + render( + , + ); + + const input = screen + .getByLabelText(/message input/i) + .closest("form")! + .querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(input, { target: { files: [first, second] } }); + }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "gateway transport limit", + ); + expect(screen.getAllByTestId("composer-chip")).toHaveLength(1); + expect(screen.getByText("first.pdf")).toBeInTheDocument(); + expect(screen.queryByText("second.pdf")).not.toBeInTheDocument(); + }); + + it("enforces the decoded attachment-total policy independently", async () => { + const first = pdfFile("first.pdf", 400 * 1024); + const second = pdfFile("second.pdf", 400 * 1024); + + render( + , + ); + + const input = screen + .getByLabelText(/message input/i) + .closest("form")! + .querySelector('input[type="file"]') as HTMLInputElement; + + await act(async () => { + fireEvent.change(input, { target: { files: [first, second] } }); + }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Attachments are too large together", + ); + expect(screen.getAllByTestId("composer-chip")).toHaveLength(1); + }); + + it("enforces the text-byte policy without changing attachment limits", () => { + const onSend = vi.fn(); + render( + , + ); + + const textarea = screen.getByLabelText(/message input/i); + fireEvent.change(textarea, { target: { value: "你好" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Message text is too large (max 4 B)", + ); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("accepts supported documents from paste and drop", async () => { + const pasted = pdfFile("pasted.pdf"); + const dropped = pdfFile("dropped.pdf"); + const onSend = vi.fn(); + + render(); + + const textarea = screen.getByLabelText(/message input/i); + const form = textarea.closest("form")!; + + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + files: [pasted], + items: [{ + kind: "file", + type: pasted.type, + getAsFile: () => pasted, + }], + types: ["Files"], + getData: () => "", + }, + }); + }); + + await waitFor(() => + expect(screen.getByText("pasted.pdf")).toBeInTheDocument(), + ); + + await act(async () => { + fireEvent.drop(form, { + dataTransfer: { + files: [dropped], + items: [], + types: ["Files"], + dropEffect: "copy", + }, + }); + }); + + await waitFor(() => + expect(screen.getByText("dropped.pdf")).toBeInTheDocument(), + ); + expect(screen.getAllByTestId("composer-chip")).toHaveLength(2); + expect(encodeImage).not.toHaveBeenCalled(); + }); + it("blocks send while an image is still encoding", async () => { const file = pngFile("slow.png"); let resolveEncode: (r: EncodeResponse) => void = () => {}; @@ -117,7 +373,7 @@ describe("ThreadComposer — image attachments", () => { expect(onSend).toHaveBeenCalledTimes(1); }); - it("rejects a non-image paste silently without adding a chip", async () => { + it("keeps a plain-text paste untouched without adding a chip", async () => { const onSend = vi.fn(); render(); const textarea = screen.getByLabelText(/message input/i); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 5baf3063..c7e89a85 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -336,7 +336,7 @@ describe("ThreadComposer", () => { expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]"); expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]"); expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]"); - expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); + expect(screen.getByRole("button", { name: "Attach files" }).className).toContain("bg-card"); expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument(); }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 9ea20c9f..e5dfe134 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -1520,6 +1520,37 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].turnPhase).toBe("user"); }); + it("adds optimistic user file attachments as media", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + const attachment = { + media: { + data_url: "data:application/pdf;base64,JVBERi0xLjQ=", + name: "report.pdf", + }, + preview: { + kind: "file" as const, + url: "data:application/pdf;base64,JVBERi0xLjQ=", + name: "report.pdf", + }, + }; + + act(() => { + result.current.send("summarize", [attachment]); + }); + + expect(result.current.messages[0].media).toEqual([attachment.preview]); + expect(result.current.messages[0].images).toBeUndefined(); + expect(fake.client.sendMessage).toHaveBeenCalledWith( + "chat-file-send", + "summarize", + [attachment.media], + expect.objectContaining({ turnId: expect.any(String) }), + ); + }); + it("attaches assistant media_urls to complete messages", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {