diff --git a/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx b/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx index f91a79fd9..7b6344f6c 100644 --- a/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx +++ b/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx @@ -15,6 +15,7 @@ import type { NanobotFeatureInfo, NanobotFeaturesPayload, } from "@/lib/types"; +import { useClient } from "@/providers/ClientProvider"; import { FeishuConnectFlow } from "./FeishuConnectFlow"; @@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({ return ( void; }) { + const { client } = useClient(); const { t } = useTranslation(); const tx = channelTranslator(t, "feishu"); const [busy, setBusy] = useState(false); @@ -114,7 +115,7 @@ function FeishuInstanceAction({ setError(null); try { onFeaturesUpdate( - await enableNanobotFeature(token, "feishu", { instanceId: instance.id }), + await enableNanobotFeature(client, "feishu", { instanceId: instance.id }), ); } catch (err) { setError((err as Error).message); diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 835100b6f..4af84e78f 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel): self._conn_default: dict[ServerConnection, str] = {} # Connections authenticated with a one-time token from /webui/bootstrap. self._webui_connections: set[ServerConnection] = set() + # Request/reply mutations aren't replayed across reconnects. Tasks may + # finish after a client-side deadline so an already-started mutation + # isn't ambiguously cancelled halfway through. + self._webui_request_tasks: dict[ + tuple[ServerConnection, str], + asyncio.Task[None], + ] = {} self._stop_event: asyncio.Event | None = None self._server_task: asyncio.Task[None] | None = None @@ -758,6 +765,9 @@ class WebSocketChannel(BaseChannel): ) -> None: """Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``).""" t = envelope.get("type") + if t == "webui_request": + await self._start_webui_request(connection, envelope) + return if t == "new_chat": new_id = str(uuid.uuid4()) scope = await self._workspace_scope_or_error( @@ -1105,6 +1115,152 @@ class WebSocketChannel(BaseChannel): return await self._send_event(connection, "error", detail=f"unknown type: {t!r}") + async def _start_webui_request( + self, + connection: ServerConnection, + envelope: dict[str, Any], + ) -> None: + request_id = envelope.get("request_id") + if not isinstance(request_id, str) or re.fullmatch( + r"[A-Za-z0-9._:-]{1,128}", + request_id, + ) is None: + await self._send_event( + connection, + "error", + detail="invalid webui request_id", + ) + return + if connection not in self._webui_connections: + await self._send_webui_response( + connection, + request_id, + status=403, + message="access_denied", + ) + return + + action = envelope.get("action") + payload = envelope.get("payload") + if not isinstance(action, str) or re.fullmatch( + r"[a-z][a-z0-9_.]{0,127}", + action, + ) is None: + await self._send_webui_response( + connection, + request_id, + status=400, + message="invalid WebUI mutation action", + ) + return + if not isinstance(payload, dict): + await self._send_webui_response( + connection, + request_id, + status=400, + message="WebUI mutation payload must be an object", + ) + return + + key = (connection, request_id) + if key in self._webui_request_tasks: + await self._send_webui_response( + connection, + request_id, + status=409, + message="duplicate WebUI request_id", + ) + return + task = asyncio.create_task( + self._complete_webui_request( + connection, + request_id, + action, + cast(dict[str, Any], payload), + ) + ) + self._webui_request_tasks[key] = task + + async def _complete_webui_request( + self, + connection: ServerConnection, + request_id: str, + action: str, + payload: dict[str, Any], + ) -> None: + try: + response = await self._http_router.dispatch_webui_mutation( + connection, + action, + payload, + ) + status = response.status_code + body = bytes(response.body).decode("utf-8", errors="replace").strip() + if 200 <= status < 300: + try: + result = json.loads(body) + except json.JSONDecodeError: + await self._send_webui_response( + connection, + request_id, + status=502, + message="WebUI mutation returned an invalid response", + ) + return + await self._send_webui_response( + connection, + request_id, + result=result, + ) + return + await self._send_webui_response( + connection, + request_id, + status=status, + message=body or response.reason_phrase, + ) + except asyncio.CancelledError: + raise + except Exception: + self.logger.exception("WebUI mutation '{}' failed", action) + await self._send_webui_response( + connection, + request_id, + status=500, + message="WebUI mutation failed", + ) + finally: + self._webui_request_tasks.pop((connection, request_id), None) + + async def _send_webui_response( + self, + connection: ServerConnection, + request_id: str, + *, + result: Any = None, + status: int | None = None, + message: str | None = None, + ) -> None: + if status is None: + await self._send_event( + connection, + "webui_response", + request_id=request_id, + ok=True, + result=result, + ) + return + await self._send_event( + connection, + "webui_response", + request_id=request_id, + ok=False, + error={ + "status": status, + "message": message or "WebUI mutation failed", + }, + ) + async def _workspace_scope_or_error( self, connection: ServerConnection, @@ -1145,6 +1301,12 @@ class WebSocketChannel(BaseChannel): except Exception as e: self.logger.warning("server task error during shutdown: {}", e) self._server_task = None + mutation_tasks = tuple(self._webui_request_tasks.values()) + for task in mutation_tasks: + task.cancel() + if mutation_tasks: + await asyncio.gather(*mutation_tasks, return_exceptions=True) + self._webui_request_tasks.clear() self._subs.clear() self._conn_chats.clear() self._conn_default.clear() diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index cb99981ec..72da015cb 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -3,12 +3,16 @@ import asyncio import json import time +import uuid from pathlib import Path +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock +import httpx import pytest import websockets +from websockets.datastructures import Headers from websockets.exceptions import ConnectionClosed from websockets.frames import Close @@ -42,6 +46,12 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY 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 ( + http_error as _http_error, +) +from nanobot.webui.http_utils import ( + http_json_response as _http_json_response, +) from nanobot.webui.http_utils import ( issue_route_secret_matches as _issue_route_secret_matches, ) @@ -119,6 +129,38 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices: ) +async def _webui_mutate( + client: Any, + action: str, + payload: dict[str, Any] | None = None, +) -> httpx.Response: + request_id = f"test-{uuid.uuid4().hex}" + await client.send(json.dumps({ + "type": "webui_request", + "request_id": request_id, + "action": action, + "payload": payload or {}, + })) + while True: + envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5)) + if envelope.get("event") != "webui_response": + continue + if envelope.get("request_id") != request_id: + continue + if envelope.get("ok") is True: + status = 200 + body = envelope.get("result") + else: + error = envelope.get("error") or {} + status = int(error.get("status") or 500) + body = {"error": str(error.get("message") or "WebUI mutation failed")} + return httpx.Response( + status, + json=body, + request=httpx.Request("WS", "http://nanobot.local/webui-mutation"), + ) + + @pytest.mark.asyncio async def test_stop_treats_cancelled_server_task_as_shutdown() -> None: channel = _ch(MessageBus()) @@ -857,6 +899,98 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None: assert client_connection not in channel._webui_connections +@pytest.mark.asyncio +async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None: + channel = _ch(bus) + conn = AsyncMock() + channel._webui_connections.add(conn) + channel.gateway.http.dispatch_webui_mutation = AsyncMock( + return_value=_http_json_response({"saved": True}) + ) + + + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "webui_request", + "request_id": "request-1", + "action": "settings.provider.update", + "payload": {"provider": "openrouter", "apiKey": "secret"}, + }, + ) + await asyncio.gather(*tuple(channel._webui_request_tasks.values())) + + channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with( + conn, + "settings.provider.update", + {"provider": "openrouter", "apiKey": "secret"}, + ) + assert json.loads(conn.send.await_args.args[0]) == { + "event": "webui_response", + "request_id": "request-1", + "ok": True, + "result": {"saved": True}, + } + + +@pytest.mark.asyncio +async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None: + channel = _ch(bus) + conn = AsyncMock() + channel._webui_connections.add(conn) + channel.gateway.http.dispatch_webui_mutation = AsyncMock( + return_value=_http_error(400, "invalid settings payload") + ) + + await channel._dispatch_envelope( + conn, + "webui-client", + { + "type": "webui_request", + "request_id": "request-2", + "action": "settings.agent.update", + "payload": {}, + }, + ) + await asyncio.gather(*tuple(channel._webui_request_tasks.values())) + + assert json.loads(conn.send.await_args.args[0]) == { + "event": "webui_response", + "request_id": "request-2", + "ok": False, + "error": {"status": 400, "message": "invalid settings payload"}, + } + + +@pytest.mark.asyncio +async def test_webui_request_requires_bootstrap_authenticated_connection( + bus: MagicMock, +) -> None: + channel = _ch(bus) + conn = AsyncMock() + channel.gateway.http.dispatch_webui_mutation = AsyncMock() + + await channel._dispatch_envelope( + conn, + "static-token-client", + { + "type": "webui_request", + "request_id": "request-3", + "action": "settings.agent.update", + "payload": {}, + }, + ) + + channel.gateway.http.dispatch_webui_mutation.assert_not_awaited() + assert json.loads(conn.send.await_args.args[0]) == { + "event": "webui_response", + "request_id": "request-3", + "ok": False, + "error": {"status": 403, "message": "access_denied"}, + } + + @pytest.mark.asyncio async def test_webui_persists_sidebar_state_larger_than_http_request_line( bus: MagicMock, @@ -866,23 +1000,33 @@ async def test_webui_persists_sidebar_state_larger_than_http_request_line( monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) channel = _ch(bus) conn = AsyncMock() + conn.request = SimpleNamespace(headers=Headers()) channel._webui_connections.add(conn) session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)] + request_id = "sidebar-large-state" envelope = { - "type": "set_sidebar_state", - "state": { + "type": "webui_request", + "request_id": request_id, + "action": "sidebar.update", + "payload": {"state": { "session_order": session_order, "view": {"sort": "manual"}, - }, + }}, } assert len(json.dumps(envelope).encode()) > 8_192 await channel._dispatch_envelope(conn, "webui-client", envelope) + await asyncio.gather(*tuple(channel._webui_request_tasks.values())) saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8")) assert saved["session_order"] == session_order assert saved["view"]["sort"] == "manual" - conn.send.assert_not_awaited() + assert json.loads(conn.send.await_args.args[0]) == { + "event": "webui_response", + "request_id": request_id, + "ok": True, + "result": saved, + } @pytest.mark.asyncio @@ -2887,7 +3031,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) + webui_client = None try: + webui_token = channel.gateway.tokens.issue_token(300, audience="webui") + webui_client = await websockets.connect( + f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test" + ) + ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5)) + assert ready["event"] == "ready" + settings = await _http_get( f"http://127.0.0.1:{port}/api/settings", headers={"Authorization": "Bearer tok"}, @@ -2971,11 +3123,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert unknown_api.status_code == 404 assert "" not in unknown_api.text.lower() - provider_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/provider/update?provider=openrouter" - "&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1", - headers={"Authorization": "Bearer tok"}, + provider_updated = await _webui_mutate( + webui_client, + "settings.provider.update", + { + "provider": "openrouter", + "apiKey": "sk-or-test", + "apiBase": "https://openrouter.ai/api/v1", + }, ) assert provider_updated.status_code == 200 provider_body = provider_updated.json() @@ -2985,22 +3140,18 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert provider_body["image_generation"]["provider_configured"] is True assert "sk-or-test" not in provider_updated.text - custom_provider_created = await _http_get( - f"http://127.0.0.1:{port}/api/settings/provider/create", - headers={ - "Authorization": "Bearer tok", - "X-Nanobot-Provider-Values": json.dumps( - { - "name": "Company Gateway", - "apiBase": "https://gateway.example/v1", - "apiKey": "sk-company", - "extraHeaders": json.dumps({"X-Tenant": "engineering"}), - "extraBody": json.dumps({"service_tier": "priority"}), - "extraQuery": json.dumps({"api-version": "2026-01-01"}), - "proxy": "http://127.0.0.1:7890", - "thinkingStyle": "enable_thinking", - } - ), + custom_provider_created = await _webui_mutate( + webui_client, + "settings.provider.create", + { + "name": "Company Gateway", + "apiBase": "https://gateway.example/v1", + "apiKey": "sk-company", + "extraHeaders": json.dumps({"X-Tenant": "engineering"}), + "extraBody": json.dumps({"service_tier": "priority"}), + "extraQuery": json.dumps({"api-version": "2026-01-01"}), + "proxy": "http://127.0.0.1:7890", + "thinkingStyle": "enable_thinking", }, ) assert custom_provider_created.status_code == 200 @@ -3015,11 +3166,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( } assert "sk-company" not in custom_provider_created.text - local_provider_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/provider/update?provider=atomic_chat" - "&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1", - headers={"Authorization": "Bearer tok"}, + local_provider_updated = await _webui_mutate( + webui_client, + "settings.provider.update", + {"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"}, ) assert local_provider_updated.status_code == 200 local_provider_body = local_provider_updated.json() @@ -3029,38 +3179,44 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert local_provider_rows["atomic_chat"]["configured"] is True assert "localhost:1337" in local_provider_updated.text - updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/update?model=atomic_chat/test" - "&provider=atomic_chat&timezone=Asia%2FShanghai" - "&bot_name=Nano&bot_icon=N&tool_hint_max_length=120", - headers={"Authorization": "Bearer tok"}, + updated = await _webui_mutate( + webui_client, + "settings.agent.update", + { + "model": "atomic_chat/test", + "provider": "atomic_chat", + "timezone": "Asia/Shanghai", + "tool_hint_max_length": 120, + }, ) assert updated.status_code == 200 updated_body = updated.json() assert updated_body["requires_restart"] is True assert updated_body["restart_required_sections"] == ["runtime"] - preset_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/update?model_preset=deep", - headers={"Authorization": "Bearer tok"}, + preset_updated = await _webui_mutate( + webui_client, + "settings.agent.update", + {"model_preset": "deep"}, ) assert preset_updated.status_code == 200 assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5" - bad_preset = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/update?model_preset=missing", - headers={"Authorization": "Bearer tok"}, + bad_preset = await _webui_mutate( + webui_client, + "settings.agent.update", + {"model_preset": "missing"}, ) assert bad_preset.status_code == 400 - created_preset = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/model-configurations/create" - "?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini", - headers={"Authorization": "Bearer tok"}, + created_preset = await _webui_mutate( + webui_client, + "settings.model_configuration.create", + { + "label": "Fast writing", + "provider": "openai", + "model": "openai/gpt-4.1-mini", + }, ) assert created_preset.status_code == 200 created_body = created_preset.json() @@ -3074,11 +3230,15 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert created_presets["fast-writing"]["label"] == "Fast writing" assert created_presets["fast-writing"]["provider"] == "openai" - updated_preset = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/model-configurations/update" - "?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5", - headers={"Authorization": "Bearer tok"}, + updated_preset = await _webui_mutate( + webui_client, + "settings.model_configuration.update", + { + "name": "fast-writing", + "label": "Codex", + "provider": "openai", + "model": "openai/gpt-5.5", + }, ) assert updated_preset.status_code == 200 updated_preset_body = updated_preset.json() @@ -3089,11 +3249,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( } assert updated_presets["fast-writing"]["label"] == "Codex" - call_order_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/model-call-order/update" - "?order=%5B%22fast-writing%22%2C%22deep%22%5D", - headers={"Authorization": "Bearer tok"}, + call_order_updated = await _webui_mutate( + webui_client, + "settings.model_call_order.update", + {"order": ["fast-writing", "deep"]}, ) assert call_order_updated.status_code == 200 call_order_body = call_order_updated.json() @@ -3101,20 +3260,27 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert call_order_body["agent"]["model"] == "openai/gpt-5.5" assert call_order_body["model_call_order"] == ["fast-writing", "deep"] - duplicate_preset = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/model-configurations/create" - "?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini", - headers={"Authorization": "Bearer tok"}, + duplicate_preset = await _webui_mutate( + webui_client, + "settings.model_configuration.create", + { + "label": "Fast writing", + "provider": "openai", + "model": "openai/gpt-4.1-mini", + }, ) assert duplicate_preset.status_code == 409 - search_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/web-search/update?provider=searxng" - "&base_url=https%3A%2F%2Fsearch.example.com" - "&max_results=8&timeout=45&use_jina_reader=false", - headers={"Authorization": "Bearer tok"}, + search_updated = await _webui_mutate( + webui_client, + "settings.web_search.update", + { + "provider": "searxng", + "base_url": "https://search.example.com", + "max_results": 8, + "timeout": 45, + "use_jina_reader": False, + }, ) assert search_updated.status_code == 200 search_body = search_updated.json() @@ -3126,10 +3292,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert search_body["web_search"]["max_results"] == 8 assert search_body["web"]["fetch"]["use_jina_reader"] is False - network_safety_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full", - headers={"Authorization": "Bearer tok"}, + network_safety_updated = await _webui_mutate( + webui_client, + "settings.network_safety.update", + { + "webui_allow_local_service_access": False, + "webui_default_access_mode": "full", + }, ) assert network_safety_updated.status_code == 200 network_safety_body = network_safety_updated.json() @@ -3139,13 +3308,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert network_safety_body["advanced"]["webui_default_access_mode"] == "full" assert network_safety_body["advanced"]["private_service_protection_enabled"] is True - image_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/image-generation/update?enabled=true" - "&provider=openrouter&model=openai%2Fgpt-image-1" - "&default_aspect_ratio=16%3A9&default_image_size=2K" - "&max_images_per_turn=3", - headers={"Authorization": "Bearer tok"}, + image_updated = await _webui_mutate( + webui_client, + "settings.image_generation.update", + { + "enabled": True, + "provider": "openrouter", + "model": "openai/gpt-image-1", + "default_aspect_ratio": "16:9", + "default_image_size": "2K", + "max_images_per_turn": 3, + }, ) assert image_updated.status_code == 200 image_body = image_updated.json() @@ -3157,11 +3330,14 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert image_body["image_generation"]["default_image_size"] == "2K" assert image_body["image_generation"]["max_images_per_turn"] == 3 - image_provider_updated = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/provider/update?provider=openrouter" - "&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1", - headers={"Authorization": "Bearer tok"}, + image_provider_updated = await _webui_mutate( + webui_client, + "settings.provider.update", + { + "provider": "openrouter", + "apiKey": "sk-or-next", + "apiBase": "https://openrouter.ai/api/v1", + }, ) assert image_provider_updated.status_code == 200 assert image_provider_updated.json()["requires_restart"] is True @@ -3169,17 +3345,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert "sk-or-next" not in image_provider_updated.text assert image_reload.await_count == 2 - bad_web = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99", - headers={"Authorization": "Bearer tok"}, + bad_web = await _webui_mutate( + webui_client, + "settings.web_search.update", + {"provider": "duckduckgo", "max_results": 99}, ) assert bad_web.status_code == 400 - bad_image = await _http_get( - "http://127.0.0.1:" - f"{port}/api/settings/image-generation/update?provider=missing", - headers={"Authorization": "Bearer tok"}, + bad_image = await _webui_mutate( + webui_client, + "settings.image_generation.update", + {"provider": "missing"}, ) assert bad_image.status_code == 400 @@ -3216,6 +3392,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert saved.tools.image_generation.default_image_size == "2K" assert saved.tools.image_generation.max_images_per_turn == 3 finally: + if webui_client is not None: + await webui_client.close() await channel.stop() await server_task @@ -3248,11 +3426,17 @@ async def test_image_settings_hot_reload_without_restart( channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300 server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) + webui_client = None try: - response = await _http_get( - f"http://127.0.0.1:{port}/api/settings/image-generation/update" - "?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1", - headers={"Authorization": "Bearer tok"}, + webui_token = channel.gateway.tokens.issue_token(300, audience="webui") + webui_client = await websockets.connect( + f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test" + ) + assert json.loads(await webui_client.recv())["event"] == "ready" + response = await _webui_mutate( + webui_client, + "settings.image_generation.update", + {"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"}, ) assert response.status_code == 200 @@ -3260,6 +3444,8 @@ async def test_image_settings_hot_reload_without_restart( assert response.json()["restart_required_sections"] == [] image_reload.assert_awaited_once_with(bus) finally: + if webui_client is not None: + await webui_client.close() await channel.stop() await server_task @@ -3291,17 +3477,25 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails( channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300 server_task = asyncio.create_task(channel.start()) await asyncio.sleep(0.3) + webui_client = None try: - response = await _http_get( - f"http://127.0.0.1:{port}/api/settings/image-generation/update" - "?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1", - headers={"Authorization": "Bearer tok"}, + webui_token = channel.gateway.tokens.issue_token(300, audience="webui") + webui_client = await websockets.connect( + f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test" + ) + assert json.loads(await webui_client.recv())["event"] == "ready" + response = await _webui_mutate( + webui_client, + "settings.image_generation.update", + {"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"}, ) assert response.status_code == 200 assert response.json()["requires_restart"] is True assert response.json()["restart_required_sections"] == ["image"] finally: + if webui_client is not None: + await webui_client.close() await channel.stop() await server_task diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 61f755550..564537e68 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -9,8 +9,8 @@ from contextlib import suppress from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock -from urllib.parse import quote, urlencode +import httpx import pytest from nanobot.bus.events import OutboundMessage @@ -614,9 +614,10 @@ async def test_webui_skill_management_routes( try: token = channel.gateway.tokens.issue_api_token(300) headers = {"Authorization": f"Bearer {token}"} - update_response = await _http_get( - f"http://127.0.0.1:{port}/api/webui/skills/update" - "?name=custom-skill&enabled=false", + update_response = await _webui_mutate( + channel, + "skill.update", + {"name": "custom-skill", "enabled": False}, headers=headers, ) assert update_response.status_code == 200 @@ -628,9 +629,10 @@ async def test_webui_skill_management_routes( ) assert custom["enabled"] is False - delete_response = await _http_get( - f"http://127.0.0.1:{port}/api/webui/skills/delete" - "?name=custom-skill", + delete_response = await _webui_mutate( + channel, + "skill.delete", + {"name": "custom-skill"}, headers=headers, ) assert delete_response.status_code == 200 @@ -751,12 +753,10 @@ async def test_webui_skills_marketplace_routes_search_and_install( } trends.assert_awaited_once_with(["acme/agent-skills/react-testing"]) - params = urlencode({ - "source": "acme/agent-skills", - "skill": "react-testing", - }) - install_response = await _http_get( - f"http://127.0.0.1:{port}/api/webui/skills/install?{params}", + install_response = await _webui_mutate( + channel, + "skill.install", + {"source": "acme/agent-skills", "skill": "react-testing"}, headers=headers, ) assert install_response.status_code == 200 @@ -810,25 +810,22 @@ async def test_webui_skill_install_rejects_overlapping_requests( workspace_path=tmp_path, port=_free_port(), ) - token = channel.gateway.tokens.issue_api_token(300) - path = ( - "/api/webui/skills/install" - "?source=acme%2Fagent-skills&skill=react-testing" + first = asyncio.create_task( + _webui_mutate( + channel, + "skill.install", + {"source": "acme/agent-skills", "skill": "react-testing"}, + ) ) - request = _FakeReq( - { - "Authorization": f"Bearer {token}", - "Host": "127.0.0.1:8765", - }, - path=path, - ) - - first = asyncio.create_task(channel.gateway.http.dispatch(_LOCAL, request)) await started.wait() - overlapping = await channel.gateway.http.dispatch(_LOCAL, request) + overlapping = await _webui_mutate( + channel, + "skill.install", + {"source": "acme/agent-skills", "skill": "react-testing"}, + ) assert overlapping.status_code == 409 - assert "already in progress" in overlapping.body.decode() + assert "already in progress" in overlapping.text assert install_mock.await_count == 1 finish.set() @@ -854,17 +851,15 @@ async def test_webui_skill_delete_remains_local_only( workspace_path=tmp_path, port=_free_port(), ) - token = channel.gateway.tokens.issue_api_token(300) - response = await channel.gateway.http.dispatch( - _REMOTE, - _FakeReq( - {"Authorization": f"Bearer {token}"}, - path="/api/webui/skills/delete?name=custom-skill", - ), + response = await _webui_mutate( + channel, + "skill.delete", + {"name": "custom-skill"}, + connection=_REMOTE, ) assert response.status_code == 403 - assert "remote skill deletion is disabled" in response.body.decode() + assert "remote skill deletion is disabled" in response.text delete.assert_not_called() @@ -904,20 +899,15 @@ async def test_webui_skill_install_honors_remote_install_opt_in( workspace_path=tmp_path, port=_free_port(), ) - token = channel.gateway.tokens.issue_api_token(300) - response = await channel.gateway.http.dispatch( - _REMOTE, - _FakeReq( - {"Authorization": f"Bearer {token}"}, - path=( - "/api/webui/skills/install" - "?source=acme%2Fagent-skills&skill=react-testing" - ), - ), + response = await _webui_mutate( + channel, + "skill.install", + {"source": "acme/agent-skills", "skill": "react-testing"}, + connection=_REMOTE, ) assert response.status_code == 200 - assert json.loads(response.body.decode())["last_action"]["name"] == "react-testing" + assert response.json()["last_action"]["name"] == "react-testing" @pytest.mark.asyncio @@ -979,9 +969,10 @@ async def test_cli_apps_routes_require_token_and_return_payload( assert catalog.status_code == 200 assert catalog.json()["apps"][0]["name"] == "gimp" - installed = await _http_get( - "http://127.0.0.1:29912/api/settings/cli-apps/install?name=gimp", - headers=auth, + installed = await _webui_mutate( + channel, + "settings.cli_app.install", + {"name": "gimp"}, ) assert installed.status_code == 200 assert installed.json()["last_action"]["message"] == "install:gimp" @@ -1017,26 +1008,29 @@ async def test_nanobot_feature_routes_require_token_and_enable( assert features["websocket"]["enabled"] is True assert features["websocket"]["ready"] is True - enabled = await _http_get( - "http://127.0.0.1:29916/api/settings/nanobot-features/enable?name=matrix", - headers=auth, + enabled = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, ) assert enabled.status_code == 200 body = enabled.json() assert body["last_action"]["message"] == "Enabled channel 'matrix'" assert body["restart_required_sections"] == ["runtime"] - disabled_websocket = await _http_get( - "http://127.0.0.1:29916/api/settings/nanobot-features/disable?name=websocket", - headers=auth, + disabled_websocket = await _webui_mutate( + channel, + "settings.feature.disable", + {"name": "websocket"}, ) assert disabled_websocket.status_code == 400 assert "cannot be disabled from WebUI" in disabled_websocket.text assert "websocket" not in json.loads(config_path.read_text(encoding="utf-8"))["channels"] - disabled = await _http_get( - "http://127.0.0.1:29916/api/settings/nanobot-features/disable?name=matrix", - headers=auth, + disabled = await _webui_mutate( + channel, + "settings.feature.disable", + {"name": "matrix"}, ) assert disabled.status_code == 200 body = disabled.json() @@ -1158,43 +1152,41 @@ async def test_pairing_routes_require_token_and_approve_or_deny( assert body["requests"][0]["created_at_ms"] == 1_000_000 assert body["requests"][0]["expires_at_ms"] == 1_600_000 - approved_response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq(auth, path="/api/settings/pairing/approve?code=ABCD-EFGH"), - "/api/settings/pairing/approve", + approved_response = await _webui_mutate( + channel, + "settings.pairing.approve", + {"code": "ABCD-EFGH"}, ) - assert approved_response is not None assert approved_response.status_code == 200 - body = json.loads(approved_response.body.decode()) + body = approved_response.json() assert body["last_action"]["action"] == "approve" assert body["last_action"]["sender_id"] == "ou_123" assert approved == ["ABCD-EFGH"] - denied_action = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq(auth, path="/api/settings/pairing/deny?code=ABCD-EFGH"), - "/api/settings/pairing/deny", + denied_action = await _webui_mutate( + channel, + "settings.pairing.deny", + {"code": "ABCD-EFGH"}, ) - assert denied_action is not None assert denied_action.status_code == 200 - assert json.loads(denied_action.body.decode())["last_action"]["action"] == "deny" + assert denied_action.json()["last_action"]["action"] == "deny" assert denied == ["ABCD-EFGH"] - missing_code = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq(auth, path="/api/settings/pairing/approve"), - "/api/settings/pairing/approve", + missing_code = await _webui_mutate( + channel, + "settings.pairing.approve", ) - assert missing_code is not None assert missing_code.status_code == 400 - assert "Missing pairing code" in missing_code.body.decode() + assert "Missing pairing code" in missing_code.text -def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) -> None: +def test_api_service_settings_read_api_key_from_webui_payload(bus: MagicMock) -> None: channel = _ch(bus) - request = _FakeReq( - {"X-Nanobot-API-Service-Values": json.dumps({"api_key": "secret-token"})}, - path="/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120", + request = _FakeReq(path="/api/settings/api-service/start") + setattr( + request, + "_nanobot_webui_mutation_payload", + {"host": "0.0.0.0", "port": 8900, "timeout": 120, "api_key": "secret-token"}, ) query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request) @@ -1207,25 +1199,20 @@ def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) - } -def test_api_service_settings_reject_invalid_private_header(bus: MagicMock) -> None: +def test_api_service_settings_reject_non_string_api_key(bus: MagicMock) -> None: from nanobot.webui.settings_api import WebUISettingsError channel = _ch(bus) - request = _FakeReq( - {"X-Nanobot-API-Service-Values": json.dumps({"api_key": 123})}, - path="/api/settings/api-service/start?host=127.0.0.1", + request = _FakeReq(path="/api/settings/api-service/start") + setattr( + request, + "_nanobot_webui_mutation_payload", + {"host": "127.0.0.1", "api_key": 123}, ) with pytest.raises(WebUISettingsError, match="API key must be a string"): channel.gateway.http.settings_routes._parse_api_service_settings_query(request) - query_secret = _FakeReq( - path="/api/settings/api-service/start?host=127.0.0.1&api_key=secret-token", - ) - with pytest.raises(WebUISettingsError, match="private header"): - channel.gateway.http.settings_routes._parse_api_service_settings_query(query_secret) - - @pytest.mark.asyncio async def test_nanobot_feature_remote_install_requires_opt_in( bus: MagicMock, @@ -1242,19 +1229,15 @@ async def test_nanobot_feature_remote_install_requires_opt_in( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - path = "/api/settings/nanobot-features/enable?name=matrix" - request = _FakeReq({"Authorization": f"Bearer {token}"}, path=path) - - blocked = await channel.gateway.http.settings_routes.dispatch( - _REMOTE, - request, - "/api/settings/nanobot-features/enable", + blocked = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, + connection=_REMOTE, ) - assert blocked is not None assert blocked.status_code == 403 - assert "remote WebUI is disabled" in blocked.body.decode() + assert "remote WebUI is disabled" in blocked.text assert install_calls == [] config_path.write_text( @@ -1262,13 +1245,13 @@ async def test_nanobot_feature_remote_install_requires_opt_in( encoding="utf-8", ) - allowed = await channel.gateway.http.settings_routes.dispatch( - _REMOTE, - request, - "/api/settings/nanobot-features/enable", + allowed = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, + connection=_REMOTE, ) - assert allowed is not None assert allowed.status_code == 200 assert install_calls == ["matrix"] @@ -1289,19 +1272,12 @@ async def test_nanobot_feature_local_install_allowed_by_default( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - request = _FakeReq( - {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}, - path="/api/settings/nanobot-features/enable?name=matrix", + response = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, ) - response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - request, - "/api/settings/nanobot-features/enable", - ) - - assert response is not None assert response.status_code == 200 assert install_calls == ["matrix"] assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][ @@ -1338,21 +1314,14 @@ async def test_nanobot_feature_channel_action_can_apply_without_restart( port=_free_port(), channel_feature_action=channel_feature_action, ) - token = channel.gateway.tokens.issue_api_token(300) - request = _FakeReq( - {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}, - path="/api/settings/nanobot-features/enable?name=matrix", + response = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, ) - response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - request, - "/api/settings/nanobot-features/enable", - ) - - assert response is not None assert response.status_code == 200 - body = json.loads(response.body.decode()) + body = response.json() assert calls == [("enable", "matrix", None)] assert body["requires_restart"] is False assert body["restart_required_sections"] == [] @@ -1380,19 +1349,14 @@ async def test_channel_connect_runtime_import_error_is_not_reported_as_unsupport lambda _name: FakePlugin(), ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq( - {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}, - path="/api/settings/channels/fake/connect/start", - ), - "/api/settings/channels/fake/connect/start", + response = await _webui_mutate( + channel, + "settings.channel.connect.start", + {"channel": "fake"}, ) - assert response is not None assert response.status_code == 500 - assert "failed to start fake connection" in response.body.decode() + assert "failed to start fake connection" in response.text @pytest.mark.asyncio @@ -1474,37 +1438,26 @@ async def test_feishu_connect_routes_write_config_and_hot_reload( port=_free_port(), channel_feature_action=channel_feature_action, ) - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"} - - started = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq( - auth, - path="/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default", - ), - "/api/settings/channels/feishu/connect/start", + started = await _webui_mutate( + channel, + "settings.channel.connect.start", + {"channel": "feishu", "domain": "feishu", "instance_id": "default"}, ) - assert started is not None assert started.status_code == 200 - start_body = json.loads(started.body.decode()) + start_body = started.json() assert start_body["status"] == "pending" assert start_body["instance_id"] == "default" assert start_body["qr_url"].startswith("https://accounts.feishu.cn/") - polled = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq( - auth, - path=f"/api/settings/channels/feishu/connect/poll?session_id={start_body['session_id']}", - ), - "/api/settings/channels/feishu/connect/poll", + polled = await _webui_mutate( + channel, + "settings.channel.connect.poll", + {"channel": "feishu", "session_id": start_body["session_id"]}, ) - assert polled is not None assert polled.status_code == 200 - body = json.loads(polled.body.decode()) + body = polled.json() assert body["status"] == "succeeded" assert body["instance_id"] == "default" assert "app_secret" not in body @@ -1664,32 +1617,25 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads( port=_free_port(), channel_feature_action=channel_feature_action, ) - token = channel.gateway.tokens.issue_api_token(300) - response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq( - { - "Authorization": f"Bearer {token}", - "Host": "127.0.0.1:8765", - "X-Nanobot-Channel-Values": json.dumps( - { - "channels.discord.token": "discord-token", - "channels.discord.allowChannels": "123, 456", - "channels.discord.groupPolicy": "open", - } - ), + response = await _webui_mutate( + channel, + "settings.channel.configure", + { + "name": "discord", + "enable": True, + "values": { + "channels.discord.token": "discord-token", + "channels.discord.allowChannels": "123, 456", + "channels.discord.groupPolicy": "open", }, - path="/api/settings/channels/configure?name=discord&enable=true", - ), - "/api/settings/channels/configure", + }, ) - assert response is not None assert response.status_code == 200 - body = json.loads(response.body.decode()) + body = response.json() assert body["saved"] is True assert body["name"] == "discord" - assert "discord-token" not in response.body.decode() + assert "discord-token" not in response.text assert calls == [("enable", "discord", "default")] assert body["nanobot_features"]["requires_restart"] is False data = json.loads(config_path.read_text(encoding="utf-8")) @@ -1728,28 +1674,20 @@ async def test_channel_configure_route_preserves_existing_channel_values( monkeypatch.setattr(loader, "_current_config_path", config_path) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq( - { - "Authorization": f"Bearer {token}", - "Host": "127.0.0.1:8765", - "X-Nanobot-Channel-Values": json.dumps( - { - "channels.discord.token": "", - "channels.discord.allowChannels": "new-channel", - } - ), + response = await _webui_mutate( + channel, + "settings.channel.configure", + { + "name": "discord", + "values": { + "channels.discord.token": "", + "channels.discord.allowChannels": "new-channel", }, - path="/api/settings/channels/configure?name=discord", - ), - "/api/settings/channels/configure", + }, ) - assert response is not None assert response.status_code == 200 - body = json.loads(response.body.decode()) + body = response.json() assert body["saved_keys"] == ["channels.discord.allowChannels"] discord = next( feature @@ -1794,26 +1732,18 @@ async def test_channel_configure_route_saves_matrix_device_id_without_replacing_ monkeypatch.setattr(loader, "_current_config_path", config_path) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq( - { - "Authorization": f"Bearer {token}", - "Host": "127.0.0.1:8765", - "X-Nanobot-Channel-Values": json.dumps( - { - "channels.matrix.accessToken": "", - "channels.matrix.deviceId": "DEVICE-ID", - } - ), + response = await _webui_mutate( + channel, + "settings.channel.configure", + { + "name": "matrix", + "values": { + "channels.matrix.accessToken": "", + "channels.matrix.deviceId": "DEVICE-ID", }, - path="/api/settings/channels/configure?name=matrix", - ), - "/api/settings/channels/configure", + }, ) - assert response is not None assert response.status_code == 200 data = json.loads(config_path.read_text(encoding="utf-8")) assert data["channels"]["matrix"]["accessToken"] == "saved-token" @@ -1834,27 +1764,19 @@ async def test_channel_configure_route_saves_mattermost_setup( monkeypatch.setattr(loader, "_current_config_path", config_path) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - response = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - _FakeReq( - { - "Authorization": f"Bearer {token}", - "Host": "127.0.0.1:8765", - "X-Nanobot-Channel-Values": json.dumps( - { - "channels.mattermost.serverUrl": "https://chat.example.com", - "channels.mattermost.token": "mattermost-token", - "channels.mattermost.teamId": "platform", - } - ), + response = await _webui_mutate( + channel, + "settings.channel.configure", + { + "name": "mattermost", + "values": { + "channels.mattermost.serverUrl": "https://chat.example.com", + "channels.mattermost.token": "mattermost-token", + "channels.mattermost.teamId": "platform", }, - path="/api/settings/channels/configure?name=mattermost", - ), - "/api/settings/channels/configure", + }, ) - assert response is not None assert response.status_code == 200 data = json.loads(config_path.read_text(encoding="utf-8")) assert data["channels"]["mattermost"] == { @@ -1880,23 +1802,17 @@ async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - request = _FakeReq( - { - "Authorization": f"Bearer {token}", - "Host": "nanobot.example", - "X-Forwarded-For": "203.0.113.42", - }, - path="/api/settings/nanobot-features/enable?name=matrix", + forwarded_headers = { + "Host": "nanobot.example", + "X-Forwarded-For": "203.0.113.42", + } + blocked = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, + headers=forwarded_headers, ) - blocked = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - request, - "/api/settings/nanobot-features/enable", - ) - - assert blocked is not None assert blocked.status_code == 403 assert install_calls == [] @@ -1905,13 +1821,13 @@ async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in( encoding="utf-8", ) - allowed = await channel.gateway.http.settings_routes.dispatch( - _LOCAL, - request, - "/api/settings/nanobot-features/enable", + allowed = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, + headers=forwarded_headers, ) - assert allowed is not None assert allowed.status_code == 200 assert install_calls == ["matrix"] @@ -1932,19 +1848,13 @@ async def test_nanobot_feature_remote_enable_without_install_is_allowed( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - request = _FakeReq( - {"Authorization": f"Bearer {token}"}, - path="/api/settings/nanobot-features/enable?name=matrix", + response = await _webui_mutate( + channel, + "settings.feature.enable", + {"name": "matrix"}, + connection=_REMOTE, ) - response = await channel.gateway.http.settings_routes.dispatch( - _REMOTE, - request, - "/api/settings/nanobot-features/enable", - ) - - assert response is not None assert response.status_code == 200 assert install_calls == [] assert json.loads(config_path.read_text(encoding="utf-8"))["channels"]["matrix"][ @@ -1966,19 +1876,13 @@ async def test_nanobot_feature_remote_disable_does_not_need_install_policy( _stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"], installed=False) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_api_token(300) - request = _FakeReq( - {"Authorization": f"Bearer {token}"}, - path="/api/settings/nanobot-features/disable?name=matrix", + response = await _webui_mutate( + channel, + "settings.feature.disable", + {"name": "matrix"}, + connection=_REMOTE, ) - response = await channel.gateway.http.settings_routes.dispatch( - _REMOTE, - request, - "/api/settings/nanobot-features/disable", - ) - - assert response is not None assert response.status_code == 200 data = json.loads(config_path.read_text(encoding="utf-8")) assert data["channels"]["matrix"]["enabled"] is False @@ -2150,14 +2054,10 @@ async def test_mcp_presets_routes_require_token_and_return_payload( assert catalog.status_code == 200 assert catalog.json()["presets"][0]["name"] == "browserbase" - enabled = await _http_get( - "http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase", - headers={ - **auth, - "X-Nanobot-MCP-Values": json.dumps( - {"browserbase_api_key": "bb_live_secret"} - ), - }, + enabled = await _webui_mutate( + channel, + "settings.mcp.enable", + {"name": "browserbase", "browserbase_api_key": "bb_live_secret"}, ) assert enabled.status_code == 200 assert preset_queries[-1][1]["browserbase_api_key"] == ["bb_live_secret"] @@ -2167,40 +2067,27 @@ async def test_mcp_presets_routes_require_token_and_return_payload( assert body["hot_reload"]["ok"] is True assert body["restart_required_sections"] == [] - bad_header = await _http_get( - "http://127.0.0.1:29913/api/settings/mcp-presets/enable?name=browserbase", - headers={**auth, "X-Nanobot-MCP-Values": "[]"}, - ) - assert bad_header.status_code == 400 - - custom = await _http_get( - "http://127.0.0.1:29913/api/settings/mcp-presets/custom", - headers={ - **auth, - "X-Nanobot-MCP-Values": json.dumps( - {"name": "docs", "command": "npx"} - ), - }, + custom = await _webui_mutate( + channel, + "settings.mcp.custom", + {"name": "docs", "command": "npx"}, ) assert custom.status_code == 200 assert custom_queries[-1][1]["command"] == ["npx"] assert custom.json()["last_action"]["message"] == "custom:docs MCP config reloaded." - imported = await _http_get( - "http://127.0.0.1:29913/api/settings/mcp-presets/import", - headers={**auth, "X-Nanobot-MCP-Values": json.dumps({"config": "{}"})}, + imported = await _webui_mutate( + channel, + "settings.mcp.import", + {"config": "{}"}, ) assert imported.status_code == 200 assert imported.json()["last_action"]["message"] == "import:config MCP config reloaded." - tools = await _http_get( - "http://127.0.0.1:29913/api/settings/mcp-presets/tools", - headers={ - **auth, - "X-Nanobot-MCP-Values": json.dumps( - {"name": "docs", "enabled_tools": []} - ), - }, + tools = await _webui_mutate( + channel, + "settings.mcp.tools", + {"name": "docs", "enabled_tools": []}, ) assert tools.status_code == 200 assert tools.json()["last_action"]["message"] == "tools:docs MCP config reloaded." @@ -2291,10 +2178,10 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped( "title_overrides": {"websocket:sidebar": "Pinned work"}, "view": {"density": "compact", "show_archived": True}, } - query = urlencode({"state": json.dumps(payload)}) - updated = await _http_get( - f"http://127.0.0.1:29911/api/webui/sidebar-state/update?{query}", - headers=auth, + updated = await _webui_mutate( + channel, + "sidebar.update", + {"state": payload}, ) assert updated.status_code == 200 body = updated.json() @@ -2325,16 +2212,14 @@ async def test_session_delete_removes_file( channel = _ch(bus, session_manager=sm, port=29903) server_task = asyncio.create_task(channel.start()) try: - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}"} - path = sm._get_session_path("websocket:doomed") assert path.exists() webui_path = tmp_path / "webui" / f"{SessionManager.safe_key('websocket:doomed')}.jsonl" assert webui_path.is_file() - resp = await _http_get( - "http://127.0.0.1:29903/api/sessions/websocket:doomed/delete", - headers=auth, + resp = await _webui_mutate( + channel, + "session.delete", + {"key": "websocket:doomed"}, ) assert resp.status_code == 200 assert resp.json()["deleted"] is True @@ -2436,21 +2321,20 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[external_job.id]["origin"]["preview"] == "" assert by_id["heartbeat"]["protected"] is True - updated = await _http_get( - f"{base_url}/api/webui/automations/update?id={user_job.id}", - headers={ - **auth, - "X-Nanobot-Automation-Values": json.dumps( - { - "name": "Daily quiz", - "message": "Ask the daily quiz", - "schedule": { - "kind": "cron", - "expr": "0 9 * * *", - "tz": "UTC", - }, - } - ), + updated = await _webui_mutate( + channel, + "automation.update", + { + "id": user_job.id, + "values": { + "name": "Daily quiz", + "message": "Ask the daily quiz", + "schedule": { + "kind": "cron", + "expr": "0 9 * * *", + "tz": "UTC", + }, + }, }, ) assert updated.status_code == 200 @@ -2461,128 +2345,125 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[user_job.id]["schedule"]["expr"] == "0 9 * * *" assert by_id[user_job.id]["schedule"]["tz"] == "UTC" - unicode_update = await _http_get( - f"{base_url}/api/webui/automations/update?id={user_job.id}", - headers={ - **auth, - "X-Nanobot-Automation-Values": quote( - json.dumps( - { - "name": "每日测验", - "message": "问今日测验", - }, - ensure_ascii=False, - ), - safe="", - ), + unicode_update = await _webui_mutate( + channel, + "automation.update", + { + "id": user_job.id, + "values": {"name": "每日测验", "message": "问今日测验"}, }, ) assert unicode_update.status_code == 200 assert cron.get_job(user_job.id).name == "每日测验" assert cron.get_job(user_job.id).payload.message == "问今日测验" - malformed_update = await _http_get( - f"{base_url}/api/webui/automations/update?id={user_job.id}", - headers={ - **auth, - "X-Nanobot-Automation-Values": json.dumps({"message": ["bad"]}), - }, + malformed_update = await _webui_mutate( + channel, + "automation.update", + {"id": user_job.id, "values": {"message": ["bad"]}}, ) assert malformed_update.status_code == 400 assert cron.get_job(user_job.id).payload.message == "问今日测验" - invalid_cron_update = await _http_get( - f"{base_url}/api/webui/automations/update?id={user_job.id}", - headers={ - **auth, - "X-Nanobot-Automation-Values": json.dumps( - {"schedule": {"kind": "cron", "expr": "not a cron", "tz": "UTC"}} - ), + invalid_cron_update = await _webui_mutate( + channel, + "automation.update", + { + "id": user_job.id, + "values": { + "schedule": {"kind": "cron", "expr": "not a cron", "tz": "UTC"} + }, }, ) assert invalid_cron_update.status_code == 400 assert cron.get_job(user_job.id).schedule.expr == "0 9 * * *" - past_one_shot_update = await _http_get( - f"{base_url}/api/webui/automations/update?id={past_one_shot_job.id}", - headers={ - **auth, - "X-Nanobot-Automation-Values": json.dumps( - { - "message": "Updated one-shot message", - "schedule": {"kind": "at", "at_ms": 1}, - } - ), + past_one_shot_update = await _webui_mutate( + channel, + "automation.update", + { + "id": past_one_shot_job.id, + "values": { + "message": "Updated one-shot message", + "schedule": {"kind": "at", "at_ms": 1}, + }, }, ) assert past_one_shot_update.status_code == 200 assert cron.get_job(past_one_shot_job.id).payload.message == "Updated one-shot message" assert cron.get_job(past_one_shot_job.id).schedule.at_ms == 1 - protected_update = await _http_get( - f"{base_url}/api/webui/automations/update?id=heartbeat", - headers={ - **auth, - "X-Nanobot-Automation-Values": json.dumps({"name": "bad"}), - }, + protected_update = await _webui_mutate( + channel, + "automation.update", + {"id": "heartbeat", "values": {"name": "bad"}}, ) assert protected_update.status_code == 403 - disabled = await _http_get( - f"{base_url}/api/webui/automations/disable?id={user_job.id}", - headers=auth, + disabled = await _webui_mutate( + channel, + "automation.disable", + {"id": user_job.id}, ) assert disabled.status_code == 200 by_id = {job["id"]: job for job in disabled.json()["jobs"]} assert by_id[user_job.id]["enabled"] is False - disabled_run = await _http_get( - f"{base_url}/api/webui/automations/run?id={user_job.id}", - headers=auth, + disabled_run = await _webui_mutate( + channel, + "automation.run", + {"id": user_job.id}, ) assert disabled_run.status_code == 409 - unbound_run = await _http_get( - f"{base_url}/api/webui/automations/run?id={incomplete_job.id}", - headers=auth, + unbound_run = await _webui_mutate( + channel, + "automation.run", + {"id": incomplete_job.id}, ) assert unbound_run.status_code == 409 assert "no linked chat" in unbound_run.text - unbound_enable = await _http_get( - f"{base_url}/api/webui/automations/enable?id={incomplete_job.id}", - headers=auth, + unbound_enable = await _webui_mutate( + channel, + "automation.enable", + {"id": incomplete_job.id}, ) assert unbound_enable.status_code == 409 assert "no linked chat" in unbound_enable.text - protected_delete = await _http_get( - f"{base_url}/api/webui/automations/delete?id=heartbeat", - headers=auth, + protected_delete = await _webui_mutate( + channel, + "automation.delete", + {"id": "heartbeat"}, ) assert protected_delete.status_code == 403 - protected_disable = await _http_get( - f"{base_url}/api/webui/automations/disable?id=heartbeat", - headers=auth, + protected_disable = await _webui_mutate( + channel, + "automation.disable", + {"id": "heartbeat"}, ) assert protected_disable.status_code == 403 - protected_run = await _http_get( - f"{base_url}/api/webui/automations/run?id=heartbeat", - headers=auth, + protected_run = await _webui_mutate( + channel, + "automation.run", + {"id": "heartbeat"}, ) assert protected_run.status_code == 403 - enabled = await _http_get( - f"{base_url}/api/webui/automations/enable?id={user_job.id}", - headers=auth, + enabled = await _webui_mutate( + channel, + "automation.enable", + {"id": user_job.id}, ) assert enabled.status_code == 200 by_id = {job["id"]: job for job in enabled.json()["jobs"]} assert by_id[user_job.id]["enabled"] is True - deleted = await _http_get( - f"{base_url}/api/webui/automations/delete?id={user_job.id}", - headers=auth, + deleted = await _webui_mutate( + channel, + "automation.delete", + {"id": user_job.id}, ) assert deleted.status_code == 200 assert user_job.id not in {job["id"] for job in deleted.json()["jobs"]} @@ -2629,46 +2510,45 @@ async def test_webui_automations_route_manages_local_triggers( assert by_id[trigger.id]["payload"]["message"] == "Review queued PR" assert by_id[trigger.id]["trigger"]["command"] == f'nanobot trigger {trigger.id} "message"' - disabled = await _http_get( - f"{base_url}/api/webui/automations/disable?id={trigger.id}", - headers=auth, + disabled = await _webui_mutate( + channel, + "automation.disable", + {"id": trigger.id}, ) assert disabled.status_code == 200 stored = trigger_store.get(trigger.id) assert stored is not None assert stored.enabled is False - run = await _http_get( - f"{base_url}/api/webui/automations/run?id={trigger.id}", - headers=auth, + run = await _webui_mutate( + channel, + "automation.run", + {"id": trigger.id}, ) assert run.status_code == 409 assert "CLI message" in run.text - renamed = await _http_get( - f"{base_url}/api/webui/automations/update?id={trigger.id}", - headers={ - **auth, - "X-Nanobot-Automation-Values": json.dumps({"name": "Release review"}), - }, + renamed = await _webui_mutate( + channel, + "automation.update", + {"id": trigger.id, "values": {"name": "Release review"}}, ) assert renamed.status_code == 200 stored = trigger_store.get(trigger.id) assert stored is not None assert stored.name == "Release review" - bad_update = await _http_get( - f"{base_url}/api/webui/automations/update?id={trigger.id}", - headers={ - **auth, - "X-Nanobot-Automation-Values": json.dumps({"message": "coupled"}), - }, + bad_update = await _webui_mutate( + channel, + "automation.update", + {"id": trigger.id, "values": {"message": "coupled"}}, ) assert bad_update.status_code == 400 - deleted = await _http_get( - f"{base_url}/api/webui/automations/delete?id={trigger.id}", - headers=auth, + deleted = await _webui_mutate( + channel, + "automation.delete", + {"id": trigger.id}, ) assert deleted.status_code == 200 assert trigger_store.get(trigger.id) is None @@ -2696,13 +2576,11 @@ async def test_session_delete_blocks_when_bound_automation_exists( channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915) server_task = asyncio.create_task(channel.start()) try: - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}"} - path = sm._get_session_path("websocket:doomed") - resp = await _http_get( - "http://127.0.0.1:29915/api/sessions/websocket:doomed/delete", - headers=auth, + resp = await _webui_mutate( + channel, + "session.delete", + {"key": "websocket:doomed"}, ) assert resp.status_code == 200 @@ -2723,7 +2601,6 @@ async def test_session_delete_blocks_and_cascades_local_triggers( ) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) port = _free_port() - base_url = f"http://127.0.0.1:{port}" sm = _seed_session(tmp_path, key="websocket:doomed") trigger_store = LocalTriggerStore(tmp_path) trigger = trigger_store.create( @@ -2740,20 +2617,19 @@ async def test_session_delete_blocks_and_cascades_local_triggers( ) server_task = asyncio.create_task(channel.start()) try: - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}"} - - blocked = await _http_get( - f"{base_url}/api/sessions/websocket:doomed/delete", - headers=auth, + blocked = await _webui_mutate( + channel, + "session.delete", + {"key": "websocket:doomed"}, ) assert blocked.status_code == 200 assert blocked.json()["blocked_by_automations"] is True assert trigger_store.get(trigger.id) is not None - deleted = await _http_get( - f"{base_url}/api/sessions/websocket:doomed/delete?delete_automations=true", - headers=auth, + deleted = await _webui_mutate( + channel, + "session.delete", + {"key": "websocket:doomed", "delete_automations": True}, ) assert deleted.status_code == 200 assert deleted.json()["deleted"] is True @@ -2788,13 +2664,11 @@ async def test_session_delete_can_cascade_bound_automations( channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916) server_task = asyncio.create_task(channel.start()) try: - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}"} - path = sm._get_session_path("websocket:doomed") - resp = await _http_get( - "http://127.0.0.1:29916/api/sessions/websocket:doomed/delete?delete_automations=true", - headers=auth, + resp = await _webui_mutate( + channel, + "session.delete", + {"key": "websocket:doomed", "delete_automations": True}, ) assert resp.status_code == 200 @@ -2830,13 +2704,11 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled( ) server_task = asyncio.create_task(channel.start()) try: - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}"} - path = sm._get_session_path("websocket:doomed") - resp = await _http_get( - "http://127.0.0.1:29918/api/sessions/websocket:doomed/delete", - headers=auth, + resp = await _webui_mutate( + channel, + "session.delete", + {"key": "websocket:doomed"}, ) assert resp.status_code == 200 @@ -2854,21 +2726,19 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled( @pytest.mark.asyncio -async def test_session_delete_accepts_percent_encoded_websocket_keys( +async def test_session_delete_action_accepts_websocket_keys( bus: MagicMock, tmp_path: Path ) -> None: sm = _seed_session(tmp_path, key="websocket:encoded-key") channel = _ch(bus, session_manager=sm, port=29910) server_task = asyncio.create_task(channel.start()) try: - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}"} - path = sm._get_session_path("websocket:encoded-key") assert path.exists() - deleted = await _http_get( - "http://127.0.0.1:29910/api/sessions/websocket%3Aencoded-key/delete", - headers=auth, + deleted = await _webui_mutate( + channel, + "session.delete", + {"key": "websocket:encoded-key"}, ) assert deleted.status_code == 200 assert deleted.json()["deleted"] is True @@ -3100,10 +2970,17 @@ async def test_session_delete_rejects_non_websocket_keys( doomed = sm._get_session_path("slack:C123") assert doomed.exists() - deny_delete = await _http_get( + get_delete = await _http_get( "http://127.0.0.1:29909/api/sessions/slack:C123/delete", headers=auth, ) + assert get_delete.status_code == 405 + + deny_delete = await _webui_mutate( + channel, + "session.delete", + {"key": "slack:C123"}, + ) assert deny_delete.status_code == 404 assert doomed.exists() finally: @@ -3119,14 +2996,12 @@ async def test_session_delete_rejects_invalid_key( channel = _ch(bus, session_manager=sm, port=29904) server_task = asyncio.create_task(channel.start()) try: - token = channel.gateway.tokens.issue_api_token(300) - auth = {"Authorization": f"Bearer {token}"} - # Invalid characters in the key -> regex match fails -> 404 # (route doesn't match, falls through to channel 404). - resp = await _http_get( - "http://127.0.0.1:29904/api/sessions/bad%20key/delete", - headers=auth, + resp = await _webui_mutate( + channel, + "session.delete", + {"key": "bad key"}, ) assert resp.status_code in {400, 404} finally: @@ -3243,6 +3118,31 @@ _NO_HEADERS = _FakeReq() _LOCAL_BROWSER_REQ = _FakeReq({"Host": "127.0.0.1:8765"}) +async def _webui_mutate( + channel: WebSocketChannel, + action: str, + payload: dict[str, Any] | None = None, + *, + headers: dict[str, str] | None = None, + connection: _FakeConn = _LOCAL, +) -> httpx.Response: + request_headers = {"Host": "127.0.0.1:8765", **(headers or {})} + mutation_connection = _FakeConn(connection.remote_address) + mutation_connection.request = _FakeReq(request_headers) + response = await channel.gateway.http.dispatch_webui_mutation( + mutation_connection, + action, + payload or {}, + ) + request = httpx.Request("GET", "http://127.0.0.1/webui-mutation") + return httpx.Response( + response.status_code, + headers=list(response.headers.raw_items()), + content=response.body, + request=request, + ) + + def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None: from nanobot.webui.http_utils import is_local_browser_request diff --git a/nanobot/channels/weixin/webui/WeixinPanel.tsx b/nanobot/channels/weixin/webui/WeixinPanel.tsx index 6d3c55637..8bf3e2ecc 100644 --- a/nanobot/channels/weixin/webui/WeixinPanel.tsx +++ b/nanobot/channels/weixin/webui/WeixinPanel.tsx @@ -27,6 +27,7 @@ import type { NanobotFeatureInfo, } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { useClient } from "@/providers/ClientProvider"; import { WEIXIN_AUTH_EXPIRED_MESSAGE, @@ -64,6 +65,7 @@ export function WeixinPanel({ onAction, onFeaturesUpdate, }: ChannelPluginPanelProps) { + const { client } = useClient(); const { t, i18n } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const channelTx = channelTranslator(t, "weixin"); @@ -150,7 +152,7 @@ export function WeixinPanel({ setSaveState("idle"); try { const payload = await configureChannel( - context.token, + client, "weixin", channelValuesForSave(editableFieldsRef.current, values), { enable: context.enabled }, @@ -168,7 +170,7 @@ export function WeixinPanel({ } finally { setSaving(false); } - }, []); + }, [client]); useEffect(() => { if ( diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 98cab4e7c..ee9760104 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -13,7 +13,6 @@ import json import time from collections.abc import Callable from typing import Any, cast -from urllib.parse import unquote from websockets.http11 import Request as WsRequest from websockets.http11 import Response @@ -40,7 +39,6 @@ from nanobot.optional_features import ( ) from nanobot.pairing import approve_code, deny_code, list_pending from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload -from nanobot.webui.http_utils import case_insensitive_header from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request from nanobot.webui.http_utils import query_first as _query_first from nanobot.webui.mcp_presets_api import mcp_presets_settings_action @@ -76,17 +74,8 @@ from nanobot.webui.version_check import check_for_update QueryParams = dict[str, list[str]] -_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values" -_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024 -_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values" -_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024 -_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values" -_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024 -_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values" -_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024 -_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code" -_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback" -_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024 +_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload" +_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" _SKIP_FIELD = object() _CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"}) @@ -112,6 +101,63 @@ _MCP_PRESET_ACTIONS_BY_PATH = { "/api/settings/mcp-presets/tools": "tools", } +_SETTINGS_MUTATION_PATHS = frozenset({ + "/api/settings/update", + "/api/settings/model-configurations/create", + "/api/settings/model-configurations/update", + "/api/settings/model-configurations/delete", + "/api/settings/model-configurations/migrate", + "/api/settings/model-call-order/update", + "/api/settings/provider/update", + "/api/settings/provider/create", + "/api/settings/provider/oauth-login", + "/api/settings/provider/oauth-login/complete", + "/api/settings/provider/oauth-logout", + "/api/settings/web-search/update", + "/api/settings/api-service/start", + "/api/settings/api-service/stop", + "/api/settings/image-generation/update", + "/api/settings/transcription/update", + "/api/settings/network-safety/update", + "/api/settings/cli-apps/install", + "/api/settings/cli-apps/update", + "/api/settings/cli-apps/uninstall", + "/api/settings/cli-apps/test", + "/api/settings/nanobot-features/enable", + "/api/settings/nanobot-features/disable", + "/api/settings/channels/validate", + "/api/settings/channels/configure", + "/api/settings/pairing/approve", + "/api/settings/pairing/deny", + *_MCP_PRESET_ACTIONS_BY_PATH, +}) + + +def _mutation_payload(request: WsRequest) -> dict[str, Any] | None: + payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None) + if not isinstance(payload, dict): + return None + return cast(dict[str, Any], payload) + + +def _query_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + return str(value) + + +def _payload_query(payload: dict[str, Any]) -> QueryParams: + return { + key: [_query_value(value)] + for key, value in payload.items() + if key + and key not in {"authorization_response", "channel", "values"} + } + class WebUISettingsRouter: """Route WebUI Settings HTTP requests behind a transport-neutral boundary.""" @@ -144,6 +190,15 @@ class WebUISettingsRouter: self._channel_connectors: dict[str, Any] = {} async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None: + if self.is_mutation_path(path) and not getattr( + request, + _WEBUI_MUTATION_REQUEST_ATTR, + False, + ): + return self._error_response( + 405, + "WebUI mutations require an authenticated WebSocket", + ) if path == "/api/settings": return self._handle_settings(request) if path == "/api/settings/usage": @@ -230,7 +285,17 @@ class WebUISettingsRouter: return await self._handle_settings_mcp_presets(request, mcp_action) return None + @staticmethod + def is_mutation_path(path: str) -> bool: + return ( + path in _SETTINGS_MUTATION_PATHS + or _channel_connect_route(path) is not None + ) + def _query(self, request: WsRequest) -> QueryParams: + payload = _mutation_payload(request) + if payload is not None: + return _payload_query(payload) return self._parse_query(request.path) def _authorized(self, request: WsRequest) -> bool: @@ -260,63 +325,10 @@ class WebUISettingsRouter: ) def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams: - query = self._query(request) - raw = request.headers.get(_MCP_VALUES_HEADER) - if not raw: - return query - if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES: - raise WebUISettingsError("MCP settings payload is too large") - try: - payload = json.loads(raw) - except json.JSONDecodeError as exc: - raise WebUISettingsError("invalid MCP settings payload") from exc - if not isinstance(payload, dict): - raise WebUISettingsError("MCP settings payload must be a JSON object") - payload = cast(dict[object, Any], payload) - merged = {key: list(values) for key, values in query.items()} - for key, value in payload.items(): - if not isinstance(key, str) or not key: - raise WebUISettingsError("MCP settings payload contains an invalid key") - if value is None: - continue - if isinstance(value, str): - text = value.strip() - else: - text = json.dumps(value, ensure_ascii=False, separators=(",", ":")) - if text: - merged[key] = [text] - return merged + return self._query(request) def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams: - query = self._query(request) - raw = request.headers.get(_PROVIDER_VALUES_HEADER) - if not raw: - return query - if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES: - raise WebUISettingsError("provider settings payload is too large") - try: - payload = json.loads(raw) - except json.JSONDecodeError as exc: - try: - payload = json.loads(unquote(raw)) - except json.JSONDecodeError: - raise WebUISettingsError("invalid provider settings payload") from exc - if not isinstance(payload, dict): - raise WebUISettingsError("provider settings payload must be a JSON object") - payload = cast(dict[object, Any], payload) - - merged = {key: list(values) for key, values in query.items()} - for key, value in payload.items(): - if not isinstance(key, str) or not key: - raise WebUISettingsError("provider settings payload contains an invalid key") - if isinstance(value, str): - text = value - elif value is None: - text = "" - else: - text = json.dumps(value, ensure_ascii=False, separators=(",", ":")) - merged[key] = [text] - return merged + return self._query(request) def _handle_settings(self, request: WsRequest) -> Response: if not self._authorized(request): @@ -472,18 +484,12 @@ class WebUISettingsRouter: if action == "login": payload = await asyncio.to_thread(login_oauth_provider, query) elif action == "complete": - authorization_response = case_insensitive_header( - request.headers, - _OAUTH_CALLBACK_HEADER, - ) or case_insensitive_header( - request.headers, - _OAUTH_CODE_HEADER, + raw_response = (_mutation_payload(request) or {}).get( + "authorization_response" ) - if ( - len(authorization_response.encode("utf-8")) - > _OAUTH_RESPONSE_HEADER_MAX_BYTES - ): - raise WebUISettingsError("OAuth authorization response is too large") + if raw_response is not None and not isinstance(raw_response, str): + raise WebUISettingsError("OAuth authorization response must be a string") + authorization_response = raw_response payload = await asyncio.to_thread( complete_oauth_provider, query, @@ -549,33 +555,12 @@ class WebUISettingsRouter: return self._json_response(self._api_service_payload(last_action="started")) def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams: - query = self._query(request) - if "api_key" in query or "apiKey" in query: - raise WebUISettingsError("API service API key must be provided in the private header") - raw = request.headers.get(_API_SERVICE_VALUES_HEADER) - if not raw: - return query - if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES: - raise WebUISettingsError("API service settings payload is too large") - try: - payload = json.loads(raw) - except json.JSONDecodeError as exc: - raise WebUISettingsError("invalid API service settings payload") from exc - if not isinstance(payload, dict): - raise WebUISettingsError("API service settings payload must be a JSON object") - payload = cast(dict[str, Any], payload) - - unknown = set(payload) - {"api_key"} - if unknown: - raise WebUISettingsError("API service settings payload contains an invalid key") - api_key = payload.get("api_key") - if api_key is not None and not isinstance(api_key, str): - raise WebUISettingsError("API service API key must be a string") - - merged = {key: list(values) for key, values in query.items() if key != "api_key"} - if api_key is not None: - merged["api_key"] = [api_key] - return merged + payload = _mutation_payload(request) + if payload is not None: + api_key = payload.get("api_key") + if api_key is not None and not isinstance(api_key, str): + raise WebUISettingsError("API service API key must be a string") + return self._query(request) async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response: if not self._authorized(request): @@ -850,7 +835,7 @@ class WebUISettingsRouter: saved = await asyncio.to_thread( self._save_channel_config_values, name, - self._parse_channel_values_header(request), + self._parse_channel_values(request), instance_id, ) except WebUISettingsError as e: @@ -906,7 +891,7 @@ class WebUISettingsRouter: payload = await asyncio.to_thread( validate_channel_config, name, - self._parse_channel_values_header(request), + self._parse_channel_values(request), instance_id=instance_id, ) except WebUISettingsError as e: @@ -916,19 +901,14 @@ class WebUISettingsRouter: return self._error_response(500, "failed to validate channel settings") return self._json_response(payload) - def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]: - raw = request.headers.get(_CHANNEL_VALUES_HEADER) - if not raw: + def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]: + payload = _mutation_payload(request) + if payload is None or "values" not in payload: return {} - if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES: - raise WebUISettingsError("channel settings payload is too large") - try: - payload = json.loads(raw) - except json.JSONDecodeError as exc: - raise WebUISettingsError("invalid channel settings payload") from exc - if not isinstance(payload, dict): + values = payload.get("values") + if not isinstance(values, dict): raise WebUISettingsError("channel settings payload must be a JSON object") - return cast(dict[str, Any], payload) + return cast(dict[str, Any], values) def _save_channel_config_values( self, diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 09cdfcd27..90148c371 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -17,9 +17,10 @@ import time from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Any, cast -from urllib.parse import unquote +from urllib.parse import quote, unquote from loguru import logger +from websockets.datastructures import Headers from websockets.http11 import Request as WsRequest from websockets.http11 import Response @@ -118,7 +119,60 @@ from nanobot.webui.transcript import build_webui_thread_response from nanobot.webui.workspaces import WebUIWorkspaceController _SLOW_WEBUI_HTTP_LOG_MS = 1_000 -_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values" +_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload" +_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request" + +_WEBUI_MUTATION_PATHS = { + "automation.enable": "/api/webui/automations/enable", + "automation.disable": "/api/webui/automations/disable", + "automation.delete": "/api/webui/automations/delete", + "automation.run": "/api/webui/automations/run", + "automation.update": "/api/webui/automations/update", + "skill.install": "/api/webui/skills/install", + "skill.update": "/api/webui/skills/update", + "skill.delete": "/api/webui/skills/delete", + "sidebar.update": "/api/webui/sidebar-state/update", + "settings.agent.update": "/api/settings/update", + "settings.model_configuration.create": "/api/settings/model-configurations/create", + "settings.model_configuration.update": "/api/settings/model-configurations/update", + "settings.model_configuration.delete": "/api/settings/model-configurations/delete", + "settings.model_configuration.migrate": "/api/settings/model-configurations/migrate", + "settings.model_call_order.update": "/api/settings/model-call-order/update", + "settings.provider.update": "/api/settings/provider/update", + "settings.provider.create": "/api/settings/provider/create", + "settings.provider.oauth_login": "/api/settings/provider/oauth-login", + "settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete", + "settings.provider.oauth_logout": "/api/settings/provider/oauth-logout", + "settings.web_search.update": "/api/settings/web-search/update", + "settings.api_service.start": "/api/settings/api-service/start", + "settings.api_service.stop": "/api/settings/api-service/stop", + "settings.image_generation.update": "/api/settings/image-generation/update", + "settings.transcription.update": "/api/settings/transcription/update", + "settings.network_safety.update": "/api/settings/network-safety/update", + "settings.cli_app.install": "/api/settings/cli-apps/install", + "settings.cli_app.update": "/api/settings/cli-apps/update", + "settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall", + "settings.cli_app.test": "/api/settings/cli-apps/test", + "settings.feature.enable": "/api/settings/nanobot-features/enable", + "settings.feature.disable": "/api/settings/nanobot-features/disable", + "settings.channel.validate": "/api/settings/channels/validate", + "settings.channel.configure": "/api/settings/channels/configure", + "settings.pairing.approve": "/api/settings/pairing/approve", + "settings.pairing.deny": "/api/settings/pairing/deny", + "settings.mcp.enable": "/api/settings/mcp-presets/enable", + "settings.mcp.remove": "/api/settings/mcp-presets/remove", + "settings.mcp.test": "/api/settings/mcp-presets/test", + "settings.mcp.custom": "/api/settings/mcp-presets/custom", + "settings.mcp.import": "/api/settings/mcp-presets/import", + "settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor", + "settings.mcp.tools": "/api/settings/mcp-presets/tools", +} + +_WEBUI_CHANNEL_CONNECT_ACTIONS = { + "settings.channel.connect.start": "start", + "settings.channel.connect.poll": "poll", + "settings.channel.connect.cancel": "cancel", +} # Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key # HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain' @@ -159,6 +213,33 @@ def _decode_api_key(raw_key: str) -> str | None: return key +def _mutation_payload(request: WsRequest) -> dict[str, Any] | None: + payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None) + if not isinstance(payload, dict): + return None + return cast(dict[str, Any], payload) + + +def _request_query(request: WsRequest) -> dict[str, list[str]]: + payload = _mutation_payload(request) + if payload is None: + return _parse_query(request.path) + query: dict[str, list[str]] = {} + for key, value in payload.items(): + if not key: + continue + if isinstance(value, bool): + text = "true" if value else "false" + elif value is None: + text = "" + elif isinstance(value, (dict, list)): + text = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + else: + text = str(value) + query[key] = [text] + return query + + def _default_model_name_from_config() -> str | None: try: from nanobot.config.loader import load_config @@ -285,11 +366,86 @@ class GatewayHTTPHandler: ) try: + if self._is_webui_mutation_path(got): + return _http_error( + 405, + "WebUI mutations require an authenticated WebSocket", + ) response = await self._dispatch_resolved(connection, request, got) return response finally: self._log_slow_http(got, response, started) + async def dispatch_webui_mutation( + self, + connection: Any, + action: str, + payload: dict[str, Any], + ) -> Response: + """Run one explicitly allowlisted mutation for an authenticated WebUI socket.""" + path = self._webui_mutation_path(action, payload) + if isinstance(path, Response): + return path + + source_request = getattr(connection, "request", None) + source_headers = getattr(source_request, "headers", None) + if source_headers is None: + headers = Headers() + else: + try: + headers = Headers(source_headers.raw_items()) + except (AttributeError, TypeError): + try: + headers = Headers(source_headers) + except TypeError: + headers = Headers() + request = WsRequest(path, headers) + setattr(request, "_nanobot_trusted_proxy_authenticated", True) + setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True) + setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload)) + response = await self._dispatch_resolved(connection, request, path) + if isinstance(response, Response): + return response + return _http_error(404, "WebUI mutation action not found") + + def _is_webui_mutation_path(self, path: str) -> bool: + if self.settings_routes.is_mutation_path(path): + return True + if re.match(r"^/api/sessions/[^/]+/delete$", path): + return True + if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path): + return True + return path in { + "/api/webui/skills/install", + "/api/webui/skills/update", + "/api/webui/skills/delete", + "/api/webui/sidebar-state/update", + } + + @staticmethod + def _webui_mutation_path( + action: str, + payload: dict[str, Any], + ) -> str | Response: + path = _WEBUI_MUTATION_PATHS.get(action) + if path is not None: + return path + if action == "session.delete": + key = payload.get("key") + if not isinstance(key, str) or not key.strip(): + return _http_error(400, "missing session key") + return f"/api/sessions/{quote(key, safe='')}/delete" + connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action) + if connect_action is not None: + channel = payload.get("channel") + if not isinstance(channel, str) or re.fullmatch( + r"[A-Za-z0-9_-]{1,64}", + channel, + ) is None: + return _http_error(400, "invalid channel name") + return f"/api/settings/channels/{channel}/connect/{connect_action}" + return _http_error(404, "unknown WebUI mutation action") + async def _dispatch_resolved( self, connection: Any, @@ -646,7 +802,7 @@ class GatewayHTTPHandler: return _http_error(400, "invalid session key") if not _is_websocket_channel_session_key(decoded_key): return _http_error(404, "session not found") - query = _parse_query(request.path) + query = _request_query(request) delete_automations = (_query_first(query, "delete_automations") or "").lower() automation_jobs = session_automation_jobs( self.cron_service, @@ -742,7 +898,7 @@ class GatewayHTTPHandler: if self.cron_service is None and self.local_trigger_store is None: return _http_error(503, "automation service unavailable") - query = _parse_query(request.path) + query = _request_query(request) job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip() if not job_id: return _http_error(400, "missing automation id") @@ -974,7 +1130,7 @@ class GatewayHTTPHandler: if self._skill_install_lock.locked(): return _http_error(409, "another skill installation is already in progress") - query = _parse_query(request.path) + query = _request_query(request) provider = _query_first(query, "provider") or "skills_sh" source = _query_first(query, "source") or "" skill_id = _query_first(query, "skill") or "" @@ -1015,7 +1171,7 @@ class GatewayHTTPHandler: def _handle_webui_skill_update(self, request: WsRequest) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") - query = _parse_query(request.path) + query = _request_query(request) name = _query_first(query, "name") or "" raw_enabled = (_query_first(query, "enabled") or "").lower() if raw_enabled not in {"true", "false"}: @@ -1047,7 +1203,7 @@ class GatewayHTTPHandler: return _http_error(401, "Unauthorized") if not _is_local_browser_request(connection, request.headers): return _http_error(403, "remote skill deletion is disabled") - name = _query_first(_parse_query(request.path), "name") or "" + name = _query_first(_request_query(request), "name") or "" try: action = delete_webui_skill( self.skills_workspace_path, @@ -1094,18 +1250,14 @@ class GatewayHTTPHandler: def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response: if not self.check_api_token(request): return _http_error(401, "Unauthorized") - query = _parse_query(request.path) - raw_state = _query_first(query, "state") - if raw_state is None: + payload = _mutation_payload(request) + state_value = payload.get("state") if payload is not None else None + if state_value is None: return _http_error(400, "missing state") - try: - decoded = json.loads(raw_state) - except json.JSONDecodeError: - return _http_error(400, "state must be JSON") - if not isinstance(decoded, dict): + if not isinstance(state_value, dict): return _http_error(400, "state must be an object") try: - state = write_webui_sidebar_state(cast(dict[str, Any], decoded)) + state = write_webui_sidebar_state(cast(dict[str, Any], state_value)) except ValueError as e: return _http_error(400, str(e)) except OSError: @@ -1174,16 +1326,10 @@ class GatewayHTTPHandler: def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None: - raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER) - if not raw: + payload = _mutation_payload(request) + if payload is None or "values" not in payload: return {} - try: - values = json.loads(raw) - except Exception: - try: - values = json.loads(unquote(raw)) - except Exception: - return None + values = payload.get("values") return cast(dict[str, Any], values) if isinstance(values, dict) else None diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index 80efcff97..9180a1614 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -28,22 +28,28 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter: ) +def _mutation_request(path: str, payload: dict[str, object]) -> SimpleNamespace: + request = SimpleNamespace(path=path, headers=Headers()) + request._nanobot_webui_mutation_request = True + request._nanobot_webui_mutation_payload = payload + request._nanobot_trusted_proxy_authenticated = True + return request + + @pytest.mark.parametrize( - ("provider", "header_name", "authorization_response"), + ("provider", "authorization_response"), [ - ("xai_grok", "X-Nanobot-OAuth-Code", "secret"), + ("xai_grok", "secret"), ( "openai_codex", - "X-Nanobot-OAuth-Callback", "http://localhost:1455/auth/callback?code=secret&state=test", ), ], ) @pytest.mark.asyncio -async def test_oauth_completion_reads_private_response_header( +async def test_oauth_completion_reads_websocket_payload( monkeypatch, provider: str, - header_name: str, authorization_response: str, ) -> None: captured: dict[str, object] = {} @@ -58,19 +64,13 @@ async def test_oauth_completion_reads_private_response_header( monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete) router = _router() - request = SimpleNamespace( - path=( - "/api/settings/provider/oauth-login/complete" - f"?provider={provider}&flow_id=flow-123" - ), - headers=Headers( - [ - ( - header_name, - authorization_response, - ) - ] - ), + request = _mutation_request( + "/api/settings/provider/oauth-login/complete", + { + "provider": provider, + "flow_id": "flow-123", + "authorization_response": authorization_response, + }, ) response = await router.dispatch( @@ -90,28 +90,29 @@ async def test_oauth_completion_reads_private_response_header( "query": {"provider": [provider], "flow_id": ["flow-123"]}, "authorization_response": authorization_response, } - assert authorization_response not in request.path + assert request.path == "/api/settings/provider/oauth-login/complete" + assert not request.headers @pytest.mark.parametrize( - ("request_path", "route_path", "function_name", "expected_query"), + ("route_path", "function_name", "payload", "expected_query"), [ ( - "/api/settings/model-configurations/delete?name=spare", "/api/settings/model-configurations/delete", "delete_model_configuration", + {"name": "spare"}, {"name": ["spare"]}, ), ( - "/api/settings/model-configurations/migrate", "/api/settings/model-configurations/migrate", "migrate_model_configurations", {}, + {}, ), ( - "/api/settings/model-call-order/update?order=%5B%22backup%22%5D", "/api/settings/model-call-order/update", "update_model_call_order", + {"order": ["backup"]}, {"order": ['["backup"]']}, ), ], @@ -119,9 +120,9 @@ async def test_oauth_completion_reads_private_response_header( @pytest.mark.asyncio async def test_model_preset_mutation_routes( monkeypatch, - request_path: str, route_path: str, function_name: str, + payload: dict[str, object], expected_query: dict[str, list[str]], ) -> None: captured: dict[str, object] = {} @@ -131,7 +132,7 @@ async def test_model_preset_mutation_routes( return {"routed": function_name} monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate) - request = SimpleNamespace(path=request_path, headers=Headers()) + request = _mutation_request(route_path, payload) response = await _router().dispatch(None, request, route_path) @@ -141,6 +142,23 @@ async def test_model_preset_mutation_routes( assert captured["query"] == expected_query +@pytest.mark.asyncio +async def test_settings_get_mutation_route_is_method_not_allowed() -> None: + path = "/api/settings/provider/update" + request = SimpleNamespace( + path=f"{path}?provider=openrouter&api_key=must-not-run", + headers=Headers(), + ) + + response = await _router().dispatch(None, request, path) + + assert response is not None + assert response.status_code == 405 + assert json.loads(response.body) == { + "error": "WebUI mutations require an authenticated WebSocket" + } + + @pytest.mark.parametrize( ("update_info", "expected"), [ diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 31bd2cf20..d655d4f05 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -2081,7 +2081,7 @@ function Shell({ setPairingBusyCode(code); setPairingError(null); try { - const payload = await runPairingAction(getToken(), action, code); + const payload = await runPairingAction(client, action, code); setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []); setSnoozedPairingCodes((current) => { if (!current.has(code)) return current; @@ -2096,7 +2096,7 @@ function Shell({ setPairingBusyCode(null); } }, - [getToken, refreshPairingRequests], + [client, refreshPairingRequests], ); const onDismissPairingRequest = useCallback((code: string) => { diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 59789d8ca..793bfec9b 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -724,7 +724,7 @@ export function SettingsView({ hostChromeInset = false, }: SettingsViewProps) { const { t } = useTranslation(); - const { getToken, token } = useClient(); + const { client, getToken, token } = useClient(); const pageVisible = usePageVisibility(); const remoteBrowserAccess = typeof window !== "undefined" && !isLoopbackHost(window.location.hostname); @@ -872,7 +872,7 @@ export function SettingsView({ const poll = async () => { try { const payload = await completeProviderOAuth( - getToken(), + client, providerOAuthFlow.provider, providerOAuthFlow.flow_id, ); @@ -902,7 +902,7 @@ export function SettingsView({ cancelled = true; if (timer !== null) window.clearTimeout(timer); }; - }, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]); + }, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]); useEffect(() => { if (!initialSettings || settings !== null) return; @@ -1301,7 +1301,7 @@ export function SettingsView({ } setModelConfigurationSaving(true); try { - const payload = await createModelConfiguration(token, { + const payload = await createModelConfiguration(client, { label, provider, model, @@ -1319,7 +1319,7 @@ export function SettingsView({ let finalPayload = payload; if (nextOrder) { - const orderedPayload = await updateModelCallOrder(token, nextOrder); + const orderedPayload = await updateModelCallOrder(client, nextOrder); applyPayload(orderedPayload); finalPayload = orderedPayload; } @@ -1345,7 +1345,7 @@ export function SettingsView({ const reasoningEffort = form.reasoningEffort || null; setSaving(true); try { - const payload = await updateModelConfiguration(token, { + const payload = await updateModelConfiguration(client, { name: selectedPreset.name, label: form.presetLabel.trim() !== selectedPreset.label @@ -1431,7 +1431,7 @@ export function SettingsView({ setModelCallOrder(nextOrder); setModelCallOrderSaving(true); try { - const payload = await updateModelCallOrder(token, nextOrder); + const payload = await updateModelCallOrder(client, nextOrder); applyPayload(payload, { preserveAgentForm: true }); onModelNameChange(payload.agent.model || null); setError(null); @@ -1447,7 +1447,7 @@ export function SettingsView({ if (modelMigrationSaving) return; setModelMigrationSaving(true); try { - const payload = await migrateModelConfigurations(token); + const payload = await migrateModelConfigurations(client); applyPayload(payload); onModelNameChange(payload.agent.model || null); setError(null); @@ -1469,7 +1469,7 @@ export function SettingsView({ } setSaving(true); try { - const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name); + const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name); applyPayload(payload); setModelPresetPendingDelete(null); setError(null); @@ -1484,7 +1484,7 @@ export function SettingsView({ if (!settings || !imageGenerationDirty || imageGenerationSaving) return; setImageGenerationSaving(true); try { - const payload = await updateImageGenerationSettings(token, imageGenerationForm); + const payload = await updateImageGenerationSettings(client, imageGenerationForm); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, image: true })); @@ -1502,7 +1502,7 @@ export function SettingsView({ if (!settings || !transcriptionDirty || transcriptionSaving) return; setTranscriptionSaving(true); try { - const payload = await updateTranscriptionSettings(token, transcriptionForm); + const payload = await updateTranscriptionSettings(client, transcriptionForm); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, browser: true })); @@ -1520,7 +1520,7 @@ export function SettingsView({ if (!settings || !networkSafetyDirty || networkSafetySaving) return; setNetworkSafetySaving(true); try { - const payload = await updateNetworkSafetySettings(token, networkSafetyForm); + const payload = await updateNetworkSafetySettings(client, networkSafetyForm); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); @@ -1544,7 +1544,7 @@ export function SettingsView({ try { let latest = nanobotFeatures; for (const name of missing) { - latest = await enableNanobotFeature(token, name); + latest = await enableNanobotFeature(client, name); if (latest.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } @@ -1568,8 +1568,8 @@ export function SettingsView({ setApiServiceError(null); try { const payload = action === "start" - ? await startApiService(token, values!) - : await stopApiService(token); + ? await startApiService(client, values!) + : await stopApiService(client); setApiService(payload); const refreshed = await fetchNanobotFeatures(token); setNanobotFeatures(refreshed); @@ -1622,7 +1622,7 @@ export function SettingsView({ if (field === "region") update.region = providerForm.region.trim(); if (field === "profile") update.profile = providerForm.profile.trim(); } - const payload = await updateProviderSettings(token, update); + const payload = await updateProviderSettings(client, update); applyPayload(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, image: true })); @@ -1656,7 +1656,7 @@ export function SettingsView({ if (providerSaving) return false; setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY); try { - const payload = await createProviderSettings(token, { + const payload = await createProviderSettings(client, { name: draft.name.trim(), apiKey: draft.apiKey.trim() || undefined, apiBase: draft.apiBase.trim(), @@ -1698,12 +1698,11 @@ export function SettingsView({ const payload = action === "login" ? await loginProviderOAuth( - token, + client, providerName, - "", providerName === "openai_codex" && remoteBrowserAccess, ) - : await logoutProviderOAuth(token, providerName); + : await logoutProviderOAuth(client, providerName); if (isProviderOAuthAuthorizationRequired(payload)) { try { if (popup && !popup.closed) popup.location.href = payload.authorization_url; @@ -1739,7 +1738,7 @@ export function SettingsView({ setProviderOAuthDialogError(null); try { const payload = await completeProviderOAuth( - token, + client, flow.provider, flow.flow_id, authorizationResponse, @@ -1798,7 +1797,7 @@ export function SettingsView({ update.apiKey = apiKey; } if (provider.credential === "base_url") update.baseUrl = baseUrl; - const payload = await updateWebSearchSettings(token, update); + const payload = await updateWebSearchSettings(client, update); applyPayload(payload); if (payload.requires_restart || webFetchRestartRequired) { setPendingRestartSections((prev) => ({ ...prev, browser: true })); @@ -1903,7 +1902,7 @@ export function SettingsView({ setCliAppsMessage(null); setCliAppsError(null); try { - const payload = await runCliAppAction(token, action, name); + const payload = await runCliAppAction(client, action, name); setCliApps(payload); if (action !== "test") { notifyCliAppsChanged(payload); @@ -1934,8 +1933,8 @@ export function SettingsView({ setNanobotFeaturesError(null); try { const payload = action === "enable" - ? await enableNanobotFeature(token, name) - : await disableNanobotFeature(token, name); + ? await enableNanobotFeature(client, name) + : await disableNanobotFeature(client, name); setNanobotFeatures(payload); if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); @@ -1955,7 +1954,7 @@ export function SettingsView({ setAutomationAction(key); setAutomationsError(null); try { - const payload = await runAutomationAction(token, action, job.id); + const payload = await runAutomationAction(client, action, job.id); setAutomations(payload); if (action === "delete") setAutomationPendingDelete(null); if (action === "run") { @@ -1977,7 +1976,7 @@ export function SettingsView({ setAutomationAction(key); setAutomationsError(null); try { - const payload = await updateAutomation(token, job.id, values); + const payload = await updateAutomation(client, job.id, values); setAutomations(payload); setAutomationPendingEdit(null); } catch (err) { @@ -1997,7 +1996,7 @@ export function SettingsView({ setMcpMessage(null); setMcpError(null); try { - const payload = await runMcpPresetAction(token, action, name, values); + const payload = await runMcpPresetAction(client, action, name, values); setMcpPresets(payload); setMcpMessage(payload.last_action?.message ?? null); if (action !== "test") { @@ -2024,7 +2023,7 @@ export function SettingsView({ setMcpMessage(null); setMcpError(null); try { - const payload = await saveCustomMcpServer(token, { + const payload = await saveCustomMcpServer(client, { name, transport: customMcpForm.transport, command: customMcpForm.command, @@ -2054,7 +2053,7 @@ export function SettingsView({ setMcpMessage(null); setMcpError(null); try { - const payload = await importMcpConfig(token, mcpConfigImport); + const payload = await importMcpConfig(client, mcpConfigImport); setMcpPresets(payload); setMcpMessage(payload.last_action?.message ?? null); notifyMcpPresetsChanged(payload); @@ -2075,7 +2074,7 @@ export function SettingsView({ setMcpMessage(null); setMcpError(null); try { - const payload = await updateMcpServerTools(token, name, enabledTools); + const payload = await updateMcpServerTools(client, name, enabledTools); setMcpPresets(payload); setMcpMessage(payload.last_action?.message ?? null); notifyMcpPresetsChanged(payload); diff --git a/webui/src/components/settings/SkillsCatalogSettings.tsx b/webui/src/components/settings/SkillsCatalogSettings.tsx index 9f8df11a9..aa243ee5d 100644 --- a/webui/src/components/settings/SkillsCatalogSettings.tsx +++ b/webui/src/components/settings/SkillsCatalogSettings.tsx @@ -269,7 +269,7 @@ function SkillDetailSheet({ open: boolean; onOpenChange: (open: boolean) => void; }) { - const { getToken } = useClient(); + const { client, getToken } = useClient(); const { t } = useTranslation(); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); @@ -321,7 +321,7 @@ function SkillDetailSheet({ setActionBusy(true); setActionError(""); try { - const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled); + const payload = await updateSkillEnabled(client, activeSkill.name, !enabled); notifySkillsChanged(payload); const updated = payload.skills.find((item) => item.name === activeSkill.name); if (updated) { @@ -345,7 +345,7 @@ function SkillDetailSheet({ setActionBusy(true); setActionError(""); try { - const payload = await deleteSkill(getToken(), activeSkill.name); + const payload = await deleteSkill(client, activeSkill.name); notifySkillsChanged(payload); onOpenChange(false); } catch (reason) { diff --git a/webui/src/components/settings/SkillsMarketplace.tsx b/webui/src/components/settings/SkillsMarketplace.tsx index 59af239a8..f13e982ae 100644 --- a/webui/src/components/settings/SkillsMarketplace.tsx +++ b/webui/src/components/settings/SkillsMarketplace.tsx @@ -46,7 +46,7 @@ export function SkillsMarketplace({ installing: string; onInstallingChange: (skillId: string) => void; }) { - const { getToken } = useClient(); + const { client, getToken } = useClient(); const { t } = useTranslation(); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); @@ -161,7 +161,7 @@ export function SkillsMarketplace({ setError(""); try { const payload = await installMarketplaceSkill( - getToken(), + client, skill.provider, skill.source, skill.skill_id, diff --git a/webui/src/components/settings/channels/ChannelInstancesPanel.tsx b/webui/src/components/settings/channels/ChannelInstancesPanel.tsx index 59458adb6..f8b1a6a01 100644 --- a/webui/src/components/settings/channels/ChannelInstancesPanel.tsx +++ b/webui/src/components/settings/channels/ChannelInstancesPanel.tsx @@ -38,6 +38,7 @@ import type { NanobotFeaturesPayload, } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { useClient } from "@/providers/ClientProvider"; export type ChannelInstancesPanelCustomization = { countLabel?: (runningCount: number) => string; @@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = { }; export function ChannelInstancesPanel({ - token, feature, showBrandLogos, chatAppsDocsUrl, @@ -58,7 +58,6 @@ export function ChannelInstancesPanel({ onFeaturesUpdate, customization = {}, }: { - token: string; feature: NanobotFeatureInfo; showBrandLogos: boolean; chatAppsDocsUrl?: string; @@ -66,6 +65,7 @@ export function ChannelInstancesPanel({ onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; customization?: ChannelInstancesPanelCustomization; }) { + const { client } = useClient(); const { t, i18n } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const displayName = localizedChannelDisplayName(feature, t); @@ -111,8 +111,8 @@ export function ChannelInstancesPanel({ setNotice(null); try { const payload = checked - ? await enableNanobotFeature(token, feature.name, { instanceId: instance.id }) - : await disableNanobotFeature(token, feature.name, { instanceId: instance.id }); + ? await enableNanobotFeature(client, feature.name, { instanceId: instance.id }) + : await disableNanobotFeature(client, feature.name, { instanceId: instance.id }); onFeaturesUpdate(payload); } catch (err) { setNotice((err as Error).message); @@ -127,7 +127,7 @@ export function ChannelInstancesPanel({ setNotice(null); try { const payload = await configureChannel( - token, + client, feature.name, channelValuesForSave(instanceFields, fieldValues), { enable: selected.enabled, instanceId: selected.id }, diff --git a/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx b/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx index a3cb257fa..885b7fcf8 100644 --- a/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx +++ b/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx @@ -14,6 +14,7 @@ import type { ChannelConnectPayload, NanobotFeaturesPayload, } from "@/lib/types"; +import { useClient } from "@/providers/ClientProvider"; export type ChannelQrConnectLabels = { qrAlt: string; @@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = { }; export function ChannelQrConnectFlow({ - token, channelName, startOptions = {}, idleLabel, @@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({ resolveMessage?: (payload: ChannelConnectPayload) => string | undefined; suppressSucceeded?: boolean; }) { + const { client } = useClient(); const pageVisible = usePageVisibility(); const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); @@ -78,8 +79,6 @@ export function ChannelQrConnectFlow({ const [error, setError] = useState(null); const [handledRequestId, setHandledRequestId] = useState(0); const pollInFlight = useRef(false); - const tokenRef = useRef(token); - tokenRef.current = token; const startDomain = startOptions.domain; const startInstanceId = startOptions.instanceId; const startMode = startOptions.mode; @@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({ pollInFlight.current = true; try { const payload = await pollChannelConnect( - tokenRef.current, + client, channelName, sessionId, ); @@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({ }; }, [ channelName, + client, connect?.interval_ms, connect?.session_id, connect?.status, @@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({ setBusy(true); setError(null); try { - const payload = await startChannelConnect(tokenRef.current, channelName, { + const payload = await startChannelConnect(client, channelName, { domain: startDomain, instanceId: startInstanceId, mode: startMode, @@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({ } finally { setBusy(false); } - }, [channelName, startDomain, startForce, startInstanceId, startMode]); + }, [channelName, client, startDomain, startForce, startInstanceId, startMode]); useEffect(() => { if (!connectRequestId || connectRequestId === handledRequestId) return; @@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({ setBusy(true); try { const payload = await cancelChannelConnect( - tokenRef.current, + client, channelName, connect.session_id, ); @@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({ setError(null); try { const payload = await pollChannelConnect( - tokenRef.current, + client, channelName, connect.session_id, - "", params, ); setConnect((current) => ({ diff --git a/webui/src/components/settings/channels/ChannelSetupPanel.tsx b/webui/src/components/settings/channels/ChannelSetupPanel.tsx index 71d4470d0..8db52eefa 100644 --- a/webui/src/components/settings/channels/ChannelSetupPanel.tsx +++ b/webui/src/components/settings/channels/ChannelSetupPanel.tsx @@ -54,6 +54,7 @@ import type { NanobotFeaturesPayload, } from "@/lib/types"; import { cn } from "@/lib/utils"; +import { useClient } from "@/providers/ClientProvider"; export function ChannelCatalogRow({ feature, @@ -148,7 +149,6 @@ export function ChannelSetupPanel({ if (feature.instances !== undefined) { return ( ; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; }) { + const { client } = useClient(); const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const [notice, setNotice] = useState(null); @@ -345,7 +346,7 @@ function ChannelSetupSurface({ setNotice(null); const values = channelValuesForSubmit(fields, fieldValues, touchedFields); try { - const validationPayload = await validateChannel(token, feature.name, values); + const validationPayload = await validateChannel(client, feature.name, values); setValidation(validationPayload); if (!validationPayload.can_enable) { setNotice( @@ -355,7 +356,7 @@ function ChannelSetupSurface({ return; } const payload = await configureChannel( - token, + client, feature.name, values, { enable: true }, @@ -377,7 +378,7 @@ function ChannelSetupSurface({ setNotice(null); try { const payload = await validateChannel( - token, + client, feature.name, channelValuesForSubmit(fields, fieldValues, touchedFields), ); diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index fd63457cc..61748ce1a 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -257,13 +257,13 @@ export function useSessions(): { const deleteChat = useCallback( async (key: string, options?: { deleteAutomations?: boolean }) => { - const result = await apiDeleteSession(tokenRef.current, key, options); + const result = await apiDeleteSession(client, key, options); if (!result.deleted) return result; optimisticKeysRef.current.delete(key); setSessions((prev) => prev.filter((s) => s.key !== key)); return result; }, - [], + [client], ); const getSessionAutomations = useCallback(async (key: string) => { diff --git a/webui/src/hooks/useSidebarState.ts b/webui/src/hooks/useSidebarState.ts index 89ac3b04c..cf90a36e1 100644 --- a/webui/src/hooks/useSidebarState.ts +++ b/webui/src/hooks/useSidebarState.ts @@ -144,6 +144,8 @@ export function useSidebarState( const { client, token } = useClient(); const tokenRef = useRef(token); const stateRef = useRef(DEFAULT_SIDEBAR_STATE); + const connectionOpenRef = useRef(client.status === "open"); + const pendingPersistenceRef = useRef(null); const [state, setState] = useState(DEFAULT_SIDEBAR_STATE); const [loading, setLoading] = useState(true); tokenRef.current = token; @@ -171,14 +173,32 @@ export function useSidebarState( }; }, []); + const persist = useCallback((next: SidebarStatePayload) => { + if (!connectionOpenRef.current) { + pendingPersistenceRef.current = next; + return; + } + void client.setSidebarState(next).catch(() => { + // Sidebar persistence is best-effort; the optimistic local state remains usable. + }); + }, [client]); + + useEffect(() => client.onStatus((status) => { + connectionOpenRef.current = status === "open"; + if (status !== "open" || pendingPersistenceRef.current === null) return; + const pending = pendingPersistenceRef.current; + pendingPersistenceRef.current = null; + persist(pending); + }), [client, persist]); + const update = useCallback( async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => { const next = normalizeSidebarState(updater(stateRef.current)); stateRef.current = next; setState(next); - client.setSidebarState(next); + persist(next); }, - [client], + [persist], ); const pruned = useMemo(() => { diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index f1e416c05..2647eb115 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -44,6 +44,8 @@ import type { import { fetchWithTimeout } from "./http"; const API_READ_TIMEOUT_MS = 20_000; +const API_MUTATION_TIMEOUT_MS = 20_000; +const PACKAGE_MUTATION_TIMEOUT_MS = 150_000; const SLASH_COMMAND_LIFECYCLES = new Set([ "side_channel", "finalize_active_turn", @@ -58,12 +60,6 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle && SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle) ); } -const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"; -const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"; -const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"; -const OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"; -const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"; - export class ApiError extends Error { status: number; constructor(status: number, message: string) { @@ -73,6 +69,14 @@ export class ApiError extends Error { } } +export interface WebUIMutationTransport { + requestMutation( + action: string, + payload?: Record, + timeoutMs?: number, + ): Promise; +} + async function request( url: string, token: string, @@ -109,7 +113,27 @@ async function request( return (await res.json()) as T; } -function mcpValuesHeader(values: Record): HeadersInit | undefined { +async function mutation( + transport: WebUIMutationTransport, + action: string, + payload: Record = {}, + timeoutMs: number = API_MUTATION_TIMEOUT_MS, +): Promise { + try { + return await transport.requestMutation(action, payload, timeoutMs); + } catch (reason) { + const status = ( + typeof reason === "object" + && reason !== null + && "status" in reason + && typeof reason.status === "number" + ) ? reason.status : 500; + const message = reason instanceof Error ? reason.message : "WebUI mutation failed"; + throw new ApiError(status, message); + } +} + +function compactMcpValues(values: Record): Record { const payload: Record = {}; Object.entries(values).forEach(([key, value]) => { if (value === null || value === undefined) return; @@ -120,12 +144,7 @@ function mcpValuesHeader(values: Record): HeadersInit | undefin } payload[key] = value; }); - if (!Object.keys(payload).length) return undefined; - return { "X-Nanobot-MCP-Values": JSON.stringify(payload) }; -} - -function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit { - return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) }; + return payload; } function splitKey(key: string): { channel: string; chatId: string } { @@ -261,37 +280,19 @@ export async function fetchAutomations( } export async function runAutomationAction( - token: string, + transport: WebUIMutationTransport, action: "enable" | "disable" | "delete" | "run", id: string, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("id", id); - return request( - `${base}/api/webui/automations/${action}?${query}`, - token, - undefined, - API_READ_TIMEOUT_MS, - ); + return mutation(transport, `automation.${action}`, { id }); } export async function updateAutomation( - token: string, + transport: WebUIMutationTransport, id: string, values: AutomationUpdatePayload, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("id", id); - return request( - `${base}/api/webui/automations/update?${query}`, - token, - { - headers: automationValuesHeader(values), - }, - API_READ_TIMEOUT_MS, - ); + return mutation(transport, "automation.update", { id, values }); } export async function fetchSkills( @@ -320,28 +321,18 @@ export async function fetchSkillDetail( } export async function updateSkillEnabled( - token: string, + transport: WebUIMutationTransport, name: string, enabled: boolean, - base: string = "", ): Promise { - const params = new URLSearchParams({ name, enabled: String(enabled) }); - return request( - `${base}/api/webui/skills/update?${params}`, - token, - ); + return mutation(transport, "skill.update", { name, enabled }); } export async function deleteSkill( - token: string, + transport: WebUIMutationTransport, name: string, - base: string = "", ): Promise { - const params = new URLSearchParams({ name }); - return request( - `${base}/api/webui/skills/delete?${params}`, - token, - ); + return mutation(transport, "skill.delete", { name }); } export async function searchMarketplaceSkills( @@ -389,37 +380,33 @@ export async function fetchMarketplaceSkillTrends( } export async function installMarketplaceSkill( - token: string, + transport: WebUIMutationTransport, provider: Exclude, source: string, skill: string, version: string = "", - base: string = "", ): Promise { - const params = new URLSearchParams({ provider, source, skill }); - if (version) params.set("version", version); - return request( - `${base}/api/webui/skills/install?${params}`, - token, - undefined, - 150_000, + return mutation( + transport, + "skill.install", + { provider, source, skill, ...(version ? { version } : {}) }, + PACKAGE_MUTATION_TIMEOUT_MS, ); } export async function deleteSession( - token: string, + transport: WebUIMutationTransport, key: string, optionsOrBase?: { deleteAutomations?: boolean } | string, - base: string = "", ): Promise { const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase; - const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base; - const query = new URLSearchParams(); - if (options?.deleteAutomations) query.set("delete_automations", "true"); - const suffix = query.toString() ? `?${query}` : ""; - return request( - `${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`, - token, + return mutation( + transport, + "session.delete", + { + key, + ...(options?.deleteAutomations ? { delete_automations: true } : {}), + }, ); } @@ -520,56 +507,50 @@ export async function fetchApiService(token: string, base: string = ""): Promise } export async function startApiService( - token: string, + transport: WebUIMutationTransport, values: { host: string; port: number; timeout: number; apiKey?: string }, - base: string = "", ): Promise { - const query = new URLSearchParams({ - host: values.host, - port: String(values.port), - timeout: String(values.timeout), - }); - const headers = values.apiKey === undefined - ? undefined - : { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) }; - return request( - `${base}/api/settings/api-service/start?${query}`, - token, - { headers }, + return mutation( + transport, + "settings.api_service.start", + { + host: values.host, + port: values.port, + timeout: values.timeout, + ...(values.apiKey !== undefined ? { api_key: values.apiKey } : {}), + }, + PACKAGE_MUTATION_TIMEOUT_MS, ); } -export async function stopApiService(token: string, base: string = ""): Promise { - return request(`${base}/api/settings/api-service/stop`, token); +export async function stopApiService( + transport: WebUIMutationTransport, +): Promise { + return mutation(transport, "settings.api_service.stop"); } export async function enableNanobotFeature( - token: string, + transport: WebUIMutationTransport, name: string, options: { instanceId?: string } = {}, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("name", name); - if (options.instanceId) query.set("instance_id", options.instanceId); - return request( - `${base}/api/settings/nanobot-features/enable?${query}`, - token, + return mutation( + transport, + "settings.feature.enable", + { name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) }, + PACKAGE_MUTATION_TIMEOUT_MS, ); } export async function disableNanobotFeature( - token: string, + transport: WebUIMutationTransport, name: string, options: { instanceId?: string } = {}, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("name", name); - if (options.instanceId) query.set("instance_id", options.instanceId); - return request( - `${base}/api/settings/nanobot-features/disable?${query}`, - token, + return mutation( + transport, + "settings.feature.disable", + { name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) }, ); } @@ -586,21 +567,15 @@ export async function fetchPairingRequests( } export async function runPairingAction( - token: string, + transport: WebUIMutationTransport, action: "approve" | "deny", code: string, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("code", code); - return request( - `${base}/api/settings/pairing/${action}?${query}`, - token, - ); + return mutation(transport, `settings.pairing.${action}`, { code }); } export async function startChannelConnect( - token: string, + transport: WebUIMutationTransport, channel: string, options: { domain?: string; @@ -608,104 +583,95 @@ export async function startChannelConnect( mode?: "replace" | "create"; force?: boolean; } = {}, - base: string = "", ): Promise { - const query = new URLSearchParams(); - if (options.domain) query.set("domain", options.domain); - if (options.instanceId) query.set("instance_id", options.instanceId); - if (options.mode) query.set("mode", options.mode); - if (options.force) query.set("force", "true"); - const suffix = query.toString(); - return request( - `${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`, - token, + return mutation( + transport, + "settings.channel.connect.start", + { + channel, + ...(options.domain ? { domain: options.domain } : {}), + ...(options.instanceId ? { instance_id: options.instanceId } : {}), + ...(options.mode ? { mode: options.mode } : {}), + ...(options.force ? { force: true } : {}), + }, + PACKAGE_MUTATION_TIMEOUT_MS, ); } export async function pollChannelConnect( - token: string, + transport: WebUIMutationTransport, channel: string, sessionId: string, - base: string = "", params: Readonly> = {}, ): Promise { - const query = new URLSearchParams(); - query.set("session_id", sessionId); - Object.entries(params).forEach(([key, value]) => { - if (key !== "session_id") query.set(key, value); - }); - return request( - `${base}/api/settings/channels/${channel}/connect/poll?${query}`, - token, + const values = Object.fromEntries( + Object.entries(params).filter(([key]) => key !== "session_id"), + ); + return mutation( + transport, + "settings.channel.connect.poll", + { channel, session_id: sessionId, ...values }, + PACKAGE_MUTATION_TIMEOUT_MS, ); } export async function cancelChannelConnect( - token: string, + transport: WebUIMutationTransport, channel: string, sessionId: string, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("session_id", sessionId); - return request( - `${base}/api/settings/channels/${channel}/connect/cancel?${query}`, - token, + return mutation( + transport, + "settings.channel.connect.cancel", + { channel, session_id: sessionId }, ); } export async function configureChannel( - token: string, + transport: WebUIMutationTransport, name: string, values: Record, options: { enable?: boolean; instanceId?: string } = {}, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("name", name); - if (options.enable !== undefined) query.set("enable", String(options.enable)); - if (options.instanceId) query.set("instance_id", options.instanceId); - return request( - `${base}/api/settings/channels/configure?${query}`, - token, + return mutation( + transport, + "settings.channel.configure", { - headers: { - [CHANNEL_VALUES_HEADER]: JSON.stringify(values), - }, + name, + values, + ...(options.enable !== undefined ? { enable: options.enable } : {}), + ...(options.instanceId ? { instance_id: options.instanceId } : {}), }, + PACKAGE_MUTATION_TIMEOUT_MS, ); } export async function validateChannel( - token: string, + transport: WebUIMutationTransport, name: string, values: Record = {}, options: { instanceId?: string } = {}, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("name", name); - if (options.instanceId) query.set("instance_id", options.instanceId); - return request( - `${base}/api/settings/channels/validate?${query}`, - token, - { - headers: { - [CHANNEL_VALUES_HEADER]: JSON.stringify(values), - }, - }, + return mutation( + transport, + "settings.channel.validate", + { name, values, ...(options.instanceId ? { instance_id: options.instanceId } : {}) }, ); } export async function runCliAppAction( - token: string, + transport: WebUIMutationTransport, action: "install" | "update" | "uninstall" | "test", name: string, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("name", name); - return request(`${base}/api/settings/cli-apps/${action}?${query}`, token); + return mutation( + transport, + `settings.cli_app.${action}`, + { name }, + action === "install" || action === "update" + ? PACKAGE_MUTATION_TIMEOUT_MS + : API_MUTATION_TIMEOUT_MS, + ); } export async function fetchMcpPresets( @@ -736,55 +702,45 @@ export async function fetchProviderModels( } export async function runMcpPresetAction( - token: string, + transport: WebUIMutationTransport, action: "enable" | "remove" | "test", name: string, values: Record = {}, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("name", name); - return request( - `${base}/api/settings/mcp-presets/${action}?${query}`, - token, - { headers: mcpValuesHeader(values) }, + return mutation( + transport, + `settings.mcp.${action}`, + { name, ...compactMcpValues(values) }, ); } export async function saveCustomMcpServer( - token: string, + transport: WebUIMutationTransport, values: Record, - base: string = "", ): Promise { - return request( - `${base}/api/settings/mcp-presets/custom`, - token, - { headers: mcpValuesHeader(values) }, + return mutation( + transport, + "settings.mcp.custom", + compactMcpValues(values), ); } export async function importMcpConfig( - token: string, + transport: WebUIMutationTransport, config: string, - base: string = "", ): Promise { - return request( - `${base}/api/settings/mcp-presets/import`, - token, - { headers: mcpValuesHeader({ config }) }, - ); + return mutation(transport, "settings.mcp.import", { config }); } export async function updateMcpServerTools( - token: string, + transport: WebUIMutationTransport, name: string, enabledTools: string[], - base: string = "", ): Promise { - return request( - `${base}/api/settings/mcp-presets/tools`, - token, - { headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) }, + return mutation( + transport, + "settings.mcp.tools", + { name, enabled_tools: enabledTools }, ); } @@ -835,280 +791,228 @@ export async function fetchSidebarState( } export async function updateSidebarState( - token: string, + transport: WebUIMutationTransport, state: SidebarStatePayload, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("state", JSON.stringify(state)); - return request( - `${base}/api/webui/sidebar-state/update?${query}`, - token, - ); + return mutation(transport, "sidebar.update", { state }); } export async function updateSettings( - token: string, + transport: WebUIMutationTransport, update: SettingsUpdate, - base: string = "", ): Promise { - const query = new URLSearchParams(); + const payload: Record = {}; if (update.modelPreset !== undefined) { - query.set("model_preset", update.modelPreset ?? "default"); + payload.model_preset = update.modelPreset ?? "default"; } - if (update.model !== undefined) query.set("model", update.model); - if (update.provider !== undefined) query.set("provider", update.provider); + if (update.model !== undefined) payload.model = update.model; + if (update.provider !== undefined) payload.provider = update.provider; if (update.contextWindowTokens !== undefined) { - query.set("context_window_tokens", String(update.contextWindowTokens)); + payload.context_window_tokens = update.contextWindowTokens; } - if (update.timezone !== undefined) query.set("timezone", update.timezone); + if (update.timezone !== undefined) payload.timezone = update.timezone; if (update.toolHintMaxLength !== undefined) { - query.set("tool_hint_max_length", String(update.toolHintMaxLength)); + payload.tool_hint_max_length = update.toolHintMaxLength; } - return request(`${base}/api/settings/update?${query}`, token); + return mutation(transport, "settings.agent.update", payload); } -function appendModelGenerationSettings( - query: URLSearchParams, +function modelGenerationSettingsPayload( configuration: Pick< ModelConfigurationCreate, "maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort" >, -): void { +): Record { + const payload: Record = {}; if (configuration.maxTokens !== undefined) { - query.set("max_tokens", String(configuration.maxTokens)); + payload.max_tokens = configuration.maxTokens; } if (configuration.contextWindowTokens !== undefined) { - query.set("context_window_tokens", String(configuration.contextWindowTokens)); + payload.context_window_tokens = configuration.contextWindowTokens; } if (configuration.temperature !== undefined) { - query.set("temperature", String(configuration.temperature)); + payload.temperature = configuration.temperature; } if (configuration.reasoningEffort !== undefined) { - query.set("reasoning_effort", configuration.reasoningEffort ?? ""); + payload.reasoning_effort = configuration.reasoningEffort ?? ""; } + return payload; } export async function createModelConfiguration( - token: string, + transport: WebUIMutationTransport, configuration: ModelConfigurationCreate, - base: string = "", ): Promise { - const query = new URLSearchParams(); - if (configuration.name !== undefined) query.set("name", configuration.name); - query.set("label", configuration.label); - query.set("provider", configuration.provider); - query.set("model", configuration.model); - appendModelGenerationSettings(query, configuration); - return request( - `${base}/api/settings/model-configurations/create?${query}`, - token, + return mutation( + transport, + "settings.model_configuration.create", + { + ...(configuration.name !== undefined ? { name: configuration.name } : {}), + label: configuration.label, + provider: configuration.provider, + model: configuration.model, + ...modelGenerationSettingsPayload(configuration), + }, ); } export async function updateModelConfiguration( - token: string, + transport: WebUIMutationTransport, configuration: ModelConfigurationUpdate, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("name", configuration.name); - if (configuration.label !== undefined) query.set("label", configuration.label); - if (configuration.provider !== undefined) query.set("provider", configuration.provider); - if (configuration.model !== undefined) query.set("model", configuration.model); - appendModelGenerationSettings(query, configuration); - return request( - `${base}/api/settings/model-configurations/update?${query}`, - token, + return mutation( + transport, + "settings.model_configuration.update", + { + name: configuration.name, + ...(configuration.label !== undefined ? { label: configuration.label } : {}), + ...(configuration.provider !== undefined ? { provider: configuration.provider } : {}), + ...(configuration.model !== undefined ? { model: configuration.model } : {}), + ...modelGenerationSettingsPayload(configuration), + }, ); } export async function deleteModelConfiguration( - token: string, + transport: WebUIMutationTransport, name: string, - base: string = "", ): Promise { - const query = new URLSearchParams({ name }); - return request( - `${base}/api/settings/model-configurations/delete?${query}`, - token, + return mutation( + transport, + "settings.model_configuration.delete", + { name }, ); } export async function migrateModelConfigurations( - token: string, - base: string = "", + transport: WebUIMutationTransport, ): Promise { - return request( - `${base}/api/settings/model-configurations/migrate`, - token, - ); + return mutation(transport, "settings.model_configuration.migrate"); } export async function updateModelCallOrder( - token: string, + transport: WebUIMutationTransport, order: string[], - base: string = "", ): Promise { - const query = new URLSearchParams({ order: JSON.stringify(order) }); - return request( - `${base}/api/settings/model-call-order/update?${query}`, - token, - ); + return mutation(transport, "settings.model_call_order.update", { order }); } export async function updateProviderSettings( - token: string, + transport: WebUIMutationTransport, update: ProviderSettingsUpdate, - base: string = "", ): Promise { - const { provider, ...values } = update; - const query = new URLSearchParams({ provider }); - return request( - `${base}/api/settings/provider/update?${query}`, - token, - { - headers: { - [PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)), - }, - }, - ); + return mutation(transport, "settings.provider.update", { ...update }); } export async function createProviderSettings( - token: string, + transport: WebUIMutationTransport, update: ProviderCreationUpdate, - base: string = "", ): Promise { - return request( - `${base}/api/settings/provider/create`, - token, - { - headers: { - [PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)), - }, - }, - ); + return mutation(transport, "settings.provider.create", { ...update }); } export async function loginProviderOAuth( - token: string, + transport: WebUIMutationTransport, provider: string, - base: string = "", remoteBrowserAccess: boolean = false, ): Promise { - const query = new URLSearchParams(); - query.set("provider", provider); - if (remoteBrowserAccess) query.set("remote_browser", "true"); - return request( - `${base}/api/settings/provider/oauth-login?${query}`, - token, - { cache: "no-store" }, + return mutation( + transport, + "settings.provider.oauth_login", + { provider, ...(remoteBrowserAccess ? { remote_browser: true } : {}) }, ); } export async function completeProviderOAuth( - token: string, + transport: WebUIMutationTransport, provider: string, flowId: string, authorizationResponse?: string, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("provider", provider); - query.set("flow_id", flowId); - const responseHeader = provider === "openai_codex" - ? OAUTH_CALLBACK_HEADER - : OAUTH_CODE_HEADER; - const headers = authorizationResponse - ? { [responseHeader]: authorizationResponse } - : undefined; - return request( - `${base}/api/settings/provider/oauth-login/complete?${query}`, - token, - { cache: "no-store", ...(headers ? { headers } : {}) }, + return mutation( + transport, + "settings.provider.oauth_complete", + { + provider, + flow_id: flowId, + ...(authorizationResponse ? { authorization_response: authorizationResponse } : {}), + }, ); } export async function logoutProviderOAuth( - token: string, + transport: WebUIMutationTransport, provider: string, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("provider", provider); - return request( - `${base}/api/settings/provider/oauth-logout?${query}`, - token, - ); + return mutation(transport, "settings.provider.oauth_logout", { provider }); } export async function updateWebSearchSettings( - token: string, + transport: WebUIMutationTransport, update: WebSearchSettingsUpdate, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("provider", update.provider); - if (update.apiKey !== undefined) query.set("api_key", update.apiKey); - if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl); - if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults)); - if (update.timeout !== undefined) query.set("timeout", String(update.timeout)); - if (update.useJinaReader !== undefined) { - query.set("use_jina_reader", String(update.useJinaReader)); - } - return request( - `${base}/api/settings/web-search/update?${query}`, - token, + return mutation( + transport, + "settings.web_search.update", + { + provider: update.provider, + ...(update.apiKey !== undefined ? { api_key: update.apiKey } : {}), + ...(update.baseUrl !== undefined ? { base_url: update.baseUrl } : {}), + ...(update.maxResults !== undefined ? { max_results: update.maxResults } : {}), + ...(update.timeout !== undefined ? { timeout: update.timeout } : {}), + ...(update.useJinaReader !== undefined + ? { use_jina_reader: update.useJinaReader } + : {}), + }, ); } export async function updateNetworkSafetySettings( - token: string, + transport: WebUIMutationTransport, update: NetworkSafetySettingsUpdate, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess)); - query.set("webui_default_access_mode", update.webuiDefaultAccessMode); - return request( - `${base}/api/settings/network-safety/update?${query}`, - token, + return mutation( + transport, + "settings.network_safety.update", + { + webui_allow_local_service_access: update.webuiAllowLocalServiceAccess, + webui_default_access_mode: update.webuiDefaultAccessMode, + }, ); } export async function updateImageGenerationSettings( - token: string, + transport: WebUIMutationTransport, update: ImageGenerationSettingsUpdate, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("enabled", String(update.enabled)); - query.set("provider", update.provider); - query.set("model", update.model); - query.set("default_aspect_ratio", update.defaultAspectRatio); - query.set("default_image_size", update.defaultImageSize); - query.set("max_images_per_turn", String(update.maxImagesPerTurn)); - return request( - `${base}/api/settings/image-generation/update?${query}`, - token, + return mutation( + transport, + "settings.image_generation.update", + { + enabled: update.enabled, + provider: update.provider, + model: update.model, + default_aspect_ratio: update.defaultAspectRatio, + default_image_size: update.defaultImageSize, + max_images_per_turn: update.maxImagesPerTurn, + }, ); } export async function updateTranscriptionSettings( - token: string, + transport: WebUIMutationTransport, update: TranscriptionSettingsUpdate, - base: string = "", ): Promise { - const query = new URLSearchParams(); - query.set("enabled", String(update.enabled)); - query.set("provider", update.provider); - query.set("model", update.model); - query.set("language", update.language); - query.set("max_duration_sec", String(update.maxDurationSec)); - query.set("max_upload_mb", String(update.maxUploadMb)); - return request( - `${base}/api/settings/transcription/update?${query}`, - token, + return mutation( + transport, + "settings.transcription.update", + { + enabled: update.enabled, + provider: update.provider, + model: update.model, + language: update.language, + max_duration_sec: update.maxDurationSec, + max_upload_mb: update.maxUploadMb, + }, ); } diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index a015061ff..fcf0e07d7 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -108,6 +108,16 @@ interface PendingRequest { timer: ReturnType; } +export class WebUIMutationError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + this.name = "WebUIMutationError"; + } +} + interface PendingChatRequest extends PendingRequest { temporary: boolean; } @@ -203,6 +213,7 @@ export class NanobotClient { private pendingNewChat: PendingChatRequest | null = null; private pendingTranscriptions = new Map>(); private pendingSystemCommands = new Map>(); + private pendingWebUIRequests = new Map>(); // Frames queued while the socket is not yet OPEN private sendQueue: Outbound[] = []; private reconnectAttempts = 0; @@ -807,6 +818,60 @@ export class NanobotClient { }); } + /** + * Send one non-replayable WebUI mutation over the authenticated socket. + * A client-side timeout only abandons the reply; the server may finish work + * that already started, so timed-out requests are never retried automatically. + */ + requestMutation( + action: string, + payload: Record = {}, + timeoutMs: number = 20_000, + ): Promise { + const socket = this.socket; + if (!socket || socket.readyState !== WS_OPEN) { + return Promise.reject( + new WebUIMutationError(503, "WebUI connection is not open"), + ); + } + const requestId = crypto.randomUUID(); + const frame: Outbound = { + type: "webui_request", + request_id: requestId, + action, + payload, + }; + if (!this.frameFitsTransport(frame)) { + return Promise.reject( + new WebUIMutationError(413, "WebUI mutation payload is too large"), + ); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingWebUIRequests.delete(requestId); + reject( + new WebUIMutationError( + 504, + `WebUI request timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + this.pendingWebUIRequests.set(requestId, { + resolve: (value) => resolve(value as T), + reject, + timer, + }); + try { + socket.send(JSON.stringify(frame)); + } catch { + clearTimeout(timer); + this.pendingWebUIRequests.delete(requestId); + reject(new WebUIMutationError(503, "Could not send WebUI request")); + } + }); + } + /** Ask the server to create a non-destructive fork before a user-message index. */ forkChat( sourceChatId: string, @@ -914,8 +979,8 @@ export class NanobotClient { }); } - setSidebarState(state: SidebarStatePayload): void { - this.queueSend({ type: "set_sidebar_state", state }); + setSidebarState(state: SidebarStatePayload): Promise { + return this.requestMutation("sidebar.update", { state }); } // -- internals --------------------------------------------------------- @@ -965,6 +1030,23 @@ export class NanobotClient { console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed)); } + if (parsed.event === "webui_response") { + const pending = this.pendingWebUIRequests.get(parsed.request_id); + if (!pending) return; + clearTimeout(pending.timer); + this.pendingWebUIRequests.delete(parsed.request_id); + if (parsed.ok) { + pending.resolve(parsed.result); + } else { + const status = Number.isFinite(parsed.error?.status) + ? parsed.error.status + : 500; + const message = parsed.error?.message || "WebUI mutation failed"; + pending.reject(new WebUIMutationError(status, message)); + } + return; + } + if (parsed.event === "error" && !parsed.turn_id) { const fallback = this.legacyRejectionTarget(parsed); if (fallback) { @@ -1151,6 +1233,13 @@ export class NanobotClient { this.pendingNewChat = null; } this.rejectAllTranscriptions("socket closed"); + for (const pending of this.pendingWebUIRequests.values()) { + clearTimeout(pending.timer); + pending.reject( + new WebUIMutationError(503, "Socket closed before WebUI response"), + ); + } + this.pendingWebUIRequests.clear(); for (const pending of this.pendingSystemCommands.values()) { clearTimeout(pending.timer); pending.reject(new Error("socket closed")); diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index b40026832..528e122c5 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -1261,6 +1261,18 @@ export type InboundEvent = detail?: string; provider?: string; } + | { + event: "webui_response"; + request_id: string; + ok: true; + result: unknown; + } + | { + event: "webui_response"; + request_id: string; + ok: false; + error: { status: number; message: string }; + } | { event: "error"; chat_id?: string; @@ -1339,6 +1351,12 @@ export interface FilePreviewPayload { export type Outbound = | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } | { type: "new_temporary_chat" } + | { + type: "webui_request"; + request_id: string; + action: string; + payload: Record; + } | { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string } | { type: "attach"; chat_id: string } | { type: "set_sidebar_state"; state: SidebarStatePayload } diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index 634835031..51494d27c 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -59,8 +59,19 @@ import { validateChannel, } from "@/lib/api"; +const requestMutation = vi.fn(); +const mutationTransport = { + requestMutation: ( + action: string, + payload?: Record, + timeoutMs?: number, + ) => requestMutation(action, payload, timeoutMs) as Promise, +}; + describe("webui API helpers", () => { beforeEach(() => { + requestMutation.mockReset(); + requestMutation.mockResolvedValue({}); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -184,88 +195,74 @@ describe("webui API helpers", () => { it("validates channel settings with form values", async () => { await validateChannel( - "tok", + mutationTransport, "slack", { "channels.slack.botToken": "xoxb-test" }, { instanceId: "default" }, ); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/channels/validate?name=slack&instance_id=default", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer tok", - "X-Nanobot-Channel-Values": JSON.stringify({ - "channels.slack.botToken": "xoxb-test", - }), - }), - }), - ); - expect(fetch).not.toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ method: "POST" }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.channel.validate", + { + name: "slack", + instance_id: "default", + values: { "channels.slack.botToken": "xoxb-test" }, + }, + 20_000, ); + expect(fetch).not.toHaveBeenCalled(); }); - it("configures channels through the WebSocket HTTP shim", async () => { + it("configures channels through the authenticated WebSocket", async () => { await configureChannel( - "tok", + mutationTransport, "discord", { "channels.discord.token": "saved-secret" }, { enable: true }, ); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/channels/configure?name=discord&enable=true", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer tok", - "X-Nanobot-Channel-Values": JSON.stringify({ - "channels.discord.token": "saved-secret", - }), - }), - }), - ); - expect(fetch).not.toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ method: "POST" }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.channel.configure", + { + name: "discord", + enable: true, + values: { "channels.discord.token": "saved-secret" }, + }, + 150_000, ); + expect(fetch).not.toHaveBeenCalled(); }); - it("serializes channel QR connect helpers", async () => { - await startChannelConnect("tok", "weixin", { force: true }); - expect(fetch).toHaveBeenLastCalledWith( - "/api/settings/channels/weixin/connect/start?force=true", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + it("serializes channel QR connect request envelopes", async () => { + await startChannelConnect(mutationTransport, "weixin", { force: true }); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.channel.connect.start", + { channel: "weixin", force: true }, + 150_000, ); - await pollChannelConnect("tok", "weixin", "session+/="); - expect(fetch).toHaveBeenLastCalledWith( - "/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await pollChannelConnect(mutationTransport, "weixin", "session+/="); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.channel.connect.poll", + { channel: "weixin", session_id: "session+/=" }, + 150_000, ); - await cancelChannelConnect("tok", "weixin", "session+/="); - expect(fetch).toHaveBeenLastCalledWith( - "/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await cancelChannelConnect(mutationTransport, "weixin", "session+/="); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.channel.connect.cancel", + { channel: "weixin", session_id: "session+/=" }, + 20_000, ); }); it("serializes workspace automation actions", async () => { - await runAutomationAction("tok", "disable", "job 1/2"); + await runAutomationAction(mutationTransport, "disable", "job 1/2"); - expect(fetch).toHaveBeenCalledWith( - "/api/webui/automations/disable?id=job+1%2F2", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "automation.disable", + { id: "job 1/2" }, + 20_000, ); }); @@ -275,19 +272,14 @@ describe("webui API helpers", () => { message: "Ask 今日 quiz", schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" }, } as const; - await updateAutomation("tok", "job 1/2", values); + await updateAutomation(mutationTransport, "job 1/2", values); - expect(fetch).toHaveBeenCalledWith( - "/api/webui/automations/update?id=job+1%2F2", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)), - }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "automation.update", + { id: "job 1/2", values }, + 20_000, ); - const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record; - expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日"); + expect(fetch).not.toHaveBeenCalled(); }); it("fetches the WebUI skill summary", async () => { @@ -348,66 +340,66 @@ describe("webui API helpers", () => { ); }); - it("encodes provider install coordinates", async () => { + it("sends provider install coordinates without placing them in a URL", async () => { await installMarketplaceSkill( - "tok", + mutationTransport, "skillhub", "@tencent/skills", "ima-skills", "1.1.8", ); - expect(fetch).toHaveBeenCalledWith( - "/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "skill.install", + { + provider: "skillhub", + source: "@tencent/skills", + skill: "ima-skills", + version: "1.1.8", + }, + 150_000, ); }); - it("updates and deletes installed skills with encoded names", async () => { - await updateSkillEnabled("tok", "custom skill", false); + it("updates and deletes installed skills over the WebSocket", async () => { + await updateSkillEnabled(mutationTransport, "custom skill", false); - expect(fetch).toHaveBeenCalledWith( - "/api/webui/skills/update?name=custom+skill&enabled=false", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenLastCalledWith( + "skill.update", + { name: "custom skill", enabled: false }, + 20_000, ); - await deleteSkill("tok", "custom skill"); - expect(fetch).toHaveBeenCalledWith( - "/api/webui/skills/delete?name=custom+skill", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await deleteSkill(mutationTransport, "custom skill"); + expect(requestMutation).toHaveBeenLastCalledWith( + "skill.delete", + { name: "custom skill" }, + 20_000, ); }); - it("percent-encodes websocket keys when deleting a session", async () => { - await deleteSession("tok", "websocket:chat-1"); + it("sends the session key in a mutation payload", async () => { + await deleteSession(mutationTransport, "websocket:chat-1"); - expect(fetch).toHaveBeenCalledWith( - "/api/sessions/websocket%3Achat-1/delete", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "session.delete", + { key: "websocket:chat-1" }, + 20_000, ); }); it("passes the automation cascade flag when deleting a session", async () => { - await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true }); + await deleteSession(mutationTransport, "websocket:chat-1", { deleteAutomations: true }); - expect(fetch).toHaveBeenCalledWith( - "/api/sessions/websocket%3Achat-1/delete?delete_automations=true", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "session.delete", + { key: "websocket:chat-1", delete_automations: true }, + 20_000, ); }); - it("serializes settings updates as a narrow query string", async () => { - await updateSettings("tok", { + it("serializes settings updates as a narrow mutation payload", async () => { + await updateSettings(mutationTransport, { modelPreset: "default", model: "openrouter/test", provider: "openrouter", @@ -416,11 +408,17 @@ describe("webui API helpers", () => { toolHintMaxLength: 120, }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.agent.update", + { + model_preset: "default", + model: "openrouter/test", + provider: "openrouter", + context_window_tokens: 262144, + timezone: "Asia/Shanghai", + tool_hint_max_length: 120, + }, + 20_000, ); }); @@ -436,7 +434,7 @@ describe("webui API helpers", () => { }); it("serializes model configuration creation", async () => { - await createModelConfiguration("tok", { + await createModelConfiguration(mutationTransport, { label: "Fast writing", provider: "openai", model: "openai/gpt-4.1-mini", @@ -446,16 +444,23 @@ describe("webui API helpers", () => { reasoningEffort: "high", }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/model-configurations/create?label=Fast+writing&provider=openai&model=openai%2Fgpt-4.1-mini&max_tokens=4096&context_window_tokens=128000&temperature=0.4&reasoning_effort=high", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.model_configuration.create", + { + label: "Fast writing", + provider: "openai", + model: "openai/gpt-4.1-mini", + max_tokens: 4096, + context_window_tokens: 128000, + temperature: 0.4, + reasoning_effort: "high", + }, + 20_000, ); }); it("serializes model configuration updates", async () => { - await updateModelConfiguration("tok", { + await updateModelConfiguration(mutationTransport, { name: "codex", label: "Codex", provider: "openai_codex", @@ -466,42 +471,47 @@ describe("webui API helpers", () => { reasoningEffort: null, }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&max_tokens=8192&context_window_tokens=65536&temperature=0&reasoning_effort=", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.model_configuration.update", + { + name: "codex", + label: "Codex", + provider: "openai_codex", + model: "openai-codex/gpt-5.5", + max_tokens: 8192, + context_window_tokens: 65536, + temperature: 0, + reasoning_effort: "", + }, + 20_000, ); }); it("serializes model preset deletion and migration", async () => { - await deleteModelConfiguration("tok", "spare"); - await migrateModelConfigurations("tok"); + await deleteModelConfiguration(mutationTransport, "spare"); + await migrateModelConfigurations(mutationTransport); - expect(fetch).toHaveBeenNthCalledWith( + expect(requestMutation).toHaveBeenNthCalledWith( 1, - "/api/settings/model-configurations/delete?name=spare", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + "settings.model_configuration.delete", + { name: "spare" }, + 20_000, ); - expect(fetch).toHaveBeenNthCalledWith( + expect(requestMutation).toHaveBeenNthCalledWith( 2, - "/api/settings/model-configurations/migrate", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + "settings.model_configuration.migrate", + {}, + 20_000, ); }); it("serializes model call order as an ordered JSON array", async () => { - await updateModelCallOrder("tok", ["backup", "primary"]); + await updateModelCallOrder(mutationTransport, ["backup", "primary"]); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.model_call_order.update", + { order: ["backup", "primary"] }, + 20_000, ); }); @@ -516,28 +526,20 @@ describe("webui API helpers", () => { }), ); - await expect( - updateModelConfiguration("tok", { - name: "codex", - model: "openai-codex/gpt-5.5", - }), - ).rejects.toMatchObject({ + await expect(fetchApiService("tok")).rejects.toMatchObject({ status: 200, message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.", }); }); - it("surfaces API error response bodies", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "npm error ENOTEMPTY", - }), + it("surfaces correlated WebSocket mutation errors", async () => { + requestMutation.mockRejectedValueOnce( + Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }), ); - await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({ + await expect( + runCliAppAction(mutationTransport, "install", "hyperframes"), + ).rejects.toMatchObject({ status: 500, message: "npm error ENOTEMPTY", }); @@ -555,50 +557,45 @@ describe("webui API helpers", () => { await pending; }); - it("serializes provider settings updates without returning secrets", async () => { - await updateProviderSettings("tok", { + it("keeps provider secrets in the WebSocket payload", async () => { + await updateProviderSettings(mutationTransport, { provider: "openrouter", apiKey: "sk-or-test", apiBase: "https://openrouter.ai/api/v1", }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/update?provider=openrouter", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ - apiKey: "sk-or-test", - apiBase: "https://openrouter.ai/api/v1", - })), - }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.provider.update", + { + provider: "openrouter", + apiKey: "sk-or-test", + apiBase: "https://openrouter.ai/api/v1", + }, + 20_000, ); + expect(fetch).not.toHaveBeenCalled(); }); it("serializes OAuth provider advanced settings", async () => { - await updateProviderSettings("tok", { + await updateProviderSettings(mutationTransport, { provider: "xai_grok", proxy: "http://127.0.0.1:7890", extraBody: '{"tools":[]}', }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/update?provider=xai_grok", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ - proxy: "http://127.0.0.1:7890", - extraBody: '{"tools":[]}', - })), - }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.provider.update", + { + provider: "xai_grok", + proxy: "http://127.0.0.1:7890", + extraBody: '{"tools":[]}', + }, + 20_000, ); }); it("serializes custom provider creation with advanced settings", async () => { - await createProviderSettings("tok", { + const update = { name: "Company Gateway", apiKey: "sk-company", apiBase: "https://gateway.example/v1", @@ -607,25 +604,13 @@ describe("webui API helpers", () => { extraQuery: '{"api-version":"2026-01-01"}', proxy: "http://127.0.0.1:7890", thinkingStyle: "enable_thinking", - }); + }; + await createProviderSettings(mutationTransport, update); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/create", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ - name: "Company Gateway", - apiKey: "sk-company", - apiBase: "https://gateway.example/v1", - extraHeaders: '{"X-Tenant":"engineering"}', - extraBody: '{"service_tier":"priority"}', - extraQuery: '{"api-version":"2026-01-01"}', - proxy: "http://127.0.0.1:7890", - thinkingStyle: "enable_thinking", - })), - }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.provider.create", + update, + 20_000, ); }); @@ -641,74 +626,65 @@ describe("webui API helpers", () => { }); it("serializes provider OAuth login and logout actions", async () => { - await loginProviderOAuth("tok", "openai_codex"); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login?provider=openai_codex", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await loginProviderOAuth(mutationTransport, "openai_codex"); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.provider.oauth_login", + { provider: "openai_codex" }, + 20_000, ); - await loginProviderOAuth("tok", "openai_codex", "", true); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await loginProviderOAuth(mutationTransport, "openai_codex", true); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.provider.oauth_login", + { provider: "openai_codex", remote_browser: true }, + 20_000, ); - await completeProviderOAuth("tok", "xai_grok", "flow-123"); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123"); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.provider.oauth_complete", + { provider: "xai_grok", flow_id: "flow-123" }, + 20_000, ); await completeProviderOAuth( - "tok", + mutationTransport, "xai_grok", "flow-123", "secret", ); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-OAuth-Code": "secret", - }, - }), + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.provider.oauth_complete", + { provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" }, + 20_000, ); await completeProviderOAuth( - "tok", + mutationTransport, "openai_codex", "flow-codex", "http://localhost:1455/auth/callback?code=secret&state=test", ); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-OAuth-Callback": - "http://localhost:1455/auth/callback?code=secret&state=test", - }, - }), + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.provider.oauth_complete", + { + provider: "openai_codex", + flow_id: "flow-codex", + authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test", + }, + 20_000, ); - await logoutProviderOAuth("tok", "openai_codex"); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/provider/oauth-logout?provider=openai_codex", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await logoutProviderOAuth(mutationTransport, "openai_codex"); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.provider.oauth_logout", + { provider: "openai_codex" }, + 20_000, ); }); it("serializes web search settings updates", async () => { - await updateWebSearchSettings("tok", { + await updateWebSearchSettings(mutationTransport, { provider: "searxng", baseUrl: "https://search.example.com", maxResults: 8, @@ -716,30 +692,37 @@ describe("webui API helpers", () => { useJinaReader: false, }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.web_search.update", + { + provider: "searxng", + base_url: "https://search.example.com", + max_results: 8, + timeout: 45, + use_jina_reader: false, + }, + 20_000, ); }); it("serializes network safety settings updates", async () => { - await updateNetworkSafetySettings("tok", { + await updateNetworkSafetySettings(mutationTransport, { webuiAllowLocalServiceAccess: false, webuiDefaultAccessMode: "full", }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.network_safety.update", + { + webui_allow_local_service_access: false, + webui_default_access_mode: "full", + }, + 20_000, ); }); it("serializes image generation settings updates", async () => { - await updateImageGenerationSettings("tok", { + await updateImageGenerationSettings(mutationTransport, { enabled: true, provider: "openrouter", model: "openai/gpt-5.4-image-2", @@ -748,11 +731,17 @@ describe("webui API helpers", () => { maxImagesPerTurn: 3, }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.image_generation.update", + { + enabled: true, + provider: "openrouter", + model: "openai/gpt-5.4-image-2", + default_aspect_ratio: "16:9", + default_image_size: "2K", + max_images_per_turn: 3, + }, + 20_000, ); }); @@ -774,12 +763,11 @@ describe("webui API helpers", () => { }), ); - await runCliAppAction("tok", "install", "gimp"); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/cli-apps/install?name=gimp", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await runCliAppAction(mutationTransport, "install", "gimp"); + expect(requestMutation).toHaveBeenCalledWith( + "settings.cli_app.install", + { name: "gimp" }, + 150_000, ); }); @@ -819,20 +807,18 @@ describe("webui API helpers", () => { }), ); - await enableNanobotFeature("tok", "matrix"); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/nanobot-features/enable?name=matrix", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await enableNanobotFeature(mutationTransport, "matrix"); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.feature.enable", + { name: "matrix" }, + 150_000, ); - await disableNanobotFeature("tok", "matrix"); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/nanobot-features/disable?name=matrix", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + await disableNanobotFeature(mutationTransport, "matrix"); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.feature.disable", + { name: "matrix" }, + 20_000, ); }); @@ -843,34 +829,31 @@ describe("webui API helpers", () => { expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), ); - await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120", - expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + await startApiService( + mutationTransport, + { host: "127.0.0.1", port: 8900, timeout: 120 }, + ); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.api_service.start", + { host: "127.0.0.1", port: 8900, timeout: 120 }, + 150_000, ); await startApiService( - "tok", + mutationTransport, { host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" }, ); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }), - }, - }), - ); - expect(fetch).not.toHaveBeenCalledWith( - expect.stringContaining("secret-token"), - expect.anything(), + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.api_service.start", + { host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" }, + 150_000, ); - await stopApiService("tok"); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/api-service/stop", - expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + await stopApiService(mutationTransport); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.api_service.stop", + {}, + 20_000, ); }); @@ -891,71 +874,46 @@ describe("webui API helpers", () => { }), ); - await runMcpPresetAction("tok", "enable", "browserbase", { + await runMcpPresetAction(mutationTransport, "enable", "browserbase", { browserbase_api_key: "bb_live_test", }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/mcp-presets/enable?name=browserbase", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer tok", - "X-Nanobot-MCP-Values": JSON.stringify({ - browserbase_api_key: "bb_live_test", - }), - }), - }), + expect(requestMutation).toHaveBeenCalledWith( + "settings.mcp.enable", + { name: "browserbase", browserbase_api_key: "bb_live_test" }, + 20_000, ); }); it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => { - await saveCustomMcpServer("tok", { + const custom = { name: "docs", transport: "stdio", command: "npx", args: '["-y","docs-mcp"]', env: '{"API_KEY":"secret"}', - }); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/mcp-presets/custom", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer tok", - "X-Nanobot-MCP-Values": JSON.stringify({ - name: "docs", - transport: "stdio", - command: "npx", - args: '["-y","docs-mcp"]', - env: '{"API_KEY":"secret"}', - }), - }), - }), + }; + await saveCustomMcpServer(mutationTransport, custom); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.mcp.custom", + custom, + 20_000, ); - await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}'); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/mcp-presets/import", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer tok", - "X-Nanobot-MCP-Values": JSON.stringify({ - config: '{"mcpServers":{"docs":{"command":"npx"}}}', - }), - }), - }), + await importMcpConfig( + mutationTransport, + '{"mcpServers":{"docs":{"command":"npx"}}}', + ); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.mcp.import", + { config: '{"mcpServers":{"docs":{"command":"npx"}}}' }, + 20_000, ); - await updateMcpServerTools("tok", "docs", ["search", "fetch"]); - expect(fetch).toHaveBeenCalledWith( - "/api/settings/mcp-presets/tools", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer tok", - "X-Nanobot-MCP-Values": JSON.stringify({ - name: "docs", - enabled_tools: ["search", "fetch"], - }), - }), - }), + await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]); + expect(requestMutation).toHaveBeenLastCalledWith( + "settings.mcp.tools", + { name: "docs", enabled_tools: ["search", "fetch"] }, + 20_000, ); }); @@ -991,19 +949,12 @@ describe("webui API helpers", () => { }), ); - await updateSidebarState("tok", state); - const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!; - expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true); - expect(init).toEqual(expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - })); - const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state"); - expect(encodedState).toBeTruthy(); - expect(JSON.parse(encodedState ?? "{}")).toMatchObject({ - pinned_keys: ["websocket:chat-1"], - title_overrides: { "websocket:chat-1": "Release" }, - project_name_overrides: { "/Users/me/nanobot": "Core" }, - }); + await updateSidebarState(mutationTransport, state); + expect(requestMutation).toHaveBeenCalledWith( + "sidebar.update", + { state }, + 20_000, + ); }); it("fetches workspace project state", async () => { diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 1f1f30f87..32abc7017 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -19,6 +19,7 @@ const toggleThemeSpy = vi.fn(); const updateUrlSpy = vi.fn(); const attachSpy = vi.fn(); const setSidebarStateSpy = vi.fn(); +const requestMutationSpy = vi.fn(); const discardTemporaryChatSpy = vi.fn(); const newTemporaryChatSpy = vi.fn<() => Promise>(); const sendMessageSpy = vi.fn(); @@ -242,6 +243,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => { newTemporaryChat = newTemporaryChatSpy; attach = attachSpy; setSidebarState = setSidebarStateSpy; + requestMutation = requestMutationSpy; discardTemporaryChat = discardTemporaryChatSpy; close = vi.fn(); updateUrl = updateUrlSpy; @@ -270,7 +272,8 @@ describe("App layout", () => { getSessionAutomationsSpy.mockReset().mockResolvedValue([]); toggleThemeSpy.mockReset(); attachSpy.mockReset(); - setSidebarStateSpy.mockReset(); + setSidebarStateSpy.mockReset().mockResolvedValue({}); + requestMutationSpy.mockReset(); discardTemporaryChatSpy.mockReset(); let temporaryChatCounter = 0; newTemporaryChatSpy.mockImplementation(async () => ( @@ -877,40 +880,36 @@ describe("App layout", () => { }], raw_markdown: "---\nname: github\n---\nUse GitHub CLI.", }, - "/api/webui/skills/update?name=github&enabled=false": { - skills: [ - { - name: "cron", - description: "Schedule reminders.", - source: "builtin", - enabled: true, - deletable: false, - available: true, - }, - { - name: "github", - description: "Work with GitHub.", - source: "builtin", - enabled: false, - deletable: false, - available: false, - unavailable_reason: "CLI: gh", - }, - { - name: "custom-skill", - description: "A workspace skill.", - source: "workspace", - enabled: true, - deletable: true, - available: true, - }, - ], - last_action: { - name: "github", - enabled: false, - deleted: false, + }); + requestMutationSpy.mockResolvedValueOnce({ + skills: [ + { + name: "cron", + description: "Schedule reminders.", + source: "builtin", + enabled: true, + deletable: false, + available: true, }, - }, + { + name: "github", + description: "Work with GitHub.", + source: "builtin", + enabled: false, + deletable: false, + available: false, + unavailable_reason: "CLI: gh", + }, + { + name: "custom-skill", + description: "A workspace skill.", + source: "workspace", + enabled: true, + deletable: true, + available: true, + }, + ], + last_action: { name: "github", enabled: false, deleted: false }, }); render(); @@ -1010,14 +1009,10 @@ describe("App layout", () => { }, raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.", }, - "/api/webui/skills/delete?name=custom-skill": { - skills: [], - last_action: { - name: "custom-skill", - enabled: false, - deleted: true, - }, - }, + }); + requestMutationSpy.mockResolvedValueOnce({ + skills: [], + last_action: { name: "custom-skill", enabled: false, deleted: true }, }); render(); @@ -1149,9 +1144,8 @@ describe("App layout", () => { "/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": { trends: { "acme/agent-skills/react-testing": [] }, }, - "/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing": - () => pendingInstall, }); + requestMutationSpy.mockImplementationOnce(() => pendingInstall); render(); @@ -1199,11 +1193,14 @@ describe("App layout", () => { fireEvent.click(screen.getByRole("button", { name: "Install skill" })); await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - "/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing", - expect.objectContaining({ - headers: { Authorization: expect.any(String) }, - }), + expect(requestMutationSpy).toHaveBeenCalledWith( + "skill.install", + { + provider: "skills_sh", + source: "acme/agent-skills", + skill: "react-testing", + }, + 150_000, ); }); fireEvent.click(screen.getByRole("tab", { name: "Installed" })); @@ -1361,14 +1358,12 @@ describe("App layout", () => { mockFetchRoutes({ "/api/settings": baseSettingsPayload(), "/api/webui/automations": { jobs: [pastOneShot] }, - "/api/webui/automations/update?id=past-one-shot": { - jobs: [ - { - ...pastOneShot, - payload: { ...pastOneShot.payload, message: "Updated one-shot message" }, - }, - ], - }, + }); + requestMutationSpy.mockResolvedValueOnce({ + jobs: [{ + ...pastOneShot, + payload: { ...pastOneShot.payload, message: "Updated one-shot message" }, + }], }); render(); @@ -1394,20 +1389,18 @@ describe("App layout", () => { fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => { - expect(fetch).toHaveBeenCalledWith( - "/api/webui/automations/update?id=past-one-shot", - expect.any(Object), + expect(requestMutationSpy).toHaveBeenCalledWith( + "automation.update", + { + id: "past-one-shot", + values: { + name: "Past one-shot", + message: "Updated one-shot message", + }, + }, + 20_000, ); }); - const updateCall = vi.mocked(fetch).mock.calls.find( - ([url]) => String(url) === "/api/webui/automations/update?id=past-one-shot", - ); - expect(updateCall).toBeTruthy(); - const headers = updateCall?.[1]?.headers as Record; - expect(JSON.parse(decodeURIComponent(headers["X-Nanobot-Automation-Values"]))).toEqual({ - name: "Past one-shot", - message: "Updated one-shot message", - }); }); it("keeps long automation details expandable without nested scrolling", async () => { @@ -1829,6 +1822,9 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + act(() => { + statusHandlers.forEach((handler) => handler("open")); + }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); await waitFor(() => expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(), @@ -2581,17 +2577,14 @@ describe("App layout", () => { mockFetchRoutes({ "/api/settings": initialSettings, }); - const fetchMock = vi.mocked(fetch); window.history.replaceState(null, "", "/#/settings?section=runtime"); render(); expect(await screen.findByText("UTC")).toBeInTheDocument(); expect( - fetchMock.mock.calls.filter(([input]) => - String(input).startsWith("/api/settings/update?timezone="), - ), - ).toHaveLength(0); + requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"), + ).toBe(false); expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument(); expect( screen.queryByText("Used for schedules and time-aware replies."), diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index 9656f0195..3be628302 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -71,6 +71,122 @@ afterEach(() => { }); describe("NanobotClient", () => { + it("correlates successful WebUI mutation replies by request id", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + const socket = lastSocket(); + socket.fakeOpen(); + + const pending = client.requestMutation<{ saved: boolean }>( + "settings.provider.update", + { provider: "openrouter", apiKey: "secret" }, + ); + const frame = JSON.parse(socket.sent.at(-1) as string); + expect(frame).toMatchObject({ + type: "webui_request", + action: "settings.provider.update", + payload: { provider: "openrouter", apiKey: "secret" }, + }); + expect(frame.request_id).toEqual(expect.any(String)); + + socket.fakeMessage({ + event: "webui_response", + request_id: frame.request_id, + ok: true, + result: { saved: true }, + }); + + await expect(pending).resolves.toEqual({ saved: true }); + }); + + it("surfaces correlated WebUI mutation errors with status", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + const socket = lastSocket(); + socket.fakeOpen(); + + const pending = client.requestMutation("settings.channel.configure", {}); + const requestId = JSON.parse(socket.sent.at(-1) as string).request_id; + socket.fakeMessage({ + event: "webui_response", + request_id: requestId, + ok: false, + error: { status: 400, message: "missing channel name" }, + }); + + await expect(pending).rejects.toMatchObject({ + status: 400, + message: "missing channel name", + }); + }); + + it("times out WebUI mutations without replaying them", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + const socket = lastSocket(); + socket.fakeOpen(); + + const pending = expect( + client.requestMutation("skill.install", { skill: "docs" }, 25), + ).rejects.toMatchObject({ + status: 504, + message: "WebUI request timed out after 25ms", + }); + expect(socket.sent).toHaveLength(1); + await vi.advanceTimersByTimeAsync(25); + + await pending; + expect(socket.sent).toHaveLength(1); + }); + + it("rejects in-flight WebUI mutations when the socket closes", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + const socket = lastSocket(); + socket.fakeOpen(); + + const pending = client.requestMutation("session.delete", { + key: "websocket:chat-1", + }); + socket.fakeCloseWithCode(1006); + + await expect(pending).rejects.toMatchObject({ + status: 503, + message: "Socket closed before WebUI response", + }); + }); + + it("does not queue WebUI mutations before the authenticated socket opens", async () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + client.connect(); + + await expect(client.requestMutation("settings.agent.update", {})).rejects.toMatchObject({ + status: 503, + message: "WebUI connection is not open", + }); + expect(lastSocket().sent).toEqual([]); + }); + it("keeps temporary chats out of attachment and reconnect state", async () => { const client = new NanobotClient({ url: "ws://test", @@ -1071,7 +1187,7 @@ describe("NanobotClient", () => { expect(client.hasUnsettledRun("chat-scope-control")).toBe(true); }); - it("sends large sidebar ordering state outside the HTTP request line", () => { + it("sends large sidebar ordering state as a correlated WebUI request", async () => { const client = new NanobotClient({ url: "ws://test", reconnect: false, @@ -1102,11 +1218,29 @@ describe("NanobotClient", () => { client.connect(); lastSocket().fakeOpen(); - client.setSidebarState(state); + const pending = client.setSidebarState(state); const [serialized] = lastSocket().sent; expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192); - expect(JSON.parse(serialized)).toEqual({ type: "set_sidebar_state", state }); + const request = JSON.parse(serialized) as { + type: string; + request_id: string; + action: string; + payload: { state: SidebarStatePayload }; + }; + expect(request).toEqual({ + type: "webui_request", + request_id: expect.any(String), + action: "sidebar.update", + payload: { state }, + }); + lastSocket().fakeMessage({ + event: "webui_response", + request_id: request.request_id, + ok: true, + result: state, + }); + await expect(pending).resolves.toEqual(state); }); it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => { diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx index 073da4b52..0e391f335 100644 --- a/webui/src/tests/settings-view.test.tsx +++ b/webui/src/tests/settings-view.test.tsx @@ -10,6 +10,8 @@ import type { SettingsPayload, } from "@/lib/types"; +const requestMutationMock = vi.fn(); + function jsonResponse(body: unknown): Response { return { ok: true, @@ -353,7 +355,7 @@ function renderSettingsView( } = {}, ) { render( - + { "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."; beforeEach(() => { + requestMutationMock.mockReset().mockResolvedValue(settingsPayload()); vi.stubGlobal( "matchMedia", vi.fn((query: string) => ({ @@ -572,12 +575,15 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [], enabled_count: 0 }); } - if (url === "/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120") { - return jsonResponse({ ...stopped, installed: true, running: true, managed: true }); - } return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + ...stopped, + installed: true, + running: true, + managed: true, + }); renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true }); @@ -586,9 +592,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(startButton); await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120", - expect.any(Object), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.api_service.start", + { host: "127.0.0.1", port: 8900, timeout: 120 }, + 150_000, ); }); }); @@ -609,21 +616,19 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url === "/api/settings/cli-apps/uninstall?name=anygen") { - return jsonResponse({ - apps: [{ ...installedAnyGen, installed: false, status: "available" }], - installed_count: 0, - catalog_updated_at: "2026-04-18", - last_action: { - ok: true, - message: "Uninstalled CLI for AnyGen.", - still_available: false, - }, - }); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + apps: [{ ...installedAnyGen, installed: false, status: "available" }], + installed_count: 0, + catalog_updated_at: "2026-04-18", + last_action: { + ok: true, + message: "Uninstalled CLI for AnyGen.", + still_available: false, + }, + }); renderSettingsView(); @@ -634,11 +639,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(uninstall); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/cli-apps/uninstall?name=anygen", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.cli_app.uninstall", + { name: "anygen" }, + 20_000, ), ); expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument(); @@ -719,8 +723,12 @@ describe("SettingsView Apps catalog", () => { enabled_count: 0, }); } - if (url === "/api/settings/nanobot-features/enable?name=matrix") { - return jsonResponse({ + return { ok: false, status: 404, json: async () => ({}) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockImplementation(async (action: string) => { + if (action === "settings.feature.enable") { + return { features: [{ name: "matrix", display_name: "Matrix", @@ -737,10 +745,10 @@ describe("SettingsView Apps catalog", () => { }], enabled_count: 1, last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true }, - }); + }; } - if (url === "/api/settings/nanobot-features/disable?name=matrix") { - return jsonResponse({ + if (action === "settings.feature.disable") { + return { features: [{ name: "matrix", display_name: "Matrix", @@ -756,11 +764,10 @@ describe("SettingsView Apps catalog", () => { enabled_count: 0, requires_restart: true, last_action: { ok: true, message: "Disabled channel 'matrix'", enabled: false }, - }); + }; } - return { ok: false, status: 404, json: async () => ({}) } as Response; + return settingsPayload(); }); - vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "channels" }); @@ -772,18 +779,14 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" })); expect(screen.getByRole("dialog", { name: "Install support for Matrix?" })).toBeInTheDocument(); expect(screen.getByText("nanobot will add what Matrix needs, then turn it on. Continue?")).toBeInTheDocument(); - expect(fetchMock).not.toHaveBeenCalledWith( - "/api/settings/nanobot-features/enable?name=matrix", - expect.anything(), - ); + expect(requestMutationMock).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Install and enable" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/nanobot-features/enable?name=matrix", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.feature.enable", + { name: "matrix" }, + 150_000, ), ); await waitFor(() => @@ -801,11 +804,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("switch", { name: "Matrix channel" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/nanobot-features/disable?name=matrix", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.feature.disable", + { name: "matrix" }, + 20_000, ), ); await waitFor(() => @@ -839,26 +841,24 @@ describe("SettingsView Apps catalog", () => { enabled_count: 1, }); } - if (url === "/api/settings/nanobot-features/enable?name=matrix") { - return jsonResponse({ - features: [{ - name: "matrix", - display_name: "Matrix", - type: "channel", - enabled: true, - installed: true, - ready: true, - status: "enabled", - install_supported: true, - requires_restart: true, - }], - enabled_count: 1, - last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true }, - }); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + features: [{ + name: "matrix", + display_name: "Matrix", + type: "channel", + enabled: true, + installed: true, + ready: true, + status: "enabled", + install_supported: true, + requires_restart: true, + }], + enabled_count: 1, + last_action: { ok: true, message: "Enabled channel 'matrix'", enabled: true }, + }); renderSettingsView({ initialSection: "channels" }); @@ -872,11 +872,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Install and enable" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/nanobot-features/enable?name=matrix", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.feature.enable", + { name: "matrix" }, + 150_000, ), ); }); @@ -950,20 +949,18 @@ describe("SettingsView Apps catalog", () => { enabled_count: 0, }); } - if (url === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace") { - return jsonResponse({ - session_id: "feishu-session", - status: "pending", - qr_url: "https://accounts.feishu.cn/login?device_code=device", - domain: "feishu", - interval_ms: 5000, - expires_at_ms: Date.now() + 600_000, - message: "Scan with Feishu or Lark to connect.", - }); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + session_id: "feishu-session", + status: "pending", + qr_url: "https://accounts.feishu.cn/login?device_code=device", + domain: "feishu", + interval_ms: 5000, + expires_at_ms: Date.now() + 600_000, + message: "Scan with Feishu or Lark to connect.", + }); renderSettingsView({ initialSection: "channels" }); @@ -973,11 +970,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Connect" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.channel.connect.start", + { channel: "feishu", domain: "feishu", instance_id: "default", mode: "replace" }, + 150_000, ), ); expect(await screen.findByText("Scan with Feishu")).toBeInTheDocument(); @@ -1008,20 +1004,18 @@ describe("SettingsView Apps catalog", () => { enabled_count: 0, }); } - if (url === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace") { - return jsonResponse({ - session_id: "feishu-switch-session", - status: "pending", - qr_url: "https://accounts.feishu.cn/login?device_code=switch-device", - domain: "feishu", - interval_ms: 5000, - expires_at_ms: Date.now() + 600_000, - message: "Scan with Feishu or Lark to connect.", - }); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + session_id: "feishu-switch-session", + status: "pending", + qr_url: "https://accounts.feishu.cn/login?device_code=switch-device", + domain: "feishu", + interval_ms: 5000, + expires_at_ms: Date.now() + 600_000, + message: "Scan with Feishu or Lark to connect.", + }); renderSettingsView({ initialSection: "channels" }); @@ -1030,11 +1024,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Connect" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.channel.connect.start", + { channel: "feishu", domain: "feishu", instance_id: "default", mode: "replace" }, + 150_000, ), ); expect(await screen.findByText("Scan with Feishu")).toBeInTheDocument(); @@ -1064,8 +1057,12 @@ describe("SettingsView Apps catalog", () => { enabled_count: 0, }); } - if (url === "/api/settings/nanobot-features/enable?name=feishu&instance_id=default") { - return jsonResponse({ + return { ok: false, status: 404, json: async () => ({}) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockImplementation(async (action: string) => { + if (action === "settings.feature.enable") { + return { features: [{ name: "feishu", display_name: "Feishu", @@ -1097,14 +1094,13 @@ describe("SettingsView Apps catalog", () => { enabled_count: 1, requires_restart: false, last_action: { ok: true, message: "Enabled channel 'feishu'", enabled: true }, - }); + }; } - if (url === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace") { + if (action === "settings.channel.connect.start") { throw new Error("Feishu connect should not start when credentials are already configured"); } - return { ok: false, status: 404, json: async () => ({}) } as Response; + return settingsPayload(); }); - vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "channels" }); @@ -1112,16 +1108,15 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(await screen.findByRole("switch", { name: "nanobot assistant" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/nanobot-features/enable?name=feishu&instance_id=default", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.feature.enable", + { name: "feishu", instance_id: "default" }, + 150_000, ), ); - expect(fetchMock.mock.calls.some(([input]) => - String(input) === "/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default&mode=replace", - )).toBe(false); + expect(requestMutationMock.mock.calls.some(([action]) => ( + action === "settings.channel.connect.start" + ))).toBe(false); expect(screen.getByRole("switch", { name: "nanobot assistant" })).toHaveAttribute( "aria-checked", "true", @@ -1310,7 +1305,6 @@ describe("SettingsView Apps catalog", () => { }); it("shows a single Feishu assistant without a duplicate assistant list", async () => { - const reconnectUrls: string[] = []; const feishuPayload = { features: [{ name: "feishu", @@ -1352,13 +1346,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); if (url === "/api/settings/nanobot-features") return jsonResponse(feishuPayload); - if (url === "/api/settings/nanobot-features/enable?name=feishu&instance_id=default") { - reconnectUrls.push(url); - return jsonResponse(feishuPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }), ); + requestMutationMock.mockResolvedValueOnce(feishuPayload); renderSettingsView({ initialSection: "channels" }); @@ -1374,7 +1365,11 @@ describe("SettingsView Apps catalog", () => { expect(screen.getByText("cli_sup...port")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Replace assistant" })).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Reconnect" })); - await waitFor(() => expect(reconnectUrls).toHaveLength(1)); + await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( + "settings.feature.enable", + { name: "feishu", instance_id: "default" }, + 150_000, + )); expect(document.querySelector('img[src="https://example.com/support.png"]')).toBeTruthy(); }); @@ -1577,8 +1572,19 @@ describe("SettingsView Apps catalog", () => { enabled_count: 0, }); } - if (url === "/api/settings/channels/configure?name=discord&enable=true") { - return jsonResponse({ + return { ok: false, status: 404, json: async () => ({}) } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + requestMutationMock + .mockResolvedValueOnce({ + name: "discord", + status: "ready", + checks: [], + missing_fields: [], + can_enable: true, + requires_restart: false, + }) + .mockResolvedValueOnce({ name: "discord", saved: true, saved_keys: [ @@ -1606,23 +1612,7 @@ describe("SettingsView Apps catalog", () => { enabled_count: 1, requires_restart: false, }, - }); - } - if (url === "/api/settings/channels/validate?name=discord") { - return jsonResponse({ - name: "discord", - status: "configured", - checks: [{ id: "bot_token", label: "Bot token", status: "pass" }], - identity: { name: "nanobot-test", account: "123" }, - missing_fields: [], - can_enable: true, - requires_restart: false, - message: "Configuration is present.", - }); - } - return { ok: false, status: 404, json: async () => ({}) } as Response; - }); - vi.stubGlobal("fetch", fetchMock); + }); renderSettingsView({ initialSection: "channels" }); @@ -1647,21 +1637,24 @@ describe("SettingsView Apps catalog", () => { await waitFor(() => expect( - fetchMock.mock.calls.some( - ([input]) => String(input) === "/api/settings/channels/configure?name=discord&enable=true", - ), + requestMutationMock.mock.calls.some(([action]) => ( + action === "settings.channel.configure" + )), ).toBe(true), ); - const configureCall = fetchMock.mock.calls.find( - ([input]) => String(input) === "/api/settings/channels/configure?name=discord&enable=true", + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.channel.configure", + { + name: "discord", + enable: true, + values: { + "channels.discord.token": "discord-token", + "channels.discord.allowChannels": "123, 456", + "channels.discord.groupPolicy": "open", + }, + }, + 150_000, ); - expect((configureCall?.[1] as RequestInit | undefined)?.method).toBeUndefined(); - const headers = (configureCall?.[1] as RequestInit | undefined)?.headers as Record; - expect(JSON.parse(headers["X-Nanobot-Channel-Values"])).toEqual({ - "channels.discord.token": "discord-token", - "channels.discord.allowChannels": "123, 456", - "channels.discord.groupPolicy": "open", - }); expect(await screen.findByText("Checked and enabled.")).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Discord channel" })).toHaveAttribute( "aria-checked", @@ -1703,29 +1696,27 @@ describe("SettingsView Apps catalog", () => { enabled_count: 0, }); } - if (url === "/api/settings/nanobot-features/enable?name=discord") { - return jsonResponse({ - features: [{ - name: "discord", - display_name: "Discord", - webui: "webui/index.ts", - type: "channel", - enabled: true, - configured: true, - installed: true, - ready: true, - status: "enabled", - install_supported: true, - requires_restart: true, - setup: channelSetupContract("discord"), - }], - enabled_count: 1, - requires_restart: false, - }); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + features: [{ + name: "discord", + display_name: "Discord", + webui: "webui/index.ts", + type: "channel", + enabled: true, + configured: true, + installed: true, + ready: true, + status: "enabled", + install_supported: true, + requires_restart: true, + setup: channelSetupContract("discord"), + }], + enabled_count: 1, + requires_restart: false, + }); renderSettingsView({ initialSection: "channels" }); @@ -1752,9 +1743,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("switch", { name: "Discord channel" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/nanobot-features/enable?name=discord", - expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.feature.enable", + { name: "discord" }, + 150_000, ), ); }); @@ -1980,10 +1972,7 @@ describe("SettingsView Apps catalog", () => { const websocketSwitch = screen.getByRole("switch", { name: "WebSocket channel" }); expect(websocketSwitch).toBeDisabled(); expect(websocketSwitch).toHaveAttribute("aria-checked", "true"); - expect(fetchMock).not.toHaveBeenCalledWith( - "/api/settings/nanobot-features/disable?name=websocket", - expect.anything(), - ); + expect(requestMutationMock).not.toHaveBeenCalled(); }); it("publishes the latest settings payload to the shell", async () => { @@ -2290,12 +2279,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url.startsWith("/api/settings/model-call-order/update?")) { - return jsonResponse(updatedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "models", initialSettings: payload }); @@ -2326,19 +2313,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.drop(primaryRow, { dataTransfer }); await waitFor(() => { - const saveCall = fetchMock.mock.calls.find(([input]) => - String(input).startsWith("/api/settings/model-call-order/update?"), - ); - expect(saveCall).toBeDefined(); - const url = new URL(String(saveCall?.[0]), "http://nanobot.test"); - expect(JSON.parse(url.searchParams.get("order") ?? "[]")).toEqual([ - "backup", - "primary", - ]); - expect(saveCall?.[1]).toEqual( - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.model_call_order.update", + { order: ["backup", "primary"] }, + 20_000, ); }); @@ -2385,12 +2363,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url.startsWith("/api/settings/model-call-order/update?")) { - return jsonResponse(updatedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "models", initialSettings: initialPayload }); @@ -2439,16 +2415,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url.startsWith("/api/settings/model-call-order/update?")) { - return { - ok: false, - status: 500, - text: async () => "Order update failed", - } as Response; - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockRejectedValueOnce(new Error("Order update failed")); renderSettingsView({ initialSection: "models", initialSettings: payload }); @@ -2503,12 +2473,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url.startsWith("/api/settings/model-call-order/update?")) { - return jsonResponse(orderedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(orderedPayload); renderSettingsView({ initialSection: "models", initialSettings: payloadWithCodex }); @@ -2524,16 +2492,11 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(enableSwitch); await waitFor(() => { - const orderCall = fetchMock.mock.calls.find(([input]) => - String(input).startsWith("/api/settings/model-call-order/update?"), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.model_call_order.update", + { order: ["primary", "backup", "codex"] }, + 20_000, ); - expect(orderCall).toBeDefined(); - const url = new URL(String(orderCall?.[0]), "http://nanobot.test"); - expect(JSON.parse(url.searchParams.get("order") ?? "[]")).toEqual([ - "primary", - "backup", - "codex", - ]); }); const enabledCodexRow = await screen.findByTestId("model-call-order-row-codex"); expect(enabledCodexRow).not.toHaveTextContent("Disabled"); @@ -2575,15 +2538,12 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url.startsWith("/api/settings/model-configurations/create?")) { - return jsonResponse(createdPayload); - } - if (url.startsWith("/api/settings/model-call-order/update?")) { - return jsonResponse(orderedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock + .mockResolvedValueOnce(createdPayload) + .mockResolvedValueOnce(orderedPayload); renderSettingsView({ initialSection: "models", initialSettings: payload }); @@ -2617,16 +2577,11 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(saveButton); await waitFor(() => { - const orderCall = fetchMock.mock.calls.find(([input]) => - String(input).startsWith("/api/settings/model-call-order/update?"), + expect(requestMutationMock).toHaveBeenLastCalledWith( + "settings.model_call_order.update", + { order: ["primary", "backup", "writer"] }, + 20_000, ); - expect(orderCall).toBeDefined(); - const url = new URL(String(orderCall?.[0]), "http://nanobot.test"); - expect(JSON.parse(url.searchParams.get("order") ?? "[]")).toEqual([ - "primary", - "backup", - "writer", - ]); }); const writerRow = await screen.findByTestId("model-call-order-row-writer"); expect(writerRow).not.toHaveTextContent("Disabled"); @@ -2663,12 +2618,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url === "/api/settings/model-configurations/migrate") { - return jsonResponse(migratedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(migratedPayload); renderSettingsView({ initialSection: "models", initialSettings: legacyPayload }); @@ -2677,11 +2630,10 @@ describe("SettingsView Apps catalog", () => { ); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/model-configurations/migrate", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.model_configuration.migrate", + {}, + 20_000, ), ); expect( @@ -2719,21 +2671,9 @@ describe("SettingsView Apps catalog", () => { authorization_url: "https://auth.x.ai/oauth2/authorize?state=test", expires_in: 600, }; - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if (url === "/api/settings/provider/oauth-login?provider=xai_grok") { - return jsonResponse(authorization); - } - if ( - url === - "/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123" - ) { - expect(init?.headers).toMatchObject({ - "X-Nanobot-OAuth-Code": "secret", - }); - return jsonResponse(signedIn); - } if (url === "/api/settings/cli-apps") { return jsonResponse({ apps: [], installed_count: 0 }); } @@ -2743,6 +2683,9 @@ describe("SettingsView Apps catalog", () => { return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock + .mockResolvedValueOnce(authorization) + .mockResolvedValueOnce(signedIn); const popup = { opener: window, location: { href: "about:blank" }, @@ -2757,9 +2700,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login?provider=xai_grok", - expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.oauth_login", + { provider: "xai_grok" }, + 20_000, ), ); expect(popup.opener).toBeNull(); @@ -2779,13 +2723,14 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Finish sign-in" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123", - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Nanobot-OAuth-Code": "secret", - }), - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.oauth_complete", + { + provider: "xai_grok", + flow_id: "flow-123", + authorization_response: "secret", + }, + 20_000, ), ); expect(await screen.findByText("Signed in as user@example.com")).toBeInTheDocument(); @@ -2825,19 +2770,6 @@ describe("SettingsView Apps catalog", () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if (url === "/api/settings/provider/oauth-login?provider=xai_grok") { - return jsonResponse(authorization); - } - if ( - url === - "/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-remote" - ) { - return jsonResponse({ - status: "pending", - provider: "xai_grok", - flow_id: "flow-remote", - }); - } if (url === "/api/settings/cli-apps") { return jsonResponse({ apps: [], installed_count: 0 }); } @@ -2847,6 +2779,7 @@ describe("SettingsView Apps catalog", () => { return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(authorization); const popup = { opener: window, location: { href: "about:blank" }, @@ -2924,19 +2857,9 @@ describe("SettingsView Apps catalog", () => { expires_in: 600, completion_input: "callback_url", }; - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if (url === "/api/settings/provider/oauth-login?provider=openai_codex") { - return jsonResponse(authorization); - } - if ( - url === - "/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex-local" - ) { - expect(init?.headers).not.toHaveProperty("X-Nanobot-OAuth-Callback"); - return jsonResponse(signedIn); - } if (url === "/api/settings/cli-apps") { return jsonResponse({ apps: [], installed_count: 0 }); } @@ -2946,6 +2869,9 @@ describe("SettingsView Apps catalog", () => { return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock + .mockResolvedValueOnce(authorization) + .mockResolvedValueOnce(signedIn); const openMock = vi.fn(); vi.stubGlobal("open", openMock); @@ -2955,9 +2881,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Sign in" })); const dialog = await screen.findByRole("dialog"); - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login?provider=openai_codex", - expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.oauth_login", + { provider: "openai_codex" }, + 20_000, ); expect(openMock).not.toHaveBeenCalled(); expect( @@ -3013,30 +2940,9 @@ describe("SettingsView Apps catalog", () => { }; const callbackUrl = "http://localhost:1455/auth/callback?code=secret&state=test"; - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if ( - url === - "/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true" - ) { - return jsonResponse(authorization); - } - if ( - url === - "/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex" - ) { - const headers = init?.headers as Record; - if (headers?.["X-Nanobot-OAuth-Callback"]) { - expect(headers["X-Nanobot-OAuth-Callback"]).toBe(callbackUrl); - return jsonResponse(signedIn); - } - return jsonResponse({ - status: "pending", - provider: "openai_codex", - flow_id: "flow-codex", - }); - } if (url === "/api/settings/cli-apps") { return jsonResponse({ apps: [], installed_count: 0 }); } @@ -3046,6 +2952,18 @@ describe("SettingsView Apps catalog", () => { return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockImplementation(async ( + action: string, + mutationPayload: Record, + ) => { + if (action === "settings.provider.oauth_login") return authorization; + if (mutationPayload.authorization_response === callbackUrl) return signedIn; + return { + status: "pending", + provider: "openai_codex", + flow_id: "flow-codex", + }; + }); const popup = { opener: window, location: { href: "about:blank" }, @@ -3093,13 +3011,14 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex", - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Nanobot-OAuth-Callback": callbackUrl, - }), - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.oauth_complete", + { + provider: "openai_codex", + flow_id: "flow-codex", + authorization_response: callbackUrl, + }, + 20_000, ), ); expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument(); @@ -3147,33 +3066,9 @@ describe("SettingsView Apps catalog", () => { }, ]; let payload: SettingsPayload = { ...base, providers }; - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if (url.startsWith("/api/settings/provider/update?")) { - const query = new URLSearchParams(url.split("?")[1]); - const providerName = query.get("provider"); - const headers = init?.headers as Record; - const values = JSON.parse(decodeURIComponent( - headers["X-Nanobot-Provider-Values"], - )) as { - proxy?: string; - extraBody?: string; - }; - payload = { - ...payload, - providers: payload.providers.map((provider) => - provider.name === providerName - ? { - ...provider, - proxy: values.proxy || null, - extra_body: values.extraBody ? JSON.parse(values.extraBody) : null, - } - : provider, - ), - }; - return jsonResponse(payload); - } if (url === "/api/settings/cli-apps") { return jsonResponse({ apps: [], installed_count: 0 }); } @@ -3183,6 +3078,24 @@ describe("SettingsView Apps catalog", () => { return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockImplementation(async ( + _action: string, + values: { provider?: string; proxy?: string; extraBody?: string }, + ) => { + payload = { + ...payload, + providers: payload.providers.map((provider) => + provider.name === values.provider + ? { + ...provider, + proxy: values.proxy || null, + extra_body: values.extraBody ? JSON.parse(values.extraBody) : null, + } + : provider, + ), + }; + return payload; + }); renderSettingsView({ initialSection: "models", initialSettings: payload }); @@ -3199,17 +3112,14 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/update?provider=xai_grok", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ - extraBody: "", - proxy: "http://127.0.0.1:7890", - })), - }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.update", + { + provider: "xai_grok", + extraBody: "", + proxy: "http://127.0.0.1:7890", + }, + 20_000, ), ); await waitFor(() => expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled()); @@ -3223,17 +3133,14 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/update?provider=openai_codex", - expect.objectContaining({ - headers: { - Authorization: "Bearer tok", - "X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ - extraBody: "", - proxy: "http://proxy.example:8080", - })), - }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.update", + { + provider: "openai_codex", + extraBody: "", + proxy: "http://proxy.example:8080", + }, + 20_000, ), ); }); @@ -3286,11 +3193,9 @@ describe("SettingsView Apps catalog", () => { }, ]; const payload: SettingsPayload = { ...base, providers }; - const fetchMock = vi.fn(async (...args: [RequestInfo | URL, RequestInit?]) => { - const [input] = args; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload); if (url === "/api/settings/cli-apps") { return jsonResponse({ apps: [], installed_count: 0 }); } @@ -3300,6 +3205,7 @@ describe("SettingsView Apps catalog", () => { return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValue(payload); renderSettingsView({ initialSection: "models", initialSettings: payload }); @@ -3308,9 +3214,10 @@ describe("SettingsView Apps catalog", () => { expect(xSearch).toHaveAttribute("aria-checked", "true"); fireEvent.click(xSearch); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/update?provider=xai_grok", - expect.anything(), + await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.update", + expect.objectContaining({ provider: "xai_grok" }), + 20_000, )); await waitFor(() => expect( screen.getByRole("button", { name: "Save provider" }), @@ -3320,9 +3227,10 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "OpenAI Codex" })); fireEvent.click(screen.getByRole("switch", { name: "Fast mode" })); fireEvent.click(screen.getByRole("button", { name: "Save provider" })); - await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/provider/update?provider=openai_codex", - expect.anything(), + await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( + "settings.provider.update", + expect.objectContaining({ provider: "openai_codex" }), + 20_000, )); await waitFor(() => expect( screen.getByRole("button", { name: "Save provider" }), @@ -3347,17 +3255,17 @@ describe("SettingsView Apps catalog", () => { ).not.toBeInTheDocument()); await waitFor(() => { - const requestUpdates = fetchMock.mock.calls - .filter(([input]) => String(input).startsWith("/api/settings/provider/update?")) - .map(([input, init]) => { - const provider = new URLSearchParams(String(input).split("?")[1]).get("provider"); - const headers = init?.headers as Record; - const values = JSON.parse(decodeURIComponent( - headers["X-Nanobot-Provider-Values"], - )) as { apiType?: string; extraBody?: string }; - return [provider, { - ...(values.apiType ? { apiType: values.apiType } : {}), - extraBody: JSON.parse(values.extraBody ?? "{}"), + const requestUpdates = requestMutationMock.mock.calls + .filter(([action]) => action === "settings.provider.update") + .map(([, values]) => { + const update = values as { + provider: string; + apiType?: string; + extraBody?: string; + }; + return [update.provider, { + ...(update.apiType ? { apiType: update.apiType } : {}), + extraBody: JSON.parse(update.extraBody ?? "{}"), }] as const; }); expect(requestUpdates).toEqual([ @@ -3397,7 +3305,6 @@ describe("SettingsView Apps catalog", () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if (url.startsWith("/api/settings/provider/update?")) return jsonResponse(payload); if (url === "/api/settings/cli-apps") { return jsonResponse({ apps: [], installed_count: 0 }); } @@ -3407,6 +3314,7 @@ describe("SettingsView Apps catalog", () => { return jsonResponse({}); }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(payload); renderSettingsView({ initialSection: "models", initialSettings: payload }); @@ -3417,14 +3325,11 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => { - const updateCall = fetchMock.mock.calls.find( - ([input]) => String(input).startsWith("/api/settings/provider/update?"), + const updateCall = requestMutationMock.mock.calls.find( + ([action]) => action === "settings.provider.update", ); expect(updateCall).toBeTruthy(); - const headers = updateCall?.[1]?.headers as Record; - const values = JSON.parse(decodeURIComponent( - headers["X-Nanobot-Provider-Values"], - )) as { extraBody: string }; + const values = updateCall?.[1] as { extraBody: string }; expect(JSON.parse(values.extraBody)).toEqual({ metadata: { owner: "legacy-config" }, tools: [{ type: "file_search", vector_store_ids: ["vs_legacy"] }], @@ -3456,14 +3361,22 @@ describe("SettingsView Apps catalog", () => { }, ], }; - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(payload); - if (url === "/api/settings/provider/create") { - const headers = init?.headers as Record; - const values = JSON.parse(decodeURIComponent( - headers["X-Nanobot-Provider-Values"], - )) as Record; + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps: [], installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [], installed_count: 0 }); + } + return jsonResponse({}); + }); + vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockImplementationOnce(async ( + _action: string, + values: Record, + ) => { payload = { ...payload, created_provider: "custom-company-gateway", @@ -3493,17 +3406,8 @@ describe("SettingsView Apps catalog", () => { }, ], }; - return jsonResponse(payload); - } - if (url === "/api/settings/cli-apps") { - return jsonResponse({ apps: [], installed_count: 0 }); - } - if (url === "/api/settings/mcp-presets") { - return jsonResponse({ presets: [], installed_count: 0 }); - } - return jsonResponse({}); + return payload; }); - vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "models", initialSettings: payload }); @@ -3548,14 +3452,11 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Save provider" })); await waitFor(() => { - const createCall = fetchMock.mock.calls.find( - ([input]) => String(input) === "/api/settings/provider/create", + const createCall = requestMutationMock.mock.calls.find( + ([action]) => action === "settings.provider.create", ); expect(createCall).toBeTruthy(); - const headers = createCall?.[1]?.headers as Record; - expect(JSON.parse(decodeURIComponent( - headers["X-Nanobot-Provider-Values"], - ))).toEqual({ + expect(createCall?.[1]).toEqual({ name: "Company Gateway", apiKey: "sk-company", apiBase: "https://gateway.example/v1", @@ -4227,12 +4128,10 @@ describe("SettingsView Apps catalog", () => { fetched_at: 1, }); } - if (url.startsWith("/api/settings/model-configurations/update?")) { - return jsonResponse(updatedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "models" }); @@ -4259,22 +4158,16 @@ describe("SettingsView Apps catalog", () => { ), ); await waitFor(() => { - const saveCall = fetchMock.mock.calls.find(([input]) => - String(input).startsWith("/api/settings/model-configurations/update?"), + const saveCall = requestMutationMock.mock.calls.find(([action]) => + action === "settings.model_configuration.update", ); expect(saveCall).toBeDefined(); - const url = new URL(String(saveCall?.[0]), "http://nanobot.test"); - expect(Object.fromEntries(url.searchParams)).toEqual({ + expect(saveCall?.[1]).toEqual({ name: "primary", model: "deepseek-reasoner", reasoning_effort: "provider-native-mode", - temperature: "0.4", + temperature: 0.4, }); - expect(saveCall?.[1]).toEqual( - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), - ); }); }); @@ -4289,17 +4182,15 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } - if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") { - return jsonResponse({ - ...payload, - advanced: { ...payload.advanced, webui_allow_local_service_access: false }, - requires_restart: true, - restart_required_sections: ["runtime"], - }); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce({ + ...payload, + advanced: { ...payload.advanced, webui_allow_local_service_access: false }, + requires_restart: true, + restart_required_sections: ["runtime"], + }); renderSettingsView({ initialSection: "advanced" }); @@ -4315,11 +4206,13 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.network_safety.update", + { + webui_allow_local_service_access: false, + webui_default_access_mode: "default", + }, + 20_000, ), ); }); @@ -4348,15 +4241,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings") return jsonResponse(payload); if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); - if ( - url === - "/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true" - ) { - return jsonResponse(updatedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(updatedPayload); renderSettingsView({ initialSection: "browser" }); @@ -4369,11 +4257,15 @@ describe("SettingsView Apps catalog", () => { fireEvent.click(saveButton); await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/settings/web-search/update?provider=keenable&max_results=5&timeout=30&use_jina_reader=true", - expect.objectContaining({ - headers: { Authorization: "Bearer tok" }, - }), + expect(requestMutationMock).toHaveBeenCalledWith( + "settings.web_search.update", + { + provider: "keenable", + max_results: 5, + timeout: 30, + use_jina_reader: true, + }, + 20_000, ), ); }); @@ -4435,12 +4327,10 @@ describe("SettingsView Apps catalog", () => { if (url === "/api/settings") return jsonResponse(payload); if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 }); if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 }); - if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") { - return jsonResponse(restartedPayload); - } return { ok: false, status: 404, json: async () => ({}) } as Response; }); vi.stubGlobal("fetch", fetchMock); + requestMutationMock.mockResolvedValueOnce(restartedPayload); renderSettingsView({ initialSection: "advanced", diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 3a302e512..dd0c95f6b 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -109,8 +109,9 @@ describe("useSessions", () => { ]); vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true }); + const client = fakeClient(); const { result } = renderHook(() => useSessions(), { - wrapper: wrap(fakeClient()), + wrapper: wrap(client), }); await waitFor(() => expect(result.current.sessions).toHaveLength(2)); @@ -119,7 +120,7 @@ describe("useSessions", () => { await result.current.deleteChat("websocket:chat-a"); }); - expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined); + expect(api.deleteSession).toHaveBeenCalledWith(client, "websocket:chat-a", undefined); expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]); });