refactor: split WebUI gateway dependencies

Maintainer edit for PR 4115: rebase onto origin/main and split gateway HTTP routing from token, media, and workspace services so WebSocketChannel depends on explicit gateway services instead of GatewayHTTPHandler internals.

Preserve file edit channel capabilities and restore tools.restrict_to_workspace wiring through ChannelManager.
This commit is contained in:
chengyongru
2026-06-02 17:14:38 +08:00
committed by Xubin Ren
parent 2420826e05
commit 2a98360105
14 changed files with 753 additions and 779 deletions
+6 -4
View File
@@ -16,7 +16,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
from nanobot.webui.ws_http import GatewayHTTPHandler
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -113,21 +112,24 @@ class ChannelManager:
kwargs: dict[str, Any] = {}
if cls.name == "websocket":
from nanobot.channels.websocket import WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
parsed = WebSocketConfig.model_validate(section)
static_path = _default_webui_dist() if self._webui_static_dist else None
workspace = Path(self.config.workspace_path)
http_handler = GatewayHTTPHandler(
gateway = build_gateway_services(
config=parsed,
bus=self.bus,
session_manager=self._session_manager,
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
bus=self.bus,
logger=logger,
)
kwargs["http_handler"] = http_handler
kwargs["gateway"] = gateway
channel = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key
+37 -267
View File
@@ -3,27 +3,20 @@
from __future__ import annotations
import asyncio
import email.utils
import hmac
import http
import json
import re
import ssl
import uuid
from collections.abc import Callable
from contextlib import suppress
from functools import partial
from pathlib import Path
from typing import Any, Self
from urllib.parse import parse_qs, unquote, urlparse
from loguru import logger
from pydantic import Field, field_validator, model_validator
from websockets.asyncio.server import ServerConnection, serve, unix_serve
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
@@ -41,53 +34,22 @@ from nanobot.utils.media_decode import (
save_base64_data_url,
)
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.gateway_services import GatewayServices
from nanobot.webui.http_utils import (
is_localhost as _is_localhost,
)
from nanobot.webui.http_utils import (
normalize_config_path as _normalize_config_path,
)
from nanobot.webui.http_utils import (
parse_request_path as _parse_request_path,
)
from nanobot.webui.http_utils import (
query_first as _query_first,
)
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
from nanobot.webui.transcript import append_transcript_object
def _strip_trailing_slash(path: str) -> str:
if len(path) > 1 and path.endswith("/"):
return path.rstrip("/")
return path or "/"
def _normalize_config_path(path: str) -> str:
return _strip_trailing_slash(path)
def _case_insensitive_header(headers: Any, key: str) -> str:
"""Read a header from websockets/http test stubs without assuming casing."""
try:
value = headers.get(key)
except Exception:
value = None
if value is None:
try:
value = headers.get(key.lower())
except Exception:
value = None
return str(value or "").strip()
def _safe_host_header(value: str) -> str:
"""Return a safe Host header value, or empty when it should not be echoed."""
value = value.strip()
if not value:
return ""
if re.fullmatch(r"\[[0-9A-Fa-f:.]+\](?::\d{1,5})?", value):
return value
if re.fullmatch(r"[A-Za-z0-9.-]+(?::\d{1,5})?", value):
return value
return ""
def _host_for_url(host: str, port: int) -> str:
host = host.strip()
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{host}:{port}"
from nanobot.webui.websocket_logging import websockets_server_logger
class WebSocketConfig(Base):
@@ -182,20 +144,6 @@ class WebSocketConfig(Base):
)
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers = Headers(
[
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", "application/json; charset=utf-8"),
]
)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, headers, body)
def publish_runtime_model_update(
bus: MessageBus,
model: str,
@@ -214,57 +162,6 @@ def publish_runtime_model_update(
))
def _default_model_name_from_config() -> str | None:
"""Resolved model string from on-disk config (bootstrap fallback)."""
try:
from nanobot.config.loader import load_config
model = load_config().resolve_preset().model.strip()
return model or None
except Exception as e:
logger.debug("bootstrap model_name could not load from config: {}", e)
return None
def _resolve_bootstrap_model_name(
runtime_name: Callable[[], str | None] | None,
) -> str | None:
"""Prefer an in-process resolver (e.g. AgentLoop); else config-derived default."""
if runtime_name is not None:
try:
raw = runtime_name()
except Exception as e:
logger.debug("bootstrap runtime model resolver failed: {}", e)
else:
if isinstance(raw, str):
stripped = raw.strip()
if stripped:
return stripped
return _default_model_name_from_config()
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
"""Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query)
path = _strip_trailing_slash(parsed.path or "/")
return path, parse_qs(parsed.query, keep_blank_values=True)
def _normalize_http_path(path_with_query: str) -> str:
"""Return the path component (no query string), with trailing slash normalized (root stays ``/``)."""
return _parse_request_path(path_with_query)[0]
def _parse_query(path_with_query: str) -> dict[str, list[str]]:
return _parse_request_path(path_with_query)[1]
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
"""Return the first value for *key*, or None."""
values = query.get(key)
return values[0] if values else None
def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip()
@@ -355,67 +252,6 @@ def _extract_data_url_mime(url: str) -> str | None:
return m.group(1).strip().lower() or None
_LOCALHOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
# Matches the legacy chat-id pattern but allows file-system-safe stems too,
# so the API can address sessions whose keys came from non-WebSocket channels.
_API_KEY_RE = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
def _decode_api_key(raw_key: str) -> str | None:
"""Decode a percent-encoded API path segment, then validate the result."""
key = unquote(raw_key)
if _API_KEY_RE.match(key) is None:
return None
return key
def _is_localhost(connection: Any) -> bool:
"""Return True if *connection* originated from the loopback interface."""
addr = getattr(connection, "remote_address", None)
if not addr:
return False
host = addr[0] if isinstance(addr, tuple) else addr
if not isinstance(host, str):
return False
# ``::ffff:127.0.0.1`` is loopback in IPv6-mapped form.
if host.startswith("::ffff:"):
host = host[7:]
return host in _LOCALHOSTS
def _http_response(
body: bytes,
*,
status: int = 200,
content_type: str = "text/plain; charset=utf-8",
extra_headers: list[tuple[str, str]] | None = None,
) -> Response:
headers = [
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", content_type),
]
if extra_headers:
headers.extend(extra_headers)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body)
def _http_error(status: int, message: str | None = None) -> Response:
body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
return _http_response(body, status=status)
def _bearer_token(headers: Any) -> str | None:
"""Pull a Bearer token out of standard or query-style headers."""
auth = headers.get("Authorization") or headers.get("authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return 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")
@@ -427,20 +263,6 @@ def _is_websocket_upgrade(request: WsRequest) -> bool:
return True
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
if not configured_secret:
return True
authorization = headers.get("Authorization") or headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:].strip()
return hmac.compare_digest(supplied, configured_secret)
header_token = headers.get("X-Nanobot-Auth") or headers.get("x-nanobot-auth")
if not header_token:
return False
return hmac.compare_digest(header_token.strip(), configured_secret)
class WebSocketChannel(BaseChannel):
"""Run a local WebSocket server; forward text/JSON messages to the message bus."""
@@ -452,7 +274,7 @@ class WebSocketChannel(BaseChannel):
config: Any,
bus: MessageBus,
*,
http_handler: Any | None = None,
gateway: GatewayServices,
):
if isinstance(config, dict):
config = WebSocketConfig.model_validate(config)
@@ -467,11 +289,11 @@ class WebSocketChannel(BaseChannel):
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
# HTTP handler injected from outside (ChannelManager / gateway startup).
# Owns tokens, sessions, media, settings, static serving.
self._http = http_handler
# Backwards-compat: workspace controller used in envelope dispatch
self._webui_workspaces = http_handler.workspaces if http_handler else None
self.gateway = gateway
self._http_router = gateway.http
self._tokens = gateway.tokens
self._media = gateway.media
self._workspaces = gateway.workspaces
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
@@ -501,9 +323,9 @@ class WebSocketChannel(BaseChannel):
connected clients normally see it via ``goal_state`` / ``turn_end`` frames.
Pushing here makes refresh + reconnect restore the strip without a new model turn.
"""
if self._http.session_manager is None:
if self.gateway.session_manager is None:
return
row = self._http.session_manager.read_session_file(f"websocket:{chat_id}")
row = self.gateway.session_manager.read_session_file(f"websocket:{chat_id}")
meta = row.get("metadata", {}) if isinstance(row, dict) else {}
if not isinstance(meta, dict):
meta = {}
@@ -543,57 +365,6 @@ class WebSocketChannel(BaseChannel):
def _expected_path(self) -> str:
return _normalize_config_path(self.config.path)
# -- Backwards-compat property aliases (used by tests) ------------------
@property
def _session_manager(self):
return self._http.session_manager
@_session_manager.setter
def _session_manager(self, value):
self._http.session_manager = value
@property
def _media_secret(self):
return self._http.media_secret
@property
def _issued_tokens(self):
return self._http.issued_tokens
@_issued_tokens.setter
def _issued_tokens(self, value):
self._http.issued_tokens = value
@property
def _api_tokens(self):
return self._http.api_tokens
def _check_api_token(self, request):
return self._http.check_api_token(request)
def _sign_media_path(self, path):
return self._http.sign_media_path(path)
def _sign_or_stage_media_path(self, path):
return self._http.sign_or_stage_media_path(path)
def _rewrite_local_markdown_images(self, text):
return self._http.rewrite_local_markdown_images(text)
def _handle_bootstrap(self, connection, request):
return self._http._handle_bootstrap(connection, request)
def _handle_sessions_list(self, request):
return self._http._handle_sessions_list(request)
def _handle_webui_thread_get(self, request, key):
return self._http._handle_webui_thread_get(request, key)
@property
def _workspace_path(self):
return self._http.workspace_path
def _build_ssl_context(self) -> ssl.SSLContext | None:
cert = self.config.ssl_certfile.strip()
key = self.config.ssl_keyfile.strip()
@@ -624,8 +395,8 @@ class WebSocketChannel(BaseChannel):
return connection.respond(403, "Forbidden")
return self._authorize_websocket_handshake(connection, query)
<< # Everything else goes to the HTTP handler
return await self._http.dispatch(connection, request)
# Everything else goes to the HTTP handler
return await self._http_router.dispatch(connection, request)
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any:
supplied = _query_first(query, "token")
@@ -634,17 +405,17 @@ class WebSocketChannel(BaseChannel):
if static_token:
if supplied and hmac.compare_digest(supplied, static_token):
return None
if supplied and self._http.take_issued_token_if_valid(supplied):
if supplied and self._tokens.take_issued_token_if_valid(supplied):
return None
return connection.respond(401, "Unauthorized")
if self.config.websocket_requires_token:
if supplied and self._http.take_issued_token_if_valid(supplied):
if supplied and self._tokens.take_issued_token_if_valid(supplied):
return None
return connection.respond(401, "Unauthorized")
if supplied:
self._http.take_issued_token_if_valid(supplied)
self._tokens.take_issued_token_if_valid(supplied)
return None
# -- Server lifecycle and connection ingress ---------------------------
@@ -878,14 +649,14 @@ class WebSocketChannel(BaseChannel):
new_id = str(uuid.uuid4())
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_new_chat(
lambda: self._workspaces.scope_for_new_chat(
envelope,
controls_available=_is_localhost(connection),
),
)
if scope is None:
return
self._webui_workspaces.persist_scope(new_id, scope)
self._workspaces.persist_scope(new_id, scope)
self._attach(connection, new_id)
await self._send_event(connection, "attached", chat_id=new_id)
await self._send_event(
@@ -913,7 +684,7 @@ class WebSocketChannel(BaseChannel):
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_set_request(
lambda: self._workspaces.scope_for_set_request(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
@@ -923,7 +694,7 @@ class WebSocketChannel(BaseChannel):
)
if scope is None:
return
self._webui_workspaces.persist_scope(cid, scope)
self._workspaces.persist_scope(cid, scope)
await self._send_event(
connection,
"session_updated",
@@ -965,7 +736,7 @@ class WebSocketChannel(BaseChannel):
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._webui_workspaces.scope_for_message(
lambda: self._workspaces.scope_for_message(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
@@ -989,7 +760,7 @@ class WebSocketChannel(BaseChannel):
if mcp_presets:
metadata["mcp_presets"] = mcp_presets
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
self._webui_workspaces.persist_scope(cid, scope)
self._workspaces.persist_scope(cid, scope)
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")
@@ -1044,8 +815,7 @@ class WebSocketChannel(BaseChannel):
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._http.issued_tokens.clear()
self._http.api_tokens.clear()
self._tokens.clear()
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
@@ -1127,7 +897,7 @@ class WebSocketChannel(BaseChannel):
)
return
text = msg.content
wire_text = self._http.rewrite_local_markdown_images(text)
wire_text = self._media.rewrite_local_markdown_images(text)
payload: dict[str, Any] = {
"event": "message",
"chat_id": msg.chat_id,
@@ -1137,7 +907,7 @@ class WebSocketChannel(BaseChannel):
payload["media"] = msg.media
urls: list[dict[str, str]] = []
for entry in msg.media:
signed = self._http.sign_or_stage_media_path(Path(entry))
signed = self._media.sign_or_stage_media_path(Path(entry))
if signed is not None:
urls.append(signed)
if urls:
@@ -1252,7 +1022,7 @@ class WebSocketChannel(BaseChannel):
if delta:
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._http.rewrite_local_markdown_images(full_text)
rewritten = self._media.rewrite_local_markdown_images(full_text)
if rewritten != full_text:
body["text"] = rewritten
else:
+70
View File
@@ -0,0 +1,70 @@
"""Composition helpers for the embedded WebUI gateway."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loguru import logger as default_logger
from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.workspaces import WebUIWorkspaceController
from nanobot.webui.ws_http import GatewayHTTPHandler
@dataclass(frozen=True)
class GatewayServices:
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
http: GatewayHTTPHandler
tokens: GatewayTokenStore
media: WebUIMediaGateway
workspaces: WebUIWorkspaceController
session_manager: Any | None
def build_gateway_services(
*,
config: Any,
bus: Any,
session_manager: Any | None,
static_dist_path: Path | None,
workspace_path: Path,
default_restrict_to_workspace: bool,
runtime_model_name: Any | None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
logger: Any = default_logger,
) -> GatewayServices:
tokens = GatewayTokenStore()
media = WebUIMediaGateway(
workspace_path=workspace_path,
logger=logger,
)
workspaces = WebUIWorkspaceController(
session_manager=session_manager,
default_workspace=workspace_path,
default_restrict_to_workspace=default_restrict_to_workspace,
)
http = GatewayHTTPHandler(
config=config,
session_manager=session_manager,
static_dist_path=static_dist_path,
runtime_model_name=runtime_model_name,
runtime_surface=runtime_surface,
runtime_capabilities_overrides=runtime_capabilities_overrides,
bus=bus,
tokens=tokens,
media=media,
workspaces=workspaces,
log=logger,
)
return GatewayServices(
http=http,
tokens=tokens,
media=media,
workspaces=workspaces,
session_manager=session_manager,
)
+82
View File
@@ -0,0 +1,82 @@
"""Token state for the embedded WebUI gateway."""
from __future__ import annotations
import secrets
import time
from dataclasses import dataclass, field
from typing import Any
from websockets.http11 import Request as WsRequest
from nanobot.webui.http_utils import bearer_token, parse_query, query_first
@dataclass
class GatewayTokenStore:
"""Own short-lived WebSocket and WebUI API tokens for one gateway process."""
max_tokens: int = 10_000
issued_tokens: dict[str, float] = field(default_factory=dict)
api_tokens: dict[str, float] = field(default_factory=dict)
def check_api_token(self, request: WsRequest) -> bool:
self._purge_expired_api_tokens()
token = bearer_token(request.headers) or query_first(
parse_query(request.path), "token"
)
if not token:
return False
expiry = self.api_tokens.get(token)
if expiry is None or time.monotonic() > expiry:
self.api_tokens.pop(token, None)
return False
return True
def can_issue(self, *, include_api_token: bool = False) -> bool:
self._purge_expired_issued_tokens()
self._purge_expired_api_tokens()
if len(self.issued_tokens) >= self.max_tokens:
return False
if include_api_token and len(self.api_tokens) >= self.max_tokens:
return False
return True
def issue_token(self, ttl_s: int | float, *, api_token: bool = False) -> str:
token_value = f"nbwt_{secrets.token_urlsafe(32)}"
expiry = time.monotonic() + float(ttl_s)
self.issued_tokens[token_value] = expiry
if api_token:
self.api_tokens[token_value] = expiry
return token_value
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
if not token_value:
return False
self._purge_expired_issued_tokens()
expiry = self.issued_tokens.pop(token_value, None)
if expiry is None:
return False
if time.monotonic() > expiry:
return False
return True
def clear(self) -> None:
self.issued_tokens.clear()
self.api_tokens.clear()
def _purge_expired_api_tokens(self) -> None:
now = time.monotonic()
for token_key, expiry in list(self.api_tokens.items()):
if now > expiry:
self.api_tokens.pop(token_key, None)
def _purge_expired_issued_tokens(self) -> None:
now = time.monotonic()
for token_key, expiry in list(self.issued_tokens.items()):
if now > expiry:
self.issued_tokens.pop(token_key, None)
def token_response_payload(token: str, expires_in: Any) -> dict[str, Any]:
return {"token": token, "expires_in": expires_in}
+151
View File
@@ -0,0 +1,151 @@
"""Shared HTTP helpers for the embedded WebUI gateway."""
from __future__ import annotations
import email.utils
import hmac
import http
import json
import re
from typing import Any
from urllib.parse import parse_qs, urlparse
from websockets.datastructures import Headers
from websockets.http11 import Response
QueryParams = dict[str, list[str]]
def strip_trailing_slash(path: str) -> str:
if len(path) > 1 and path.endswith("/"):
return path.rstrip("/")
return path or "/"
def normalize_config_path(path: str) -> str:
return strip_trailing_slash(path)
def case_insensitive_header(headers: Any, key: str) -> str:
"""Read a header from websockets/http test stubs without assuming casing."""
try:
value = headers.get(key)
except Exception:
value = None
if value is None:
try:
value = headers.get(key.lower())
except Exception:
value = None
return str(value or "").strip()
def safe_host_header(value: str) -> str:
"""Return a safe Host header value, or empty when it should not be echoed."""
value = value.strip()
if not value:
return ""
if re.fullmatch(r"\[[0-9A-Fa-f:.]+\](?::\d{1,5})?", value):
return value
if re.fullmatch(r"[A-Za-z0-9.-]+(?::\d{1,5})?", value):
return value
return ""
def host_for_url(host: str, port: int) -> str:
host = host.strip()
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{host}:{port}"
def http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers = Headers(
[
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", "application/json; charset=utf-8"),
]
)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, headers, body)
def http_response(
body: bytes,
*,
status: int = 200,
content_type: str = "text/plain; charset=utf-8",
extra_headers: list[tuple[str, str]] | None = None,
) -> Response:
headers = [
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", content_type),
]
if extra_headers:
headers.extend(extra_headers)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body)
def http_error(status: int, message: str | None = None) -> Response:
body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
return http_response(body, status=status)
def parse_request_path(path_with_query: str) -> tuple[str, QueryParams]:
"""Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query)
path = strip_trailing_slash(parsed.path or "/")
return path, parse_qs(parsed.query, keep_blank_values=True)
def normalize_http_path(path_with_query: str) -> str:
return parse_request_path(path_with_query)[0]
def parse_query(path_with_query: str) -> QueryParams:
return parse_request_path(path_with_query)[1]
def query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def is_localhost(connection: Any) -> bool:
addr = getattr(connection, "remote_address", None)
if not addr:
return False
host = addr[0] if isinstance(addr, tuple) else addr
if not isinstance(host, str):
return False
if host.startswith("::ffff:"):
host = host[7:]
return host in {"127.0.0.1", "::1", "localhost"}
def bearer_token(headers: Any) -> str | None:
auth = headers.get("Authorization") or headers.get("authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
def issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
if not configured_secret:
return True
authorization = headers.get("Authorization") or headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:].strip()
return hmac.compare_digest(supplied, configured_secret)
header_token = headers.get("X-Nanobot-Auth") or headers.get("x-nanobot-auth")
if not header_token:
return False
return hmac.compare_digest(header_token.strip(), configured_secret)
+9 -40
View File
@@ -4,10 +4,8 @@ from __future__ import annotations
import base64
import binascii
import email.utils
import hashlib
import hmac
import http
import mimetypes
import re
import shutil
@@ -16,12 +14,20 @@ from collections.abc import Callable
from pathlib import Path
from typing import Any
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.config.paths import get_media_dir
from nanobot.utils.helpers import safe_filename
from nanobot.webui.http_utils import (
case_insensitive_header as _case_insensitive_header,
)
from nanobot.webui.http_utils import (
http_error as _http_error,
)
from nanobot.webui.http_utils import (
http_response as _http_response,
)
MediaDirProvider = Callable[[str | None], Path]
SignedMediaPath = Callable[[Path], dict[str, str] | None]
@@ -67,43 +73,6 @@ _SVG_MEDIA_HEADERS: tuple[tuple[str, str], ...] = (
_BYTE_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$")
def _http_response(
body: bytes,
*,
status: int = 200,
content_type: str = "text/plain; charset=utf-8",
extra_headers: list[tuple[str, str]] | None = None,
) -> Response:
headers = [
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", content_type),
]
if extra_headers:
headers.extend(extra_headers)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body)
def _http_error(status: int, message: str | None = None) -> Response:
body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
return _http_response(body, status=status)
def _case_insensitive_header(headers: Any, key: str) -> str:
try:
value = headers.get(key)
except Exception:
value = None
if value is None:
try:
value = headers.get(key.lower())
except Exception:
value = None
return str(value or "").strip()
def _parse_single_byte_range(range_header: str, size: int) -> tuple[int, int]:
"""Parse a single HTTP byte range for signed media responses."""
if size <= 0 or "," in range_header:
+92
View File
@@ -0,0 +1,92 @@
"""Media gateway services shared by WebUI HTTP routes and WebSocket frames."""
from __future__ import annotations
import secrets
from collections.abc import Callable
from pathlib import Path
from typing import Any
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.config.paths import get_media_dir
from nanobot.webui.media_api import (
attach_signed_media_urls,
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
signed_media_attachments,
)
from nanobot.webui.transcript import rewrite_local_markdown_images
class WebUIMediaGateway:
"""Own media URL signing and WebUI markdown/media augmentation."""
def __init__(
self,
*,
workspace_path: Path,
logger: Any,
media_dir: Callable[[str | None], Path] | None = None,
secret: bytes | 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)
def serve_signed_media(
self,
sig: str,
payload: str,
*,
request: WsRequest | None = None,
) -> Response:
return serve_signed_media(
sig,
payload,
secret=self.secret,
request=request,
media_dir=self._media_dir,
)
def sign_media_path(self, abs_path: Path) -> str | None:
return sign_media_path(
abs_path,
secret=self.secret,
media_dir=self._media_dir,
)
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
return sign_or_stage_media_path(
path,
secret=self.secret,
media_dir=self._media_dir,
logger=self.logger,
)
def rewrite_local_markdown_images(
self,
text: str,
*,
workspace_path: Path | None = None,
) -> str:
return rewrite_local_markdown_images(
text,
workspace_path=workspace_path or self.workspace_path,
sign_path=self.sign_or_stage_media_path,
)
def augment_media_urls(self, payload: dict[str, Any]) -> None:
attach_signed_media_urls(payload, sign_path=self.sign_media_path)
def augment_transcript_media(self, paths: list[str]) -> list[dict[str, Any]]:
return signed_media_attachments(
paths,
sign_path=self.sign_or_stage_media_path,
)
def augment_transcript_user_media(self, paths: list[str]) -> list[dict[str, Any]]:
return self.augment_transcript_media(paths)
+60 -297
View File
@@ -9,187 +9,70 @@ Also houses shared HTTP utility functions used by both this module and
from __future__ import annotations
import email.utils
import hmac
import http
import json
import mimetypes
import re
import secrets
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import parse_qs, urlparse
from loguru import logger
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.media_api import (
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
from nanobot.webui.http_utils import (
case_insensitive_header as _case_insensitive_header,
)
from nanobot.webui.http_utils import (
host_for_url as _host_for_url,
)
from nanobot.webui.http_utils import (
http_error as _http_error,
)
from nanobot.webui.http_utils import (
http_json_response as _http_json_response,
)
from nanobot.webui.http_utils import (
http_response as _http_response,
)
from nanobot.webui.http_utils import (
is_localhost as _is_localhost,
)
from nanobot.webui.http_utils import (
issue_route_secret_matches as _issue_route_secret_matches,
)
from nanobot.webui.http_utils import (
normalize_config_path as _normalize_config_path,
)
from nanobot.webui.http_utils import (
parse_query as _parse_query,
)
from nanobot.webui.http_utils import (
parse_request_path as _parse_request_path,
)
from nanobot.webui.http_utils import (
query_first as _query_first,
)
from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header,
)
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
)
from nanobot.webui.thread_disk import delete_webui_thread
from nanobot.webui.transcript import (
build_webui_thread_response,
rewrite_local_markdown_images,
)
from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController
if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import SessionManager
# ---------------------------------------------------------------------------
# Shared HTTP utility functions (imported by websocket.py)
# ---------------------------------------------------------------------------
def _strip_trailing_slash(path: str) -> str:
if len(path) > 1 and path.endswith("/"):
return path.rstrip("/")
return path or "/"
def _normalize_config_path(path: str) -> str:
return _strip_trailing_slash(path)
def _case_insensitive_header(headers: Any, key: str) -> str:
"""Read a header from websockets/http test stubs without assuming casing."""
try:
value = headers.get(key)
except Exception:
value = None
if value is None:
try:
value = headers.get(key.lower())
except Exception:
value = None
return str(value or "").strip()
def _safe_host_header(value: str) -> str:
"""Return a safe Host header value, or empty when it should not be echoed."""
value = value.strip()
if not value:
return ""
if re.fullmatch(r"\[[0-9A-Fa-f:.]+\](?::\d{1,5})?", value):
return value
if re.fullmatch(r"[A-Za-z0-9.-]+(?::\d{1,5})?", value):
return value
return ""
def _host_for_url(host: str, port: int) -> str:
host = host.strip()
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{host}:{port}"
def _http_json_response(data: dict[str, Any], *, status: int = 200) -> Response:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
headers = Headers(
[
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", "application/json; charset=utf-8"),
]
)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, headers, body)
def _http_response(
body: bytes,
*,
status: int = 200,
content_type: str = "text/plain; charset=utf-8",
extra_headers: list[tuple[str, str]] | None = None,
) -> Response:
headers = [
("Date", email.utils.formatdate(usegmt=True)),
("Connection", "close"),
("Content-Length", str(len(body))),
("Content-Type", content_type),
]
if extra_headers:
headers.extend(extra_headers)
reason = http.HTTPStatus(status).phrase
return Response(status, reason, Headers(headers), body)
def _http_error(status: int, message: str | None = None) -> Response:
body = (message or http.HTTPStatus(status).phrase).encode("utf-8")
return _http_response(body, status=status)
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
"""Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query)
path = _strip_trailing_slash(parsed.path or "/")
return path, parse_qs(parsed.query, keep_blank_values=True)
def _normalize_http_path(path_with_query: str) -> str:
return _parse_request_path(path_with_query)[0]
def _parse_query(path_with_query: str) -> dict[str, list[str]]:
return _parse_request_path(path_with_query)[1]
def _query_first(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _is_localhost(connection: Any) -> bool:
addr = getattr(connection, "remote_address", None)
if not addr:
return False
host = addr[0] if isinstance(addr, tuple) else addr
if not isinstance(host, str):
return False
if host.startswith("::ffff:"):
host = host[7:]
return host in {"127.0.0.1", "::1", "localhost"}
def _bearer_token(headers: Any) -> str | None:
auth = headers.get("Authorization") or headers.get("authorization")
if auth and auth.lower().startswith("bearer "):
return auth[7:].strip() or None
return None
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
if not configured_secret:
return True
authorization = headers.get("Authorization") or headers.get("authorization")
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:].strip()
return hmac.compare_digest(supplied, configured_secret)
header_token = headers.get("X-Nanobot-Auth") or headers.get("x-nanobot-auth")
if not header_token:
return False
return hmac.compare_digest(header_token.strip(), configured_secret)
def _decode_api_key(raw_key: str) -> str | None:
from urllib.parse import unquote
@@ -234,48 +117,36 @@ def _resolve_bootstrap_model_name(
class GatewayHTTPHandler:
"""Handles all HTTP routes served alongside the WebSocket endpoint.
Owns token management, session API, media API, static file serving,
and delegates settings routes to ``WebUISettingsRouter``.
Routes HTTP requests and delegates stateful work to explicit gateway
services owned by the composition layer.
"""
_MAX_ISSUED_TOKENS = 10_000
def __init__(
self,
*,
config: Any, # WebSocketConfig
session_manager: SessionManager | None,
static_dist_path: Path | None,
workspace_path: Path,
runtime_model_name: Callable[[], str | None] | None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
bus: MessageBus,
tokens: GatewayTokenStore,
media: WebUIMediaGateway,
workspaces: WebUIWorkspaceController,
log: Any = logger,
) -> None:
self.config = config
self.session_manager = session_manager
self.static_dist_path = static_dist_path
self.workspace_path = workspace_path
self.runtime_model_name = runtime_model_name
self.bus = bus
self.tokens = tokens
self.media = media
self.workspaces = workspaces
self._log = log
self._runtime_surface = runtime_surface
self.issued_tokens: dict[str, float] = {}
self.api_tokens: dict[str, float] = {}
self.media_secret: bytes = secrets.token_bytes(32)
# Workspace controller
from nanobot.webui.workspaces import WebUIWorkspaceController
self.workspaces = WebUIWorkspaceController(
session_manager=session_manager,
default_workspace=workspace_path,
default_restrict_to_workspace=None,
)
# Settings router
from nanobot.webui.settings_api import runtime_capabilities as _rc
from nanobot.webui.settings_routes import WebUISettingsRouter
@@ -294,46 +165,13 @@ class GatewayHTTPHandler:
# -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool:
self._purge_expired_api_tokens()
token = _bearer_token(request.headers) or _query_first(
_parse_query(request.path), "token"
)
if not token:
return False
expiry = self.api_tokens.get(token)
if expiry is None or time.monotonic() > expiry:
self.api_tokens.pop(token, None)
return False
return True
def _purge_expired_api_tokens(self) -> None:
now = time.monotonic()
for token_key, expiry in list(self.api_tokens.items()):
if now > expiry:
self.api_tokens.pop(token_key, None)
def _purge_expired_issued_tokens(self) -> None:
now = time.monotonic()
for token_key, expiry in list(self.issued_tokens.items()):
if now > expiry:
self.issued_tokens.pop(token_key, None)
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
if not token_value:
return False
self._purge_expired_issued_tokens()
expiry = self.issued_tokens.pop(token_value, None)
if expiry is None:
return False
if time.monotonic() > expiry:
return False
return True
return self.tokens.check_api_token(request)
# -- Main dispatch ------------------------------------------------------
async def dispatch(self, connection: Any, request: WsRequest) -> Any | None:
"""Route an HTTP request. Returns Response or None."""
got, query = _parse_request_path(request.path)
got, _ = _parse_request_path(request.path)
# Token issue endpoint
if self.config.token_issue_path:
@@ -389,18 +227,14 @@ class GatewayHTTPHandler:
"token_issue_path is set but token_issue_secret is empty; "
"any client can obtain connection tokens — set token_issue_secret for production."
)
self._purge_expired_issued_tokens()
if len(self.issued_tokens) >= self._MAX_ISSUED_TOKENS:
if not self.tokens.can_issue():
self._log.error(
"too many outstanding issued tokens ({}), rejecting issuance",
len(self.issued_tokens),
len(self.tokens.issued_tokens),
)
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
token_value = f"nbwt_{secrets.token_urlsafe(32)}"
self.issued_tokens[token_value] = time.monotonic() + float(self.config.token_ttl_s)
return _http_json_response(
{"token": token_value, "expires_in": self.config.token_ttl_s}
)
token_value = self.tokens.issue_token(self.config.token_ttl_s)
return _http_json_response(token_response_payload(token_value, self.config.token_ttl_s))
# -- Bootstrap ----------------------------------------------------------
@@ -412,21 +246,13 @@ class GatewayHTTPHandler:
elif not _is_localhost(connection):
return _http_error(403, "bootstrap is localhost-only")
self._purge_expired_issued_tokens()
self._purge_expired_api_tokens()
if (
len(self.issued_tokens) >= self._MAX_ISSUED_TOKENS
or len(self.api_tokens) >= self._MAX_ISSUED_TOKENS
):
if not self.tokens.can_issue(include_api_token=True):
return _http_response(
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
status=429,
content_type="application/json; charset=utf-8",
)
token = f"nbwt_{secrets.token_urlsafe(32)}"
expiry = time.monotonic() + float(self.config.token_ttl_s)
self.issued_tokens[token] = expiry
self.api_tokens[token] = expiry
token = self.tokens.issue_token(self.config.token_ttl_s, api_token=True)
ws_url = self._bootstrap_ws_url(request)
expected_path = _normalize_config_path(self.config.path)
@@ -510,7 +336,7 @@ class GatewayHTTPHandler:
messages = data.get("messages")
if isinstance(messages, list):
scrub_subagent_messages_for_channel(messages)
self._augment_media_urls(data)
self.media.augment_media_urls(data)
return _http_json_response(data)
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
@@ -524,11 +350,11 @@ class GatewayHTTPHandler:
scope = self.workspaces.scope_for_session_key(decoded_key)
data = build_webui_thread_response(
decoded_key,
augment_user_media=self._augment_transcript_user_media,
augment_assistant_text=lambda text: rewrite_local_markdown_images(
augment_user_media=self.media.augment_transcript_media,
augment_assistant_media=self.media.augment_transcript_media,
augment_assistant_text=lambda text: self.media.rewrite_local_markdown_images(
text,
workspace_path=scope.project_path,
sign_path=self.sign_or_stage_media_path,
),
)
if data is None:
@@ -561,34 +387,10 @@ class GatewayHTTPHandler:
def _handle_media_fetch(
self, sig: str, payload: str, request: WsRequest | None = None
) -> Response:
return serve_signed_media(
return self.media.serve_signed_media(
sig,
payload,
secret=self.media_secret,
request=request,
media_dir=lambda channel=None: get_media_dir(channel),
)
def sign_media_path(self, abs_path: Path) -> str | None:
return sign_media_path(
abs_path,
secret=self.media_secret,
media_dir=lambda channel=None: get_media_dir(channel),
)
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
return sign_or_stage_media_path(
path,
secret=self.media_secret,
media_dir=lambda channel=None: get_media_dir(channel),
logger=self._log,
)
def rewrite_local_markdown_images(self, text: str) -> str:
return rewrite_local_markdown_images(
text,
workspace_path=self.workspace_path,
sign_path=self.sign_or_stage_media_path,
)
# -- Misc routes --------------------------------------------------------
@@ -688,44 +490,5 @@ class GatewayHTTPHandler:
extra_headers=[("Cache-Control", cache)],
)
# -- Media helpers (called by WebSocketChannel.send) --------------------
def _augment_media_urls(self, payload: dict[str, Any]) -> None:
messages = payload.get("messages")
if not isinstance(messages, list):
return
for msg in messages:
if not isinstance(msg, dict):
continue
media = msg.get("media")
if not isinstance(media, list) or not media:
continue
urls: list[dict[str, str]] = []
for entry in media:
if not isinstance(entry, str) or not entry:
continue
signed = self.sign_media_path(Path(entry))
if signed is None:
continue
urls.append({"url": signed, "name": Path(entry).name})
if urls:
msg["media_urls"] = urls
msg.pop("media", None)
def _augment_transcript_user_media(self, paths: list[str]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for pstr in paths:
path = Path(pstr)
att = self.sign_or_stage_media_path(path)
if att is None:
continue
mime, _ = mimetypes.guess_type(path.name)
kind = "video" if mime and mime.startswith("video/") else "image"
out.append(
{"kind": kind, "url": att["url"], "name": att.get("name", path.name)},
)
return out
def _is_websocket_channel_session_key(key: str) -> bool:
return key.startswith("websocket:")