feat(webui): support document attachments with ingress safeguards (#4771)
* feat: support document attachments in webui * fix(webui): normalize document attachment MIME * refactor(webui): move attachment policy out of channel * fix(webui): reject oversized attachments before send * fix(webui): align Portuguese attachment errors * refactor(webui): separate ingress and transport limits * fix(webui): reject malformed attachment payloads
This commit is contained in:
+21
-118
@@ -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:<mime>;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
|
||||
|
||||
@@ -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:<mime>;base64,<payload>`` 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user