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
@@ -65,6 +65,32 @@ def manager() -> ChannelManager:
return mgr
def test_websocket_gateway_uses_configured_workspace_restriction(tmp_path, monkeypatch):
monkeypatch.setattr(
"nanobot.webui.workspaces.read_webui_default_access_mode",
lambda: "default",
)
config = Config.model_validate(
{
"agents": {"defaults": {"workspace": str(tmp_path)}},
"tools": {"restrictToWorkspace": True},
"channels": {
"websocket": {
"enabled": True,
"websocketRequiresToken": False,
},
},
}
)
mgr = ChannelManager(config, MessageBus(), webui_static_dist=False)
channel = mgr.channels["websocket"]
scope = channel.gateway.workspaces.default_scope()
assert scope.project_path == tmp_path
assert scope.restrict_to_workspace is True
@pytest.mark.asyncio
async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
channel = manager.channels["mock"]
+93 -71
View File
@@ -20,20 +20,30 @@ from nanobot.channels.websocket import (
WebSocketChannel,
WebSocketConfig,
_is_valid_chat_id,
_issue_route_secret_matches,
_normalize_config_path,
_normalize_http_path,
_parse_envelope,
_parse_inbound_payload,
_parse_query,
_parse_request_path,
publish_runtime_model_update,
)
from nanobot.webui.ws_http import GatewayHTTPHandler
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
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 (
normalize_http_path as _normalize_http_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.settings_api import settings_payload, update_provider_settings
# -- Shared helpers (aligned with test_websocket_integration.py) ---------------
@@ -52,34 +62,36 @@ def _ch(bus: Any, **kw: Any) -> WebSocketChannel:
}
cfg.update(kw)
parsed = WebSocketConfig.model_validate(cfg)
http_handler = GatewayHTTPHandler(
gateway = build_gateway_services(
config=parsed,
bus=bus,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
default_restrict_to_workspace=False,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
return WebSocketChannel(cfg, bus, http_handler=http_handler)
return WebSocketChannel(cfg, bus, gateway=gateway)
def _basic_handler(bus: Any, **kw: Any) -> GatewayHTTPHandler:
def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
cfg = WebSocketConfig.model_validate({
"enabled": True, "allowFrom": ["*"],
"host": "127.0.0.1", "port": _PORT,
"path": "/ws", "websocketRequiresToken": False,
})
return GatewayHTTPHandler(
return build_gateway_services(
config=cfg,
bus=bus,
session_manager=kw.get("session_manager"),
static_dist_path=None,
workspace_path=kw.get("workspace_path", Path.cwd()),
default_restrict_to_workspace=kw.get("default_restrict_to_workspace", False),
runtime_model_name=None,
runtime_surface=kw.get("runtime_surface", "browser"),
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
bus=bus,
)
@@ -194,7 +206,7 @@ def test_ssl_context_requires_both_cert_and_key_files() -> None:
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "sslCertfile": "/tmp/c.pem", "sslKeyfile": ""},
bus,
http_handler=_basic_handler(bus),
gateway=_basic_handler(bus),
)
with pytest.raises(ValueError, match="ssl_certfile and ssl_keyfile"):
channel._build_ssl_context()
@@ -310,7 +322,7 @@ async def test_webui_message_scope_inherits_persisted_session_scope(
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -356,7 +368,7 @@ async def test_webui_scope_expands_home_project_path(
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
http_handler=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
gateway=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -393,7 +405,7 @@ async def test_webui_scope_rejects_missing_project_path(bus: MagicMock, tmp_path
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
http_handler=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
gateway=_basic_handler(bus, session_manager=SessionManager(tmp_path / "sessions"), workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -430,7 +442,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -486,7 +498,7 @@ async def test_webui_set_workspace_scope_rejects_running_chat(bus: MagicMock, tm
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("127.0.0.1", 50123)
@@ -545,7 +557,7 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "host": "127.0.0.1"},
bus,
http_handler=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=default_workspace),
)
conn = AsyncMock()
conn.remote_address = ("203.0.113.8", 50123)
@@ -574,7 +586,7 @@ async def test_webui_scope_rejects_non_loopback_custom_scope(bus: MagicMock, tmp
@pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -600,7 +612,7 @@ async def test_send_delivers_json_message_with_media_and_reply() -> None:
@pytest.mark.asyncio
async def test_send_broadcasts_runtime_model_updates() -> None:
bus = MessageBus()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -647,8 +659,8 @@ async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -
return ws_media if channel == "websocket" else media_root
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
monkeypatch.setattr("nanobot.webui.ws_http.get_media_dir", fake_media_dir)
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
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()
channel._attach(mock_ws, "chat-1")
@@ -671,7 +683,7 @@ async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -
@pytest.mark.asyncio
async def test_send_missing_connection_is_noop_without_error() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
msg = OutboundMessage(channel="websocket", chat_id="missing", content="x")
await channel.send(msg)
@@ -679,7 +691,7 @@ async def test_send_missing_connection_is_noop_without_error() -> None:
@pytest.mark.asyncio
async def test_send_removes_connection_on_connection_closed() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._attach(mock_ws, "chat-1")
@@ -694,7 +706,7 @@ async def test_send_removes_connection_on_connection_closed() -> None:
@pytest.mark.asyncio
async def test_send_progress_includes_structured_tool_events() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -742,7 +754,7 @@ async def test_send_progress_includes_structured_tool_events() -> None:
@pytest.mark.asyncio
async def test_send_file_edit_progress_uses_file_edit_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -791,7 +803,7 @@ async def test_send_file_edit_progress_uses_file_edit_event() -> None:
@pytest.mark.asyncio
async def test_send_progress_includes_agent_ui_blob() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -815,7 +827,7 @@ async def test_send_progress_includes_agent_ui_blob() -> None:
@pytest.mark.asyncio
async def test_send_delta_removes_connection_on_connection_closed() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
mock_ws.send.side_effect = ConnectionClosed(Close(1006, ""), Close(1006, ""), True)
channel._attach(mock_ws, "chat-1")
@@ -829,7 +841,7 @@ async def test_send_delta_removes_connection_on_connection_closed() -> None:
@pytest.mark.asyncio
async def test_send_delta_emits_delta_and_stream_end() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -862,11 +874,11 @@ async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch,
return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
monkeypatch.setattr("nanobot.webui.ws_http.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},
bus,
http_handler=_basic_handler(bus, workspace_path=workspace),
gateway=_basic_handler(bus, workspace_path=workspace),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -895,11 +907,11 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
monkeypatch.setattr("nanobot.webui.ws_http.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},
bus,
http_handler=_basic_handler(bus, workspace_path=workspace),
gateway=_basic_handler(bus, workspace_path=workspace),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -919,7 +931,7 @@ async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp
@pytest.mark.asyncio
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -940,7 +952,7 @@ async def test_send_reasoning_delta_emits_streaming_frame() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_end_emits_close_frame() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -956,7 +968,7 @@ async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
the base implementation must produce one delta and one end so the
WebUI sees the same shape either way."""
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -978,7 +990,7 @@ async def test_send_reasoning_one_shot_expands_to_delta_plus_end() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_delta_drops_empty_chunks() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -990,7 +1002,7 @@ async def test_send_reasoning_delta_drops_empty_chunks() -> None:
@pytest.mark.asyncio
async def test_send_reasoning_without_subscribers_is_noop() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
await channel.send_reasoning_delta("unattached", "thinking", None)
await channel.send_reasoning_end("unattached", None)
@@ -1000,7 +1012,7 @@ async def test_send_reasoning_without_subscribers_is_noop() -> None:
@pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -1019,7 +1031,7 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
@pytest.mark.asyncio
async def test_send_turn_end_includes_latency_ms_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -1038,7 +1050,7 @@ async def test_send_turn_end_includes_latency_ms_when_present() -> None:
@pytest.mark.asyncio
async def test_send_turn_end_includes_goal_state_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -1058,7 +1070,7 @@ async def test_send_turn_end_includes_goal_state_when_present() -> None:
@pytest.mark.asyncio
async def test_send_goal_status_running_emits_event_with_started_at() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -1086,7 +1098,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None:
@pytest.mark.asyncio
async def test_send_goal_status_idle_omits_started_at() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -1109,7 +1121,7 @@ async def test_send_goal_status_idle_omits_started_at() -> None:
@pytest.mark.asyncio
async def test_send_goal_state_emits_blob_per_chat() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_a = AsyncMock()
mock_b = AsyncMock()
channel._attach(mock_a, "chat-a")
@@ -1138,10 +1150,9 @@ async def test_send_goal_state_emits_blob_per_chat() -> None:
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_noop_without_session_manager() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._session_manager = None
await channel._maybe_push_active_goal_state("chat-1")
mock_ws.send.assert_not_called()
@@ -1149,10 +1160,13 @@ async def test_maybe_push_active_goal_state_noop_without_session_manager() -> No
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
sm = MagicMock()
sm.read_session_file.return_value = None
channel._session_manager = sm
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sm),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
@@ -1162,7 +1176,6 @@ async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
sm = MagicMock()
sm.read_session_file.return_value = {
"metadata": {
@@ -1174,7 +1187,11 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
},
"messages": [],
}
channel._session_manager = sm
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sm),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
@@ -1190,7 +1207,7 @@ async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk()
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.session import webui_turns as wth
@@ -1203,7 +1220,7 @@ async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> Non
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_replays_running() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.session import webui_turns as wth
@@ -1228,7 +1245,7 @@ async def test_maybe_push_turn_run_wall_clock_replays_running() -> None:
@pytest.mark.asyncio
async def test_send_session_updated_emits_session_updated_event() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -1247,7 +1264,7 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
@pytest.mark.asyncio
async def test_send_session_updated_includes_scope_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
@@ -1266,7 +1283,7 @@ async def test_send_session_updated_includes_scope_when_present() -> None:
@pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
mock_ws.send.side_effect = RuntimeError("unexpected")
channel._attach(mock_ws, "chat-1")
@@ -1279,7 +1296,7 @@ async def test_send_non_connection_closed_exception_is_raised() -> None:
@pytest.mark.asyncio
async def test_send_delta_missing_connection_is_noop() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
# No exception, no error — just a no-op
await channel.send_delta("nonexistent", "chunk", {"_stream_delta": True, "_stream_id": "s1"})
@@ -1287,7 +1304,7 @@ async def test_send_delta_missing_connection_is_noop() -> None:
@pytest.mark.asyncio
async def test_stop_is_idempotent() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, http_handler=_basic_handler(bus))
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus, gateway=_basic_handler(bus))
# stop() before start() should not raise
await channel.stop()
await channel.stop()
@@ -1448,7 +1465,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
)
channel = _ch(bus, port=port)
channel._api_tokens["tok"] = time.monotonic() + 300
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -1733,7 +1750,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None:
port = 29892
channel = _ch(bus, port=port)
channel._api_tokens["tok"] = time.monotonic() + 300
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -1771,7 +1788,7 @@ async def test_bootstrap_exposes_native_surface(bus: MagicMock) -> None:
"websocketRequiresToken": True,
},
bus,
http_handler=_basic_handler(bus, runtime_surface="native", runtime_capabilities_overrides={"can_pick_folder": True}),
gateway=_basic_handler(bus, runtime_surface="native", runtime_capabilities_overrides={"can_pick_folder": True}),
)
server_task = asyncio.create_task(channel.start())
@@ -1941,8 +1958,9 @@ async def test_token_issue_rejects_when_at_capacity(bus: MagicMock) -> None:
try:
# Fill issued tokens to capacity
channel._http.issued_tokens = {
f"nbwt_fill_{i}": time.monotonic() + 300 for i in range(channel._http._MAX_ISSUED_TOKENS)
channel.gateway.tokens.issued_tokens = {
f"nbwt_fill_{i}": time.monotonic() + 300
for i in range(channel.gateway.tokens.max_tokens)
}
resp = await _http_get(
@@ -2299,10 +2317,8 @@ def test_sessions_list_includes_active_run_started_at() -> None:
from nanobot.session import webui_turns as wth
bus = MagicMock()
channel = _ch(bus)
channel._api_tokens["tok"] = time.monotonic() + 300.0
channel._session_manager = MagicMock()
channel._session_manager.list_sessions.return_value = [
session_manager = MagicMock()
session_manager.list_sessions.return_value = [
{
"key": "websocket:chat-1",
"created_at": "2026-05-19T10:00:00Z",
@@ -2317,19 +2333,25 @@ def test_sessions_list_includes_active_run_started_at() -> None:
"updated_at": "2026-05-19T10:01:00Z",
},
]
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=session_manager),
)
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
try:
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
req = Request("/api/sessions", Headers([("Authorization", "Bearer tok")]))
resp = channel._handle_sessions_list(req)
resp = channel.gateway.http._handle_sessions_list(req)
finally:
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
assert resp.status_code == 200
body = json.loads(resp.body.decode())
workspace_scope = body["sessions"][0].pop("workspace_scope")
assert workspace_scope["project_path"] == str(channel._workspace_path)
assert workspace_scope["project_path"] == str(channel.gateway.media.workspace_path)
assert workspace_scope["access_mode"] in {"restricted", "full"}
assert body["sessions"] == [
{
@@ -2376,10 +2398,10 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
append_transcript_object(key, {"event": "user", "chat_id": "c1", "text": "hi"})
bus = MagicMock()
channel = _ch(bus)
channel._api_tokens["tok"] = time.monotonic() + 300.0
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
enc = quote(key, safe="")
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
resp = channel._handle_webui_thread_get(req, enc)
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert body["sessionKey"] == key
@@ -21,7 +21,7 @@ from nanobot.channels.websocket import (
WebSocketConfig,
_extract_data_url_mime,
)
from nanobot.webui.ws_http import GatewayHTTPHandler
from nanobot.webui.gateway_services import build_gateway_services
def _tiny_png_data_url() -> str:
@@ -45,17 +45,18 @@ def _make_channel() -> WebSocketChannel:
bus.publish_inbound = AsyncMock()
cfg = {"enabled": True, "allowFrom": ["*"], "websocketRequiresToken": False}
parsed = WebSocketConfig.model_validate(cfg)
handler = GatewayHTTPHandler(
gateway = build_gateway_services(
config=parsed,
bus=bus,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
default_restrict_to_workspace=False,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
channel = WebSocketChannel(cfg, bus, http_handler=handler)
channel = WebSocketChannel(cfg, bus, gateway=gateway)
channel._handle_message = AsyncMock() # type: ignore[method-assign]
return channel
+22 -21
View File
@@ -13,7 +13,7 @@ import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.ws_http import GatewayHTTPHandler
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
_PORT = 29900
@@ -25,18 +25,19 @@ def _make_handler(
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
runtime_model_name: Any | None = None,
) -> GatewayHTTPHandler:
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = Path.cwd()
return GatewayHTTPHandler(
return build_gateway_services(
config=config,
bus=bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
workspace_path=workspace,
default_restrict_to_workspace=False,
runtime_model_name=runtime_model_name,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
@@ -58,13 +59,13 @@ def _ch(
"websocketRequiresToken": False,
}
cfg.update(extra)
http_handler = _make_handler(
gateway = _make_handler(
cfg, bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
runtime_model_name=runtime_model_name,
)
return WebSocketChannel(cfg, bus, http_handler=http_handler)
return WebSocketChannel(cfg, bus, gateway=gateway)
@pytest.fixture()
@@ -729,20 +730,20 @@ async def test_api_token_pool_purges_expired(bus: MagicMock, tmp_path: Path) ->
channel = _ch(bus, session_manager=sm, port=29908)
# Don't start a server — directly inject and validate.
import time as _time
channel._http.api_tokens["expired"] = _time.monotonic() - 1
channel._http.api_tokens["live"] = _time.monotonic() + 60
channel.gateway.tokens.api_tokens["expired"] = _time.monotonic() - 1
channel.gateway.tokens.api_tokens["live"] = _time.monotonic() + 60
class _FakeReq:
path = "/api/sessions"
headers = {"Authorization": "Bearer expired"}
assert channel._http.check_api_token(_FakeReq()) is False
assert channel.gateway.tokens.check_api_token(_FakeReq()) is False
class _LiveReq:
path = "/api/sessions"
headers = {"Authorization": "Bearer live"}
assert channel._http.check_api_token(_LiveReq()) is True
assert channel.gateway.tokens.check_api_token(_LiveReq()) is True
class _FakeConn:
@@ -797,7 +798,7 @@ def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
resp = channel._handle_bootstrap(
resp = channel.gateway.http._handle_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
@@ -806,7 +807,7 @@ def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
channel = _ch(bus, host="0.0.0.0", token="static-tok")
resp = channel._handle_bootstrap(
resp = channel.gateway.http._handle_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer static-tok"})
)
assert resp.status_code == 200
@@ -816,7 +817,7 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1", port=29931)
resp = channel._handle_bootstrap(
resp = channel.gateway.http._handle_bootstrap(
_LOCAL,
_FakeReq({"Host": "nanobot.example", "X-Forwarded-Proto": "https"}),
)
@@ -827,7 +828,7 @@ def test_bootstrap_ws_url_uses_forwarded_https_host(bus: MagicMock) -> None:
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
@@ -837,7 +838,7 @@ def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytes
lambda: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["model_name"] == "live/model"
@@ -849,7 +850,7 @@ def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeyp
lambda: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["model_name"] == "from-disk"
@@ -865,7 +866,7 @@ def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: p
raise RuntimeError("resolver failed")
channel = _ch(bus, host="127.0.0.1", runtime_model_name=boom)
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["model_name"] == "from-disk"
@@ -873,7 +874,7 @@ def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: p
def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct")
resp = channel._handle_bootstrap(
resp = channel.gateway.http._handle_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer wrong"})
)
assert resp.status_code == 401
@@ -881,7 +882,7 @@ def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_bootstrap(
resp = channel.gateway.http._handle_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer s3cret"})
)
assert resp.status_code == 200
@@ -891,7 +892,7 @@ def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_bootstrap(
resp = channel.gateway.http._handle_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
@@ -900,5 +901,5 @@ def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None:
"""When secret is set, even localhost must provide it (reverse-proxy safety)."""
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 401
+58 -33
View File
@@ -7,19 +7,18 @@ multi-client scenarios, edge cases, and realistic usage patterns.
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
import websockets
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.bus.events import OutboundMessage
from nanobot.webui.ws_http import GatewayHTTPHandler
from ws_test_client import WsTestClient, issue_token, issue_token_ok
from nanobot.bus.events import OutboundMessage
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
def _ch(bus: Any, port: int, **kw: Any) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -32,17 +31,18 @@ def _ch(bus: Any, port: int, **kw: Any) -> WebSocketChannel:
}
cfg.update(kw)
parsed = WebSocketConfig.model_validate(cfg)
handler = GatewayHTTPHandler(
gateway = build_gateway_services(
config=parsed,
bus=bus,
session_manager=None,
static_dist_path=None,
workspace_path=Path.cwd(),
default_restrict_to_workspace=False,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
return WebSocketChannel(cfg, bus, http_handler=handler)
return WebSocketChannel(cfg, bus, gateway=gateway)
@pytest.fixture()
@@ -67,7 +67,8 @@ async def test_ready_event_fields(bus: MagicMock) -> None:
assert len(r.chat_id) == 36
assert r.client_id == "c1"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -80,7 +81,8 @@ async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
r = await c.recv_ready()
assert r.client_id.startswith("anon-")
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -93,7 +95,8 @@ async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29903/", client_id="b") as c2:
assert (await c1.recv_ready()).chat_id != (await c2.recv_ready()).chat_id
finally:
await ch.stop(); await t
await ch.stop()
await t
# -- Inbound messages (client -> server) ----------------------------------
@@ -113,7 +116,8 @@ async def test_plain_text(bus: MagicMock) -> None:
assert inbound.content == "hello world"
assert inbound.sender_id == "p"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -128,7 +132,8 @@ async def test_json_content_field(bus: MagicMock) -> None:
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == "structured"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -146,7 +151,8 @@ async def test_json_text_and_message_fields(bus: MagicMock) -> None:
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == "via message"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -162,7 +168,8 @@ async def test_empty_payload_ignored(bus: MagicMock) -> None:
await asyncio.sleep(0.1)
bus.publish_inbound.assert_not_awaited()
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -179,7 +186,8 @@ async def test_messages_preserve_order(bus: MagicMock) -> None:
contents = [call[0][0].content for call in bus.publish_inbound.call_args_list]
assert contents == [f"msg-{i}" for i in range(5)]
finally:
await ch.stop(); await t
await ch.stop()
await t
# -- Outbound messages (server -> client) ---------------------------------
@@ -199,7 +207,8 @@ async def test_server_send_message(bus: MagicMock) -> None:
msg = await c.recv_message()
assert msg.text == "reply"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -238,7 +247,8 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
prog = await c.recv_message()
assert prog.raw.get("kind") == "progress"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -258,7 +268,8 @@ async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
assert msg.media == ["/tmp/a.png"]
assert msg.reply_to == "m1"
finally:
await ch.stop(); await t
await ch.stop()
await t
# -- Streaming ------------------------------------------------------------
@@ -282,7 +293,8 @@ async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
ends = [m for m in msgs if m.event == "stream_end"]
assert len(ends) == 1
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -306,7 +318,8 @@ async def test_interleaved_streams(bus: MagicMock) -> None:
assert sa == "A1A2"
assert sb == "B1B2"
finally:
await ch.stop(); await t
await ch.stop()
await t
# -- Multi-client ---------------------------------------------------------
@@ -330,7 +343,8 @@ async def test_independent_sessions(bus: MagicMock) -> None:
))
assert (await c2.recv_message()).text == "for-u2"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -348,7 +362,8 @@ async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
))
assert chat_id not in ch._subs
finally:
await ch.stop(); await t
await ch.stop()
await t
# -- Authentication -------------------------------------------------------
@@ -363,7 +378,8 @@ async def test_static_token_accepted(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29915/", client_id="a", token="secret") as c:
assert (await c.recv_ready()).client_id == "a"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -377,7 +393,8 @@ async def test_static_token_rejected(bus: MagicMock) -> None:
pass
assert exc.value.response.status_code == 401
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -411,7 +428,8 @@ async def test_token_issue_full_flow(bus: MagicMock) -> None:
pass
assert exc.value.response.status_code == 401
finally:
await ch.stop(); await t
await ch.stop()
await t
# -- Path routing ---------------------------------------------------------
@@ -426,7 +444,8 @@ async def test_custom_path(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29918/my-chat", client_id="p") as c:
assert (await c.recv_ready()).event == "ready"
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -440,7 +459,8 @@ async def test_wrong_path_404(bus: MagicMock) -> None:
pass
assert exc.value.response.status_code == 404
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -452,7 +472,8 @@ async def test_trailing_slash_normalized(bus: MagicMock) -> None:
async with WsTestClient("ws://127.0.0.1:29920/ws/", client_id="s") as c:
assert (await c.recv_ready()).event == "ready"
finally:
await ch.stop(); await t
await ch.stop()
await t
# -- Edge cases -----------------------------------------------------------
@@ -471,7 +492,8 @@ async def test_large_message(bus: MagicMock) -> None:
await asyncio.sleep(0.2)
assert bus.publish_inbound.call_args[0][0].content == big
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -491,7 +513,8 @@ async def test_unicode_roundtrip(bus: MagicMock) -> None:
))
assert (await c.recv_message()).text == text
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -513,7 +536,8 @@ async def test_rapid_fire(bus: MagicMock) -> None:
received = [(await c.recv_message()).text for _ in range(50)]
assert received == [f"out-{i}" for i in range(50)]
finally:
await ch.stop(); await t
await ch.stop()
await t
@pytest.mark.asyncio
@@ -528,4 +552,5 @@ async def test_invalid_json_as_plain_text(bus: MagicMock) -> None:
await asyncio.sleep(0.1)
assert bus.publish_inbound.call_args[0][0].content == "{broken json"
finally:
await ch.stop(); await t
await ch.stop()
await t
+42 -42
View File
@@ -2,8 +2,8 @@
integration on ``/api/sessions/<key>/messages``.
The route is the return path for images attached to persisted user turns:
:meth:`WebSocketChannel._sign_media_path` mints URLs during session reads,
and :meth:`WebSocketChannel._handle_media_fetch` serves the bytes back.
:meth:`WebSocketChannel.gateway.media.sign_media_path` mints URLs during session reads,
and :meth:`GatewayHTTPHandler._handle_media_fetch` serves the bytes back.
These tests cover the two halves end-to-end plus the adversarial edges
(bad signatures, ``..`` traversal, non-existent files, non-image types).
"""
@@ -22,13 +22,12 @@ import httpx
import pytest
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.media_api import (
b64url_decode,
b64url_encode,
)
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.ws_http import GatewayHTTPHandler
# PNG magic bytes + a couple of sentinel bytes so we can verify byte-for-byte
# round-trip of the served payload. Stays under mimetype + size limits.
@@ -57,17 +56,18 @@ def _ch(
"websocketRequiresToken": False,
}
parsed = WebSocketConfig.model_validate(cfg)
http_handler = GatewayHTTPHandler(
gateway = build_gateway_services(
config=parsed,
bus=bus,
session_manager=session_manager,
static_dist_path=None,
workspace_path=workspace_path or Path.cwd(),
default_restrict_to_workspace=False,
runtime_model_name=None,
runtime_surface="browser",
runtime_capabilities_overrides=None,
bus=bus,
)
return WebSocketChannel(cfg, bus, http_handler=http_handler)
return WebSocketChannel(cfg, bus, gateway=gateway)
@pytest.fixture()
@@ -95,7 +95,7 @@ async def _http_get(
# ---------------------------------------------------------------------------
# _sign_media_path: the URL minter
# gateway.media.sign_media_path: the URL minter
# ---------------------------------------------------------------------------
@@ -114,11 +114,11 @@ def test_sign_media_path_rejects_paths_outside_media_root(
media = tmp_path / "media"
media.mkdir()
channel = _ch(bus, port=0)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
assert channel._sign_media_path(outside) is None
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
assert channel.gateway.media.sign_media_path(outside) is None
# Traversal via the media root is also rejected — the resolve() step
# normalises ``..`` out before the relative_to check.
assert channel._sign_media_path(media / ".." / "secrets" / "cred.txt") is None
assert channel.gateway.media.sign_media_path(media / ".." / "secrets" / "cred.txt") is None
def test_sign_media_path_round_trips_via_hmac(
@@ -129,13 +129,13 @@ def test_sign_media_path_round_trips_via_hmac(
media.mkdir()
(media / "a.png").write_bytes(_PNG_BYTES)
channel = _ch(bus, port=0)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
url = channel._sign_media_path(media / "a.png")
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url = channel.gateway.media.sign_media_path(media / "a.png")
assert url is not None
assert url.startswith("/api/media/")
sig, payload = url[len("/api/media/"):].split("/", 1)
expected = hmac.new(
channel._media_secret, payload.encode("ascii"), hashlib.sha256
channel.gateway.media.secret, payload.encode("ascii"), hashlib.sha256
).digest()[:16]
assert b64url_decode(sig) == expected
# The payload decodes back to the *relative* path — no absolute-path leaks.
@@ -152,8 +152,8 @@ def test_local_markdown_image_is_staged_and_rewritten(
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
with patch("nanobot.webui.ws_http.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel._rewrite_local_markdown_images(
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel.gateway.media.rewrite_local_markdown_images(
"The result:\n![Cloud Architecture Diagram](demo_arch.png)"
)
@@ -174,8 +174,8 @@ def test_local_markdown_video_is_staged_and_rewritten(
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
with patch("nanobot.webui.ws_http.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel._rewrite_local_markdown_images(
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel.gateway.media.rewrite_local_markdown_images(
"The result:\n![nanobot-intro.mp4](nanobot-intro.mp4)"
)
@@ -197,8 +197,8 @@ def test_local_markdown_image_rejects_workspace_escape(
channel = _ch(bus, workspace_path=workspace, port=0)
text = "![nope](../outside.png)"
with patch("nanobot.webui.ws_http.get_media_dir", side_effect=_fake_media_dir(media)):
assert channel._rewrite_local_markdown_images(text) == text
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
assert channel.gateway.media.rewrite_local_markdown_images(text) == text
assert not (media / "websocket").exists()
@@ -219,8 +219,8 @@ async def test_media_route_serves_signed_file(
target.write_bytes(_PNG_BYTES)
channel = _ch(bus, port=29920)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -252,8 +252,8 @@ async def test_media_route_serves_video_byte_ranges(
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29927)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -284,8 +284,8 @@ async def test_media_route_serves_suffix_video_byte_ranges(
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29928)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -313,8 +313,8 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
target.write_bytes(b"0123456789")
channel = _ch(bus, port=29929)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -339,15 +339,15 @@ async def test_media_route_rejects_bad_signature(
"""A payload re-signed with a different secret must 401.
Protects against a restart: old URLs baked into a stale tab become
un-forgeable once ``_media_secret`` regenerates.
un-forgeable once ``gateway.media.secret`` regenerates.
"""
media = tmp_path / "media"
media.mkdir()
(media / "f.png").write_bytes(_PNG_BYTES)
channel = _ch(bus, port=29921)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
good = channel._sign_media_path(media / "f.png")
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
good = channel.gateway.media.sign_media_path(media / "f.png")
assert good is not None
_, payload = good[len("/api/media/"):].split("/", 1)
# Forge a sig with a *different* secret.
@@ -385,11 +385,11 @@ async def test_media_route_rejects_path_traversal_payload(
# Hand-craft a traversal payload the legit signer would refuse to mint.
payload = b64url_encode(b"../secret.txt")
mac = hmac.new(
channel._media_secret, payload.encode("ascii"), hashlib.sha256
channel.gateway.media.secret, payload.encode("ascii"), hashlib.sha256
).digest()[:16]
url = f"/api/media/{b64url_encode(mac)}/{payload}"
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
@@ -413,8 +413,8 @@ async def test_media_route_404s_missing_file(
target.write_bytes(_PNG_BYTES)
channel = _ch(bus, port=29923)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
target.unlink() # the file vanishes between signing and fetching
server_task = asyncio.create_task(channel.start())
@@ -441,10 +441,10 @@ async def test_media_route_degrades_non_image_to_octet_stream(
(media / "scary.html").write_bytes(b"<script>alert(1)</script>")
channel = _ch(bus, port=29924)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
payload = b64url_encode(b"scary.html")
mac = hmac.new(
channel._media_secret, payload.encode("ascii"), hashlib.sha256
channel.gateway.media.secret, payload.encode("ascii"), hashlib.sha256
).digest()[:16]
url = f"/api/media/{b64url_encode(mac)}/{payload}"
server_task = asyncio.create_task(channel.start())
@@ -472,8 +472,8 @@ async def test_media_route_serves_svg_with_strict_csp(
target.write_text("<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>")
channel = _ch(bus, port=29928)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
url_path = channel._sign_media_path(target)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -513,7 +513,7 @@ async def test_session_messages_exposes_signed_media_urls(
sm.save(sess)
channel = _ch(bus, session_manager=sm, port=29925)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
@@ -558,7 +558,7 @@ async def test_session_messages_skips_vanished_media(
sm.save(sess)
channel = _ch(bus, session_manager=sm, port=29926)
with patch("nanobot.webui.ws_http.get_media_dir", return_value=media):
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: