fix(webui): move mutations to authenticated websocket requests

This commit is contained in:
chengyongru
2026-08-10 16:23:47 +08:00
committed by chengyongru
parent 71a99b0780
commit 5d733b1c7c
25 changed files with 2458 additions and 2056 deletions
@@ -15,6 +15,7 @@ import type {
NanobotFeatureInfo, NanobotFeatureInfo,
NanobotFeaturesPayload, NanobotFeaturesPayload,
} from "@/lib/types"; } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
import { FeishuConnectFlow } from "./FeishuConnectFlow"; import { FeishuConnectFlow } from "./FeishuConnectFlow";
@@ -33,7 +34,6 @@ export function FeishuAssistantsPanel({
return ( return (
<ChannelInstancesPanel <ChannelInstancesPanel
token={token}
feature={feature} feature={feature}
showBrandLogos={showBrandLogos} showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl} chatAppsDocsUrl={chatAppsDocsUrl}
@@ -92,6 +92,7 @@ function FeishuInstanceAction({
instance: NanobotChannelInstanceInfo; instance: NanobotChannelInstanceInfo;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) { }) {
const { client } = useClient();
const { t } = useTranslation(); const { t } = useTranslation();
const tx = channelTranslator(t, "feishu"); const tx = channelTranslator(t, "feishu");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -114,7 +115,7 @@ function FeishuInstanceAction({
setError(null); setError(null);
try { try {
onFeaturesUpdate( onFeaturesUpdate(
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }), await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
); );
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
+162
View File
@@ -373,6 +373,13 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[ServerConnection, str] = {} self._conn_default: dict[ServerConnection, str] = {}
# Connections authenticated with a one-time token from /webui/bootstrap. # Connections authenticated with a one-time token from /webui/bootstrap.
self._webui_connections: set[ServerConnection] = set() 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._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None self._server_task: asyncio.Task[None] | None = None
@@ -758,6 +765,9 @@ class WebSocketChannel(BaseChannel):
) -> None: ) -> None:
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``).""" """Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
t = envelope.get("type") t = envelope.get("type")
if t == "webui_request":
await self._start_webui_request(connection, envelope)
return
if t == "new_chat": if t == "new_chat":
new_id = str(uuid.uuid4()) new_id = str(uuid.uuid4())
scope = await self._workspace_scope_or_error( scope = await self._workspace_scope_or_error(
@@ -1105,6 +1115,152 @@ class WebSocketChannel(BaseChannel):
return return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}") 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( async def _workspace_scope_or_error(
self, self,
connection: ServerConnection, connection: ServerConnection,
@@ -1145,6 +1301,12 @@ class WebSocketChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.warning("server task error during shutdown: {}", e) self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None 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._subs.clear()
self._conn_chats.clear() self._conn_chats.clear()
self._conn_default.clear() self._conn_default.clear()
@@ -3,12 +3,16 @@
import asyncio import asyncio
import json import json
import time import time
import uuid
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest import pytest
import websockets import websockets
from websockets.datastructures import Headers
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
from websockets.frames import Close 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 import webui_turns as wth
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services 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 ( from nanobot.webui.http_utils import (
issue_route_secret_matches as _issue_route_secret_matches, 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 @pytest.mark.asyncio
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None: async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
channel = _ch(MessageBus()) 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 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 @pytest.mark.asyncio
async def test_webui_persists_sidebar_state_larger_than_http_request_line( async def test_webui_persists_sidebar_state_larger_than_http_request_line(
bus: MagicMock, 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) monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
channel = _ch(bus) channel = _ch(bus)
conn = AsyncMock() conn = AsyncMock()
conn.request = SimpleNamespace(headers=Headers())
channel._webui_connections.add(conn) channel._webui_connections.add(conn)
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)] session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
request_id = "sidebar-large-state"
envelope = { envelope = {
"type": "set_sidebar_state", "type": "webui_request",
"state": { "request_id": request_id,
"action": "sidebar.update",
"payload": {"state": {
"session_order": session_order, "session_order": session_order,
"view": {"sort": "manual"}, "view": {"sort": "manual"},
}, }},
} }
assert len(json.dumps(envelope).encode()) > 8_192 assert len(json.dumps(envelope).encode()) > 8_192
await channel._dispatch_envelope(conn, "webui-client", envelope) 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")) saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
assert saved["session_order"] == session_order assert saved["session_order"] == session_order
assert saved["view"]["sort"] == "manual" 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 @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()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
webui_client = None
try: 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( settings = await _http_get(
f"http://127.0.0.1:{port}/api/settings", f"http://127.0.0.1:{port}/api/settings",
headers={"Authorization": "Bearer tok"}, 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 unknown_api.status_code == 404
assert "<!doctype html>" not in unknown_api.text.lower() assert "<!doctype html>" not in unknown_api.text.lower()
provider_updated = await _http_get( provider_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/provider/update?provider=openrouter" "settings.provider.update",
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1", {
headers={"Authorization": "Bearer tok"}, "provider": "openrouter",
"apiKey": "sk-or-test",
"apiBase": "https://openrouter.ai/api/v1",
},
) )
assert provider_updated.status_code == 200 assert provider_updated.status_code == 200
provider_body = provider_updated.json() 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 provider_body["image_generation"]["provider_configured"] is True
assert "sk-or-test" not in provider_updated.text assert "sk-or-test" not in provider_updated.text
custom_provider_created = await _http_get( custom_provider_created = await _webui_mutate(
f"http://127.0.0.1:{port}/api/settings/provider/create", webui_client,
headers={ "settings.provider.create",
"Authorization": "Bearer tok", {
"X-Nanobot-Provider-Values": json.dumps( "name": "Company Gateway",
{ "apiBase": "https://gateway.example/v1",
"name": "Company Gateway", "apiKey": "sk-company",
"apiBase": "https://gateway.example/v1", "extraHeaders": json.dumps({"X-Tenant": "engineering"}),
"apiKey": "sk-company", "extraBody": json.dumps({"service_tier": "priority"}),
"extraHeaders": json.dumps({"X-Tenant": "engineering"}), "extraQuery": json.dumps({"api-version": "2026-01-01"}),
"extraBody": json.dumps({"service_tier": "priority"}), "proxy": "http://127.0.0.1:7890",
"extraQuery": json.dumps({"api-version": "2026-01-01"}), "thinkingStyle": "enable_thinking",
"proxy": "http://127.0.0.1:7890",
"thinkingStyle": "enable_thinking",
}
),
}, },
) )
assert custom_provider_created.status_code == 200 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 assert "sk-company" not in custom_provider_created.text
local_provider_updated = await _http_get( local_provider_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/provider/update?provider=atomic_chat" "settings.provider.update",
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1", {"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
headers={"Authorization": "Bearer tok"},
) )
assert local_provider_updated.status_code == 200 assert local_provider_updated.status_code == 200
local_provider_body = local_provider_updated.json() 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 local_provider_rows["atomic_chat"]["configured"] is True
assert "localhost:1337" in local_provider_updated.text assert "localhost:1337" in local_provider_updated.text
updated = await _http_get( updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/update?model=atomic_chat/test" "settings.agent.update",
"&provider=atomic_chat&timezone=Asia%2FShanghai" {
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120", "model": "atomic_chat/test",
headers={"Authorization": "Bearer tok"}, "provider": "atomic_chat",
"timezone": "Asia/Shanghai",
"tool_hint_max_length": 120,
},
) )
assert updated.status_code == 200 assert updated.status_code == 200
updated_body = updated.json() updated_body = updated.json()
assert updated_body["requires_restart"] is True assert updated_body["requires_restart"] is True
assert updated_body["restart_required_sections"] == ["runtime"] assert updated_body["restart_required_sections"] == ["runtime"]
preset_updated = await _http_get( preset_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/update?model_preset=deep", "settings.agent.update",
headers={"Authorization": "Bearer tok"}, {"model_preset": "deep"},
) )
assert preset_updated.status_code == 200 assert preset_updated.status_code == 200
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5" assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
bad_preset = await _http_get( bad_preset = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/update?model_preset=missing", "settings.agent.update",
headers={"Authorization": "Bearer tok"}, {"model_preset": "missing"},
) )
assert bad_preset.status_code == 400 assert bad_preset.status_code == 400
created_preset = await _http_get( created_preset = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/model-configurations/create" "settings.model_configuration.create",
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini", {
headers={"Authorization": "Bearer tok"}, "label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
) )
assert created_preset.status_code == 200 assert created_preset.status_code == 200
created_body = created_preset.json() 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"]["label"] == "Fast writing"
assert created_presets["fast-writing"]["provider"] == "openai" assert created_presets["fast-writing"]["provider"] == "openai"
updated_preset = await _http_get( updated_preset = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/model-configurations/update" "settings.model_configuration.update",
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5", {
headers={"Authorization": "Bearer tok"}, "name": "fast-writing",
"label": "Codex",
"provider": "openai",
"model": "openai/gpt-5.5",
},
) )
assert updated_preset.status_code == 200 assert updated_preset.status_code == 200
updated_preset_body = updated_preset.json() 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" assert updated_presets["fast-writing"]["label"] == "Codex"
call_order_updated = await _http_get( call_order_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/model-call-order/update" "settings.model_call_order.update",
"?order=%5B%22fast-writing%22%2C%22deep%22%5D", {"order": ["fast-writing", "deep"]},
headers={"Authorization": "Bearer tok"},
) )
assert call_order_updated.status_code == 200 assert call_order_updated.status_code == 200
call_order_body = call_order_updated.json() 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["agent"]["model"] == "openai/gpt-5.5"
assert call_order_body["model_call_order"] == ["fast-writing", "deep"] assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
duplicate_preset = await _http_get( duplicate_preset = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/model-configurations/create" "settings.model_configuration.create",
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini", {
headers={"Authorization": "Bearer tok"}, "label": "Fast writing",
"provider": "openai",
"model": "openai/gpt-4.1-mini",
},
) )
assert duplicate_preset.status_code == 409 assert duplicate_preset.status_code == 409
search_updated = await _http_get( search_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/web-search/update?provider=searxng" "settings.web_search.update",
"&base_url=https%3A%2F%2Fsearch.example.com" {
"&max_results=8&timeout=45&use_jina_reader=false", "provider": "searxng",
headers={"Authorization": "Bearer tok"}, "base_url": "https://search.example.com",
"max_results": 8,
"timeout": 45,
"use_jina_reader": False,
},
) )
assert search_updated.status_code == 200 assert search_updated.status_code == 200
search_body = search_updated.json() 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_search"]["max_results"] == 8
assert search_body["web"]["fetch"]["use_jina_reader"] is False assert search_body["web"]["fetch"]["use_jina_reader"] is False
network_safety_updated = await _http_get( network_safety_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full", "settings.network_safety.update",
headers={"Authorization": "Bearer tok"}, {
"webui_allow_local_service_access": False,
"webui_default_access_mode": "full",
},
) )
assert network_safety_updated.status_code == 200 assert network_safety_updated.status_code == 200
network_safety_body = network_safety_updated.json() 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"]["webui_default_access_mode"] == "full"
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
image_updated = await _http_get( image_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/image-generation/update?enabled=true" "settings.image_generation.update",
"&provider=openrouter&model=openai%2Fgpt-image-1" {
"&default_aspect_ratio=16%3A9&default_image_size=2K" "enabled": True,
"&max_images_per_turn=3", "provider": "openrouter",
headers={"Authorization": "Bearer tok"}, "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 assert image_updated.status_code == 200
image_body = image_updated.json() 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"]["default_image_size"] == "2K"
assert image_body["image_generation"]["max_images_per_turn"] == 3 assert image_body["image_generation"]["max_images_per_turn"] == 3
image_provider_updated = await _http_get( image_provider_updated = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/provider/update?provider=openrouter" "settings.provider.update",
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1", {
headers={"Authorization": "Bearer tok"}, "provider": "openrouter",
"apiKey": "sk-or-next",
"apiBase": "https://openrouter.ai/api/v1",
},
) )
assert image_provider_updated.status_code == 200 assert image_provider_updated.status_code == 200
assert image_provider_updated.json()["requires_restart"] is True 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 "sk-or-next" not in image_provider_updated.text
assert image_reload.await_count == 2 assert image_reload.await_count == 2
bad_web = await _http_get( bad_web = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99", "settings.web_search.update",
headers={"Authorization": "Bearer tok"}, {"provider": "duckduckgo", "max_results": 99},
) )
assert bad_web.status_code == 400 assert bad_web.status_code == 400
bad_image = await _http_get( bad_image = await _webui_mutate(
"http://127.0.0.1:" webui_client,
f"{port}/api/settings/image-generation/update?provider=missing", "settings.image_generation.update",
headers={"Authorization": "Bearer tok"}, {"provider": "missing"},
) )
assert bad_image.status_code == 400 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.default_image_size == "2K"
assert saved.tools.image_generation.max_images_per_turn == 3 assert saved.tools.image_generation.max_images_per_turn == 3
finally: finally:
if webui_client is not None:
await webui_client.close()
await channel.stop() await channel.stop()
await server_task 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 channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
webui_client = None
try: try:
response = await _http_get( webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
f"http://127.0.0.1:{port}/api/settings/image-generation/update" webui_client = await websockets.connect(
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1", f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
headers={"Authorization": "Bearer tok"}, )
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.status_code == 200
@@ -3260,6 +3444,8 @@ async def test_image_settings_hot_reload_without_restart(
assert response.json()["restart_required_sections"] == [] assert response.json()["restart_required_sections"] == []
image_reload.assert_awaited_once_with(bus) image_reload.assert_awaited_once_with(bus)
finally: finally:
if webui_client is not None:
await webui_client.close()
await channel.stop() await channel.stop()
await server_task 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 channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3) await asyncio.sleep(0.3)
webui_client = None
try: try:
response = await _http_get( webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
f"http://127.0.0.1:{port}/api/settings/image-generation/update" webui_client = await websockets.connect(
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1", f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
headers={"Authorization": "Bearer tok"}, )
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.status_code == 200
assert response.json()["requires_restart"] is True assert response.json()["requires_restart"] is True
assert response.json()["restart_required_sections"] == ["image"] assert response.json()["restart_required_sections"] == ["image"]
finally: finally:
if webui_client is not None:
await webui_client.close()
await channel.stop() await channel.stop()
await server_task await server_task
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,7 @@ import type {
NanobotFeatureInfo, NanobotFeatureInfo,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import { import {
WEIXIN_AUTH_EXPIRED_MESSAGE, WEIXIN_AUTH_EXPIRED_MESSAGE,
@@ -64,6 +65,7 @@ export function WeixinPanel({
onAction, onAction,
onFeaturesUpdate, onFeaturesUpdate,
}: ChannelPluginPanelProps) { }: ChannelPluginPanelProps) {
const { client } = useClient();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const channelTx = channelTranslator(t, "weixin"); const channelTx = channelTranslator(t, "weixin");
@@ -150,7 +152,7 @@ export function WeixinPanel({
setSaveState("idle"); setSaveState("idle");
try { try {
const payload = await configureChannel( const payload = await configureChannel(
context.token, client,
"weixin", "weixin",
channelValuesForSave(editableFieldsRef.current, values), channelValuesForSave(editableFieldsRef.current, values),
{ enable: context.enabled }, { enable: context.enabled },
@@ -168,7 +170,7 @@ export function WeixinPanel({
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, []); }, [client]);
useEffect(() => { useEffect(() => {
if ( if (
+99 -119
View File
@@ -13,7 +13,6 @@ import json
import time import time
from collections.abc import Callable from collections.abc import Callable
from typing import Any, cast from typing import Any, cast
from urllib.parse import unquote
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from websockets.http11 import Response 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.pairing import approve_code, deny_code, list_pending
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload 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 is_local_browser_request as _is_local_browser_request
from nanobot.webui.http_utils import query_first as _query_first from nanobot.webui.http_utils import query_first as _query_first
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action 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]] QueryParams = dict[str, list[str]]
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values" _WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024 _WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
_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
_SKIP_FIELD = object() _SKIP_FIELD = object()
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"}) _CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
@@ -112,6 +101,63 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/tools": "tools", "/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: class WebUISettingsRouter:
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary.""" """Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
@@ -144,6 +190,15 @@ class WebUISettingsRouter:
self._channel_connectors: dict[str, Any] = {} self._channel_connectors: dict[str, Any] = {}
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None: 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": if path == "/api/settings":
return self._handle_settings(request) return self._handle_settings(request)
if path == "/api/settings/usage": if path == "/api/settings/usage":
@@ -230,7 +285,17 @@ class WebUISettingsRouter:
return await self._handle_settings_mcp_presets(request, mcp_action) return await self._handle_settings_mcp_presets(request, mcp_action)
return None 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: 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) return self._parse_query(request.path)
def _authorized(self, request: WsRequest) -> bool: def _authorized(self, request: WsRequest) -> bool:
@@ -260,63 +325,10 @@ class WebUISettingsRouter:
) )
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams: def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request) return 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
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams: def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request) return 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
def _handle_settings(self, request: WsRequest) -> Response: def _handle_settings(self, request: WsRequest) -> Response:
if not self._authorized(request): if not self._authorized(request):
@@ -472,18 +484,12 @@ class WebUISettingsRouter:
if action == "login": if action == "login":
payload = await asyncio.to_thread(login_oauth_provider, query) payload = await asyncio.to_thread(login_oauth_provider, query)
elif action == "complete": elif action == "complete":
authorization_response = case_insensitive_header( raw_response = (_mutation_payload(request) or {}).get(
request.headers, "authorization_response"
_OAUTH_CALLBACK_HEADER,
) or case_insensitive_header(
request.headers,
_OAUTH_CODE_HEADER,
) )
if ( if raw_response is not None and not isinstance(raw_response, str):
len(authorization_response.encode("utf-8")) raise WebUISettingsError("OAuth authorization response must be a string")
> _OAUTH_RESPONSE_HEADER_MAX_BYTES authorization_response = raw_response
):
raise WebUISettingsError("OAuth authorization response is too large")
payload = await asyncio.to_thread( payload = await asyncio.to_thread(
complete_oauth_provider, complete_oauth_provider,
query, query,
@@ -549,33 +555,12 @@ class WebUISettingsRouter:
return self._json_response(self._api_service_payload(last_action="started")) return self._json_response(self._api_service_payload(last_action="started"))
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams: def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request) payload = _mutation_payload(request)
if "api_key" in query or "apiKey" in query: if payload is not None:
raise WebUISettingsError("API service API key must be provided in the private header") api_key = payload.get("api_key")
raw = request.headers.get(_API_SERVICE_VALUES_HEADER) if api_key is not None and not isinstance(api_key, str):
if not raw: raise WebUISettingsError("API service API key must be a string")
return query return self._query(request)
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
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response: async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
if not self._authorized(request): if not self._authorized(request):
@@ -850,7 +835,7 @@ class WebUISettingsRouter:
saved = await asyncio.to_thread( saved = await asyncio.to_thread(
self._save_channel_config_values, self._save_channel_config_values,
name, name,
self._parse_channel_values_header(request), self._parse_channel_values(request),
instance_id, instance_id,
) )
except WebUISettingsError as e: except WebUISettingsError as e:
@@ -906,7 +891,7 @@ class WebUISettingsRouter:
payload = await asyncio.to_thread( payload = await asyncio.to_thread(
validate_channel_config, validate_channel_config,
name, name,
self._parse_channel_values_header(request), self._parse_channel_values(request),
instance_id=instance_id, instance_id=instance_id,
) )
except WebUISettingsError as e: except WebUISettingsError as e:
@@ -916,19 +901,14 @@ class WebUISettingsRouter:
return self._error_response(500, "failed to validate channel settings") return self._error_response(500, "failed to validate channel settings")
return self._json_response(payload) return self._json_response(payload)
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]: def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
raw = request.headers.get(_CHANNEL_VALUES_HEADER) payload = _mutation_payload(request)
if not raw: if payload is None or "values" not in payload:
return {} return {}
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES: values = payload.get("values")
raise WebUISettingsError("channel settings payload is too large") if not isinstance(values, dict):
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid channel settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("channel settings payload must be a JSON object") 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( def _save_channel_config_values(
self, self,
+171 -25
View File
@@ -17,9 +17,10 @@ import time
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from urllib.parse import unquote from urllib.parse import quote, unquote
from loguru import logger from loguru import logger
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest from websockets.http11 import Request as WsRequest
from websockets.http11 import Response from websockets.http11 import Response
@@ -118,7 +119,60 @@ from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000 _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 # 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' # 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 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: def _default_model_name_from_config() -> str | None:
try: try:
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
@@ -285,11 +366,86 @@ class GatewayHTTPHandler:
) )
try: 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) response = await self._dispatch_resolved(connection, request, got)
return response return response
finally: finally:
self._log_slow_http(got, response, started) 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( async def _dispatch_resolved(
self, self,
connection: Any, connection: Any,
@@ -646,7 +802,7 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key") return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key): if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found") 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() delete_automations = (_query_first(query, "delete_automations") or "").lower()
automation_jobs = session_automation_jobs( automation_jobs = session_automation_jobs(
self.cron_service, self.cron_service,
@@ -742,7 +898,7 @@ class GatewayHTTPHandler:
if self.cron_service is None and self.local_trigger_store is None: if self.cron_service is None and self.local_trigger_store is None:
return _http_error(503, "automation service unavailable") 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() job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
if not job_id: if not job_id:
return _http_error(400, "missing automation id") return _http_error(400, "missing automation id")
@@ -974,7 +1130,7 @@ class GatewayHTTPHandler:
if self._skill_install_lock.locked(): if self._skill_install_lock.locked():
return _http_error(409, "another skill installation is already in progress") 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" provider = _query_first(query, "provider") or "skills_sh"
source = _query_first(query, "source") or "" source = _query_first(query, "source") or ""
skill_id = _query_first(query, "skill") or "" skill_id = _query_first(query, "skill") or ""
@@ -1015,7 +1171,7 @@ class GatewayHTTPHandler:
def _handle_webui_skill_update(self, request: WsRequest) -> Response: def _handle_webui_skill_update(self, request: WsRequest) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
query = _parse_query(request.path) query = _request_query(request)
name = _query_first(query, "name") or "" name = _query_first(query, "name") or ""
raw_enabled = (_query_first(query, "enabled") or "").lower() raw_enabled = (_query_first(query, "enabled") or "").lower()
if raw_enabled not in {"true", "false"}: if raw_enabled not in {"true", "false"}:
@@ -1047,7 +1203,7 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
if not _is_local_browser_request(connection, request.headers): if not _is_local_browser_request(connection, request.headers):
return _http_error(403, "remote skill deletion is disabled") 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: try:
action = delete_webui_skill( action = delete_webui_skill(
self.skills_workspace_path, self.skills_workspace_path,
@@ -1094,18 +1250,14 @@ class GatewayHTTPHandler:
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response: def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
query = _parse_query(request.path) payload = _mutation_payload(request)
raw_state = _query_first(query, "state") state_value = payload.get("state") if payload is not None else None
if raw_state is None: if state_value is None:
return _http_error(400, "missing state") return _http_error(400, "missing state")
try: if not isinstance(state_value, dict):
decoded = json.loads(raw_state)
except json.JSONDecodeError:
return _http_error(400, "state must be JSON")
if not isinstance(decoded, dict):
return _http_error(400, "state must be an object") return _http_error(400, "state must be an object")
try: 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: except ValueError as e:
return _http_error(400, str(e)) return _http_error(400, str(e))
except OSError: except OSError:
@@ -1174,16 +1326,10 @@ class GatewayHTTPHandler:
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None: def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER) payload = _mutation_payload(request)
if not raw: if payload is None or "values" not in payload:
return {} return {}
try: values = payload.get("values")
values = json.loads(raw)
except Exception:
try:
values = json.loads(unquote(raw))
except Exception:
return None
return cast(dict[str, Any], values) if isinstance(values, dict) else None return cast(dict[str, Any], values) if isinstance(values, dict) else None
+43 -25
View File
@@ -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( @pytest.mark.parametrize(
("provider", "header_name", "authorization_response"), ("provider", "authorization_response"),
[ [
("xai_grok", "X-Nanobot-OAuth-Code", "secret"), ("xai_grok", "secret"),
( (
"openai_codex", "openai_codex",
"X-Nanobot-OAuth-Callback",
"http://localhost:1455/auth/callback?code=secret&state=test", "http://localhost:1455/auth/callback?code=secret&state=test",
), ),
], ],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_oauth_completion_reads_private_response_header( async def test_oauth_completion_reads_websocket_payload(
monkeypatch, monkeypatch,
provider: str, provider: str,
header_name: str,
authorization_response: str, authorization_response: str,
) -> None: ) -> None:
captured: dict[str, object] = {} 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) monkeypatch.setattr("nanobot.webui.settings_routes.complete_oauth_provider", complete)
router = _router() router = _router()
request = SimpleNamespace( request = _mutation_request(
path=( "/api/settings/provider/oauth-login/complete",
"/api/settings/provider/oauth-login/complete" {
f"?provider={provider}&flow_id=flow-123" "provider": provider,
), "flow_id": "flow-123",
headers=Headers( "authorization_response": authorization_response,
[ },
(
header_name,
authorization_response,
)
]
),
) )
response = await router.dispatch( response = await router.dispatch(
@@ -90,28 +90,29 @@ async def test_oauth_completion_reads_private_response_header(
"query": {"provider": [provider], "flow_id": ["flow-123"]}, "query": {"provider": [provider], "flow_id": ["flow-123"]},
"authorization_response": authorization_response, "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( @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", "/api/settings/model-configurations/delete",
"delete_model_configuration", "delete_model_configuration",
{"name": "spare"},
{"name": ["spare"]}, {"name": ["spare"]},
), ),
( (
"/api/settings/model-configurations/migrate",
"/api/settings/model-configurations/migrate", "/api/settings/model-configurations/migrate",
"migrate_model_configurations", "migrate_model_configurations",
{}, {},
{},
), ),
( (
"/api/settings/model-call-order/update?order=%5B%22backup%22%5D",
"/api/settings/model-call-order/update", "/api/settings/model-call-order/update",
"update_model_call_order", "update_model_call_order",
{"order": ["backup"]},
{"order": ['["backup"]']}, {"order": ['["backup"]']},
), ),
], ],
@@ -119,9 +120,9 @@ async def test_oauth_completion_reads_private_response_header(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_model_preset_mutation_routes( async def test_model_preset_mutation_routes(
monkeypatch, monkeypatch,
request_path: str,
route_path: str, route_path: str,
function_name: str, function_name: str,
payload: dict[str, object],
expected_query: dict[str, list[str]], expected_query: dict[str, list[str]],
) -> None: ) -> None:
captured: dict[str, object] = {} captured: dict[str, object] = {}
@@ -131,7 +132,7 @@ async def test_model_preset_mutation_routes(
return {"routed": function_name} return {"routed": function_name}
monkeypatch.setattr(f"nanobot.webui.settings_routes.{function_name}", mutate) 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) response = await _router().dispatch(None, request, route_path)
@@ -141,6 +142,23 @@ async def test_model_preset_mutation_routes(
assert captured["query"] == expected_query 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( @pytest.mark.parametrize(
("update_info", "expected"), ("update_info", "expected"),
[ [
+2 -2
View File
@@ -2081,7 +2081,7 @@ function Shell({
setPairingBusyCode(code); setPairingBusyCode(code);
setPairingError(null); setPairingError(null);
try { try {
const payload = await runPairingAction(getToken(), action, code); const payload = await runPairingAction(client, action, code);
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []); setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
setSnoozedPairingCodes((current) => { setSnoozedPairingCodes((current) => {
if (!current.has(code)) return current; if (!current.has(code)) return current;
@@ -2096,7 +2096,7 @@ function Shell({
setPairingBusyCode(null); setPairingBusyCode(null);
} }
}, },
[getToken, refreshPairingRequests], [client, refreshPairingRequests],
); );
const onDismissPairingRequest = useCallback((code: string) => { const onDismissPairingRequest = useCallback((code: string) => {
+30 -31
View File
@@ -724,7 +724,7 @@ export function SettingsView({
hostChromeInset = false, hostChromeInset = false,
}: SettingsViewProps) { }: SettingsViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { getToken, token } = useClient(); const { client, getToken, token } = useClient();
const pageVisible = usePageVisibility(); const pageVisible = usePageVisibility();
const remoteBrowserAccess = const remoteBrowserAccess =
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname); typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
@@ -872,7 +872,7 @@ export function SettingsView({
const poll = async () => { const poll = async () => {
try { try {
const payload = await completeProviderOAuth( const payload = await completeProviderOAuth(
getToken(), client,
providerOAuthFlow.provider, providerOAuthFlow.provider,
providerOAuthFlow.flow_id, providerOAuthFlow.flow_id,
); );
@@ -902,7 +902,7 @@ export function SettingsView({
cancelled = true; cancelled = true;
if (timer !== null) window.clearTimeout(timer); if (timer !== null) window.clearTimeout(timer);
}; };
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]); }, [applyPayload, client, closeProviderOAuthFlow, providerOAuthFlow]);
useEffect(() => { useEffect(() => {
if (!initialSettings || settings !== null) return; if (!initialSettings || settings !== null) return;
@@ -1301,7 +1301,7 @@ export function SettingsView({
} }
setModelConfigurationSaving(true); setModelConfigurationSaving(true);
try { try {
const payload = await createModelConfiguration(token, { const payload = await createModelConfiguration(client, {
label, label,
provider, provider,
model, model,
@@ -1319,7 +1319,7 @@ export function SettingsView({
let finalPayload = payload; let finalPayload = payload;
if (nextOrder) { if (nextOrder) {
const orderedPayload = await updateModelCallOrder(token, nextOrder); const orderedPayload = await updateModelCallOrder(client, nextOrder);
applyPayload(orderedPayload); applyPayload(orderedPayload);
finalPayload = orderedPayload; finalPayload = orderedPayload;
} }
@@ -1345,7 +1345,7 @@ export function SettingsView({
const reasoningEffort = form.reasoningEffort || null; const reasoningEffort = form.reasoningEffort || null;
setSaving(true); setSaving(true);
try { try {
const payload = await updateModelConfiguration(token, { const payload = await updateModelConfiguration(client, {
name: selectedPreset.name, name: selectedPreset.name,
label: label:
form.presetLabel.trim() !== selectedPreset.label form.presetLabel.trim() !== selectedPreset.label
@@ -1431,7 +1431,7 @@ export function SettingsView({
setModelCallOrder(nextOrder); setModelCallOrder(nextOrder);
setModelCallOrderSaving(true); setModelCallOrderSaving(true);
try { try {
const payload = await updateModelCallOrder(token, nextOrder); const payload = await updateModelCallOrder(client, nextOrder);
applyPayload(payload, { preserveAgentForm: true }); applyPayload(payload, { preserveAgentForm: true });
onModelNameChange(payload.agent.model || null); onModelNameChange(payload.agent.model || null);
setError(null); setError(null);
@@ -1447,7 +1447,7 @@ export function SettingsView({
if (modelMigrationSaving) return; if (modelMigrationSaving) return;
setModelMigrationSaving(true); setModelMigrationSaving(true);
try { try {
const payload = await migrateModelConfigurations(token); const payload = await migrateModelConfigurations(client);
applyPayload(payload); applyPayload(payload);
onModelNameChange(payload.agent.model || null); onModelNameChange(payload.agent.model || null);
setError(null); setError(null);
@@ -1469,7 +1469,7 @@ export function SettingsView({
} }
setSaving(true); setSaving(true);
try { try {
const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name); const payload = await deleteModelConfiguration(client, modelPresetPendingDelete.name);
applyPayload(payload); applyPayload(payload);
setModelPresetPendingDelete(null); setModelPresetPendingDelete(null);
setError(null); setError(null);
@@ -1484,7 +1484,7 @@ export function SettingsView({
if (!settings || !imageGenerationDirty || imageGenerationSaving) return; if (!settings || !imageGenerationDirty || imageGenerationSaving) return;
setImageGenerationSaving(true); setImageGenerationSaving(true);
try { try {
const payload = await updateImageGenerationSettings(token, imageGenerationForm); const payload = await updateImageGenerationSettings(client, imageGenerationForm);
applyPayload(payload); applyPayload(payload);
if (payload.requires_restart) { if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true })); setPendingRestartSections((prev) => ({ ...prev, image: true }));
@@ -1502,7 +1502,7 @@ export function SettingsView({
if (!settings || !transcriptionDirty || transcriptionSaving) return; if (!settings || !transcriptionDirty || transcriptionSaving) return;
setTranscriptionSaving(true); setTranscriptionSaving(true);
try { try {
const payload = await updateTranscriptionSettings(token, transcriptionForm); const payload = await updateTranscriptionSettings(client, transcriptionForm);
applyPayload(payload); applyPayload(payload);
if (payload.requires_restart) { if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, browser: true })); setPendingRestartSections((prev) => ({ ...prev, browser: true }));
@@ -1520,7 +1520,7 @@ export function SettingsView({
if (!settings || !networkSafetyDirty || networkSafetySaving) return; if (!settings || !networkSafetyDirty || networkSafetySaving) return;
setNetworkSafetySaving(true); setNetworkSafetySaving(true);
try { try {
const payload = await updateNetworkSafetySettings(token, networkSafetyForm); const payload = await updateNetworkSafetySettings(client, networkSafetyForm);
applyPayload(payload); applyPayload(payload);
if (payload.requires_restart) { if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true })); setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
@@ -1544,7 +1544,7 @@ export function SettingsView({
try { try {
let latest = nanobotFeatures; let latest = nanobotFeatures;
for (const name of missing) { for (const name of missing) {
latest = await enableNanobotFeature(token, name); latest = await enableNanobotFeature(client, name);
if (latest.requires_restart) { if (latest.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true })); setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
} }
@@ -1568,8 +1568,8 @@ export function SettingsView({
setApiServiceError(null); setApiServiceError(null);
try { try {
const payload = action === "start" const payload = action === "start"
? await startApiService(token, values!) ? await startApiService(client, values!)
: await stopApiService(token); : await stopApiService(client);
setApiService(payload); setApiService(payload);
const refreshed = await fetchNanobotFeatures(token); const refreshed = await fetchNanobotFeatures(token);
setNanobotFeatures(refreshed); setNanobotFeatures(refreshed);
@@ -1622,7 +1622,7 @@ export function SettingsView({
if (field === "region") update.region = providerForm.region.trim(); if (field === "region") update.region = providerForm.region.trim();
if (field === "profile") update.profile = providerForm.profile.trim(); if (field === "profile") update.profile = providerForm.profile.trim();
} }
const payload = await updateProviderSettings(token, update); const payload = await updateProviderSettings(client, update);
applyPayload(payload); applyPayload(payload);
if (payload.requires_restart) { if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, image: true })); setPendingRestartSections((prev) => ({ ...prev, image: true }));
@@ -1656,7 +1656,7 @@ export function SettingsView({
if (providerSaving) return false; if (providerSaving) return false;
setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY); setProviderSaving(CUSTOM_PROVIDER_CREATION_KEY);
try { try {
const payload = await createProviderSettings(token, { const payload = await createProviderSettings(client, {
name: draft.name.trim(), name: draft.name.trim(),
apiKey: draft.apiKey.trim() || undefined, apiKey: draft.apiKey.trim() || undefined,
apiBase: draft.apiBase.trim(), apiBase: draft.apiBase.trim(),
@@ -1698,12 +1698,11 @@ export function SettingsView({
const payload = const payload =
action === "login" action === "login"
? await loginProviderOAuth( ? await loginProviderOAuth(
token, client,
providerName, providerName,
"",
providerName === "openai_codex" && remoteBrowserAccess, providerName === "openai_codex" && remoteBrowserAccess,
) )
: await logoutProviderOAuth(token, providerName); : await logoutProviderOAuth(client, providerName);
if (isProviderOAuthAuthorizationRequired(payload)) { if (isProviderOAuthAuthorizationRequired(payload)) {
try { try {
if (popup && !popup.closed) popup.location.href = payload.authorization_url; if (popup && !popup.closed) popup.location.href = payload.authorization_url;
@@ -1739,7 +1738,7 @@ export function SettingsView({
setProviderOAuthDialogError(null); setProviderOAuthDialogError(null);
try { try {
const payload = await completeProviderOAuth( const payload = await completeProviderOAuth(
token, client,
flow.provider, flow.provider,
flow.flow_id, flow.flow_id,
authorizationResponse, authorizationResponse,
@@ -1798,7 +1797,7 @@ export function SettingsView({
update.apiKey = apiKey; update.apiKey = apiKey;
} }
if (provider.credential === "base_url") update.baseUrl = baseUrl; if (provider.credential === "base_url") update.baseUrl = baseUrl;
const payload = await updateWebSearchSettings(token, update); const payload = await updateWebSearchSettings(client, update);
applyPayload(payload); applyPayload(payload);
if (payload.requires_restart || webFetchRestartRequired) { if (payload.requires_restart || webFetchRestartRequired) {
setPendingRestartSections((prev) => ({ ...prev, browser: true })); setPendingRestartSections((prev) => ({ ...prev, browser: true }));
@@ -1903,7 +1902,7 @@ export function SettingsView({
setCliAppsMessage(null); setCliAppsMessage(null);
setCliAppsError(null); setCliAppsError(null);
try { try {
const payload = await runCliAppAction(token, action, name); const payload = await runCliAppAction(client, action, name);
setCliApps(payload); setCliApps(payload);
if (action !== "test") { if (action !== "test") {
notifyCliAppsChanged(payload); notifyCliAppsChanged(payload);
@@ -1934,8 +1933,8 @@ export function SettingsView({
setNanobotFeaturesError(null); setNanobotFeaturesError(null);
try { try {
const payload = action === "enable" const payload = action === "enable"
? await enableNanobotFeature(token, name) ? await enableNanobotFeature(client, name)
: await disableNanobotFeature(token, name); : await disableNanobotFeature(client, name);
setNanobotFeatures(payload); setNanobotFeatures(payload);
if (payload.requires_restart) { if (payload.requires_restart) {
setPendingRestartSections((prev) => ({ ...prev, runtime: true })); setPendingRestartSections((prev) => ({ ...prev, runtime: true }));
@@ -1955,7 +1954,7 @@ export function SettingsView({
setAutomationAction(key); setAutomationAction(key);
setAutomationsError(null); setAutomationsError(null);
try { try {
const payload = await runAutomationAction(token, action, job.id); const payload = await runAutomationAction(client, action, job.id);
setAutomations(payload); setAutomations(payload);
if (action === "delete") setAutomationPendingDelete(null); if (action === "delete") setAutomationPendingDelete(null);
if (action === "run") { if (action === "run") {
@@ -1977,7 +1976,7 @@ export function SettingsView({
setAutomationAction(key); setAutomationAction(key);
setAutomationsError(null); setAutomationsError(null);
try { try {
const payload = await updateAutomation(token, job.id, values); const payload = await updateAutomation(client, job.id, values);
setAutomations(payload); setAutomations(payload);
setAutomationPendingEdit(null); setAutomationPendingEdit(null);
} catch (err) { } catch (err) {
@@ -1997,7 +1996,7 @@ export function SettingsView({
setMcpMessage(null); setMcpMessage(null);
setMcpError(null); setMcpError(null);
try { try {
const payload = await runMcpPresetAction(token, action, name, values); const payload = await runMcpPresetAction(client, action, name, values);
setMcpPresets(payload); setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null); setMcpMessage(payload.last_action?.message ?? null);
if (action !== "test") { if (action !== "test") {
@@ -2024,7 +2023,7 @@ export function SettingsView({
setMcpMessage(null); setMcpMessage(null);
setMcpError(null); setMcpError(null);
try { try {
const payload = await saveCustomMcpServer(token, { const payload = await saveCustomMcpServer(client, {
name, name,
transport: customMcpForm.transport, transport: customMcpForm.transport,
command: customMcpForm.command, command: customMcpForm.command,
@@ -2054,7 +2053,7 @@ export function SettingsView({
setMcpMessage(null); setMcpMessage(null);
setMcpError(null); setMcpError(null);
try { try {
const payload = await importMcpConfig(token, mcpConfigImport); const payload = await importMcpConfig(client, mcpConfigImport);
setMcpPresets(payload); setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null); setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload); notifyMcpPresetsChanged(payload);
@@ -2075,7 +2074,7 @@ export function SettingsView({
setMcpMessage(null); setMcpMessage(null);
setMcpError(null); setMcpError(null);
try { try {
const payload = await updateMcpServerTools(token, name, enabledTools); const payload = await updateMcpServerTools(client, name, enabledTools);
setMcpPresets(payload); setMcpPresets(payload);
setMcpMessage(payload.last_action?.message ?? null); setMcpMessage(payload.last_action?.message ?? null);
notifyMcpPresetsChanged(payload); notifyMcpPresetsChanged(payload);
@@ -269,7 +269,7 @@ function SkillDetailSheet({
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const { getToken } = useClient(); const { client, getToken } = useClient();
const { t } = useTranslation(); const { t } = useTranslation();
const [detail, setDetail] = useState<SkillDetail | null>(null); const [detail, setDetail] = useState<SkillDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -321,7 +321,7 @@ function SkillDetailSheet({
setActionBusy(true); setActionBusy(true);
setActionError(""); setActionError("");
try { try {
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled); const payload = await updateSkillEnabled(client, activeSkill.name, !enabled);
notifySkillsChanged(payload); notifySkillsChanged(payload);
const updated = payload.skills.find((item) => item.name === activeSkill.name); const updated = payload.skills.find((item) => item.name === activeSkill.name);
if (updated) { if (updated) {
@@ -345,7 +345,7 @@ function SkillDetailSheet({
setActionBusy(true); setActionBusy(true);
setActionError(""); setActionError("");
try { try {
const payload = await deleteSkill(getToken(), activeSkill.name); const payload = await deleteSkill(client, activeSkill.name);
notifySkillsChanged(payload); notifySkillsChanged(payload);
onOpenChange(false); onOpenChange(false);
} catch (reason) { } catch (reason) {
@@ -46,7 +46,7 @@ export function SkillsMarketplace({
installing: string; installing: string;
onInstallingChange: (skillId: string) => void; onInstallingChange: (skillId: string) => void;
}) { }) {
const { getToken } = useClient(); const { client, getToken } = useClient();
const { t } = useTranslation(); const { t } = useTranslation();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]); const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
@@ -161,7 +161,7 @@ export function SkillsMarketplace({
setError(""); setError("");
try { try {
const payload = await installMarketplaceSkill( const payload = await installMarketplaceSkill(
getToken(), client,
skill.provider, skill.provider,
skill.source, skill.source,
skill.skill_id, skill.skill_id,
@@ -38,6 +38,7 @@ import type {
NanobotFeaturesPayload, NanobotFeaturesPayload,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export type ChannelInstancesPanelCustomization = { export type ChannelInstancesPanelCustomization = {
countLabel?: (runningCount: number) => string; countLabel?: (runningCount: number) => string;
@@ -50,7 +51,6 @@ export type ChannelInstancesPanelCustomization = {
}; };
export function ChannelInstancesPanel({ export function ChannelInstancesPanel({
token,
feature, feature,
showBrandLogos, showBrandLogos,
chatAppsDocsUrl, chatAppsDocsUrl,
@@ -58,7 +58,6 @@ export function ChannelInstancesPanel({
onFeaturesUpdate, onFeaturesUpdate,
customization = {}, customization = {},
}: { }: {
token: string;
feature: NanobotFeatureInfo; feature: NanobotFeatureInfo;
showBrandLogos: boolean; showBrandLogos: boolean;
chatAppsDocsUrl?: string; chatAppsDocsUrl?: string;
@@ -66,6 +65,7 @@ export function ChannelInstancesPanel({
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
customization?: ChannelInstancesPanelCustomization; customization?: ChannelInstancesPanelCustomization;
}) { }) {
const { client } = useClient();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const displayName = localizedChannelDisplayName(feature, t); const displayName = localizedChannelDisplayName(feature, t);
@@ -111,8 +111,8 @@ export function ChannelInstancesPanel({
setNotice(null); setNotice(null);
try { try {
const payload = checked const payload = checked
? await enableNanobotFeature(token, feature.name, { instanceId: instance.id }) ? await enableNanobotFeature(client, feature.name, { instanceId: instance.id })
: await disableNanobotFeature(token, feature.name, { instanceId: instance.id }); : await disableNanobotFeature(client, feature.name, { instanceId: instance.id });
onFeaturesUpdate(payload); onFeaturesUpdate(payload);
} catch (err) { } catch (err) {
setNotice((err as Error).message); setNotice((err as Error).message);
@@ -127,7 +127,7 @@ export function ChannelInstancesPanel({
setNotice(null); setNotice(null);
try { try {
const payload = await configureChannel( const payload = await configureChannel(
token, client,
feature.name, feature.name,
channelValuesForSave(instanceFields, fieldValues), channelValuesForSave(instanceFields, fieldValues),
{ enable: selected.enabled, instanceId: selected.id }, { enable: selected.enabled, instanceId: selected.id },
@@ -14,6 +14,7 @@ import type {
ChannelConnectPayload, ChannelConnectPayload,
NanobotFeaturesPayload, NanobotFeaturesPayload,
} from "@/lib/types"; } from "@/lib/types";
import { useClient } from "@/providers/ClientProvider";
export type ChannelQrConnectLabels = { export type ChannelQrConnectLabels = {
qrAlt: string; qrAlt: string;
@@ -43,7 +44,6 @@ export type ChannelQrConnectPendingContext = {
}; };
export function ChannelQrConnectFlow({ export function ChannelQrConnectFlow({
token,
channelName, channelName,
startOptions = {}, startOptions = {},
idleLabel, idleLabel,
@@ -69,6 +69,7 @@ export function ChannelQrConnectFlow({
resolveMessage?: (payload: ChannelConnectPayload) => string | undefined; resolveMessage?: (payload: ChannelConnectPayload) => string | undefined;
suppressSucceeded?: boolean; suppressSucceeded?: boolean;
}) { }) {
const { client } = useClient();
const pageVisible = usePageVisibility(); const pageVisible = usePageVisibility();
const { t } = useTranslation(); const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
@@ -78,8 +79,6 @@ export function ChannelQrConnectFlow({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [handledRequestId, setHandledRequestId] = useState(0); const [handledRequestId, setHandledRequestId] = useState(0);
const pollInFlight = useRef(false); const pollInFlight = useRef(false);
const tokenRef = useRef(token);
tokenRef.current = token;
const startDomain = startOptions.domain; const startDomain = startOptions.domain;
const startInstanceId = startOptions.instanceId; const startInstanceId = startOptions.instanceId;
const startMode = startOptions.mode; const startMode = startOptions.mode;
@@ -129,7 +128,7 @@ export function ChannelQrConnectFlow({
pollInFlight.current = true; pollInFlight.current = true;
try { try {
const payload = await pollChannelConnect( const payload = await pollChannelConnect(
tokenRef.current, client,
channelName, channelName,
sessionId, sessionId,
); );
@@ -163,6 +162,7 @@ export function ChannelQrConnectFlow({
}; };
}, [ }, [
channelName, channelName,
client,
connect?.interval_ms, connect?.interval_ms,
connect?.session_id, connect?.session_id,
connect?.status, connect?.status,
@@ -175,7 +175,7 @@ export function ChannelQrConnectFlow({
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
const payload = await startChannelConnect(tokenRef.current, channelName, { const payload = await startChannelConnect(client, channelName, {
domain: startDomain, domain: startDomain,
instanceId: startInstanceId, instanceId: startInstanceId,
mode: startMode, mode: startMode,
@@ -187,7 +187,7 @@ export function ChannelQrConnectFlow({
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, [channelName, startDomain, startForce, startInstanceId, startMode]); }, [channelName, client, startDomain, startForce, startInstanceId, startMode]);
useEffect(() => { useEffect(() => {
if (!connectRequestId || connectRequestId === handledRequestId) return; if (!connectRequestId || connectRequestId === handledRequestId) return;
@@ -203,7 +203,7 @@ export function ChannelQrConnectFlow({
setBusy(true); setBusy(true);
try { try {
const payload = await cancelChannelConnect( const payload = await cancelChannelConnect(
tokenRef.current, client,
channelName, channelName,
connect.session_id, connect.session_id,
); );
@@ -223,10 +223,9 @@ export function ChannelQrConnectFlow({
setError(null); setError(null);
try { try {
const payload = await pollChannelConnect( const payload = await pollChannelConnect(
tokenRef.current, client,
channelName, channelName,
connect.session_id, connect.session_id,
"",
params, params,
); );
setConnect((current) => ({ setConnect((current) => ({
@@ -54,6 +54,7 @@ import type {
NanobotFeaturesPayload, NanobotFeaturesPayload,
} from "@/lib/types"; } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
export function ChannelCatalogRow({ export function ChannelCatalogRow({
feature, feature,
@@ -148,7 +149,6 @@ export function ChannelSetupPanel({
if (feature.instances !== undefined) { if (feature.instances !== undefined) {
return ( return (
<ChannelInstancesPanel <ChannelInstancesPanel
token={token}
feature={feature} feature={feature}
showBrandLogos={showBrandLogos} showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl} chatAppsDocsUrl={chatAppsDocsUrl}
@@ -269,6 +269,7 @@ function ChannelSetupSurface({
ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>; ConnectFlow?: ComponentType<ChannelPluginConnectFlowProps>;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) { }) {
const { client } = useClient();
const { t } = useTranslation(); const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
@@ -345,7 +346,7 @@ function ChannelSetupSurface({
setNotice(null); setNotice(null);
const values = channelValuesForSubmit(fields, fieldValues, touchedFields); const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
try { try {
const validationPayload = await validateChannel(token, feature.name, values); const validationPayload = await validateChannel(client, feature.name, values);
setValidation(validationPayload); setValidation(validationPayload);
if (!validationPayload.can_enable) { if (!validationPayload.can_enable) {
setNotice( setNotice(
@@ -355,7 +356,7 @@ function ChannelSetupSurface({
return; return;
} }
const payload = await configureChannel( const payload = await configureChannel(
token, client,
feature.name, feature.name,
values, values,
{ enable: true }, { enable: true },
@@ -377,7 +378,7 @@ function ChannelSetupSurface({
setNotice(null); setNotice(null);
try { try {
const payload = await validateChannel( const payload = await validateChannel(
token, client,
feature.name, feature.name,
channelValuesForSubmit(fields, fieldValues, touchedFields), channelValuesForSubmit(fields, fieldValues, touchedFields),
); );
+2 -2
View File
@@ -257,13 +257,13 @@ export function useSessions(): {
const deleteChat = useCallback( const deleteChat = useCallback(
async (key: string, options?: { deleteAutomations?: boolean }) => { 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; if (!result.deleted) return result;
optimisticKeysRef.current.delete(key); optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== key)); setSessions((prev) => prev.filter((s) => s.key !== key));
return result; return result;
}, },
[], [client],
); );
const getSessionAutomations = useCallback(async (key: string) => { const getSessionAutomations = useCallback(async (key: string) => {
+22 -2
View File
@@ -144,6 +144,8 @@ export function useSidebarState(
const { client, token } = useClient(); const { client, token } = useClient();
const tokenRef = useRef(token); const tokenRef = useRef(token);
const stateRef = useRef(DEFAULT_SIDEBAR_STATE); const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
const connectionOpenRef = useRef(client.status === "open");
const pendingPersistenceRef = useRef<SidebarStatePayload | null>(null);
const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE); const [state, setState] = useState<SidebarStatePayload>(DEFAULT_SIDEBAR_STATE);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
tokenRef.current = token; 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( const update = useCallback(
async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => { async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
const next = normalizeSidebarState(updater(stateRef.current)); const next = normalizeSidebarState(updater(stateRef.current));
stateRef.current = next; stateRef.current = next;
setState(next); setState(next);
client.setSidebarState(next); persist(next);
}, },
[client], [persist],
); );
const pruned = useMemo(() => { const pruned = useMemo(() => {
+264 -360
View File
@@ -44,6 +44,8 @@ import type {
import { fetchWithTimeout } from "./http"; import { fetchWithTimeout } from "./http";
const API_READ_TIMEOUT_MS = 20_000; 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<SlashCommandLifecycle>([ const SLASH_COMMAND_LIFECYCLES = new Set<SlashCommandLifecycle>([
"side_channel", "side_channel",
"finalize_active_turn", "finalize_active_turn",
@@ -58,12 +60,6 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
&& SLASH_COMMAND_LIFECYCLES.has(value as 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 { export class ApiError extends Error {
status: number; status: number;
constructor(status: number, message: string) { constructor(status: number, message: string) {
@@ -73,6 +69,14 @@ export class ApiError extends Error {
} }
} }
export interface WebUIMutationTransport {
requestMutation<T>(
action: string,
payload?: Record<string, unknown>,
timeoutMs?: number,
): Promise<T>;
}
async function request<T>( async function request<T>(
url: string, url: string,
token: string, token: string,
@@ -109,7 +113,27 @@ async function request<T>(
return (await res.json()) as T; return (await res.json()) as T;
} }
function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefined { async function mutation<T>(
transport: WebUIMutationTransport,
action: string,
payload: Record<string, unknown> = {},
timeoutMs: number = API_MUTATION_TIMEOUT_MS,
): Promise<T> {
try {
return await transport.requestMutation<T>(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<string, unknown>): Record<string, unknown> {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
Object.entries(values).forEach(([key, value]) => { Object.entries(values).forEach(([key, value]) => {
if (value === null || value === undefined) return; if (value === null || value === undefined) return;
@@ -120,12 +144,7 @@ function mcpValuesHeader(values: Record<string, unknown>): HeadersInit | undefin
} }
payload[key] = value; payload[key] = value;
}); });
if (!Object.keys(payload).length) return undefined; return payload;
return { "X-Nanobot-MCP-Values": JSON.stringify(payload) };
}
function automationValuesHeader(values: AutomationUpdatePayload): HeadersInit {
return { "X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)) };
} }
function splitKey(key: string): { channel: string; chatId: string } { function splitKey(key: string): { channel: string; chatId: string } {
@@ -261,37 +280,19 @@ export async function fetchAutomations(
} }
export async function runAutomationAction( export async function runAutomationAction(
token: string, transport: WebUIMutationTransport,
action: "enable" | "disable" | "delete" | "run", action: "enable" | "disable" | "delete" | "run",
id: string, id: string,
base: string = "",
): Promise<AutomationsPayload> { ): Promise<AutomationsPayload> {
const query = new URLSearchParams(); return mutation<AutomationsPayload>(transport, `automation.${action}`, { id });
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/${action}?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
} }
export async function updateAutomation( export async function updateAutomation(
token: string, transport: WebUIMutationTransport,
id: string, id: string,
values: AutomationUpdatePayload, values: AutomationUpdatePayload,
base: string = "",
): Promise<AutomationsPayload> { ): Promise<AutomationsPayload> {
const query = new URLSearchParams(); return mutation<AutomationsPayload>(transport, "automation.update", { id, values });
query.set("id", id);
return request<AutomationsPayload>(
`${base}/api/webui/automations/update?${query}`,
token,
{
headers: automationValuesHeader(values),
},
API_READ_TIMEOUT_MS,
);
} }
export async function fetchSkills( export async function fetchSkills(
@@ -320,28 +321,18 @@ export async function fetchSkillDetail(
} }
export async function updateSkillEnabled( export async function updateSkillEnabled(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
enabled: boolean, enabled: boolean,
base: string = "",
): Promise<SkillActionPayload> { ): Promise<SkillActionPayload> {
const params = new URLSearchParams({ name, enabled: String(enabled) }); return mutation<SkillActionPayload>(transport, "skill.update", { name, enabled });
return request<SkillActionPayload>(
`${base}/api/webui/skills/update?${params}`,
token,
);
} }
export async function deleteSkill( export async function deleteSkill(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
base: string = "",
): Promise<SkillActionPayload> { ): Promise<SkillActionPayload> {
const params = new URLSearchParams({ name }); return mutation<SkillActionPayload>(transport, "skill.delete", { name });
return request<SkillActionPayload>(
`${base}/api/webui/skills/delete?${params}`,
token,
);
} }
export async function searchMarketplaceSkills( export async function searchMarketplaceSkills(
@@ -389,37 +380,33 @@ export async function fetchMarketplaceSkillTrends(
} }
export async function installMarketplaceSkill( export async function installMarketplaceSkill(
token: string, transport: WebUIMutationTransport,
provider: Exclude<MarketplaceProvider, "all">, provider: Exclude<MarketplaceProvider, "all">,
source: string, source: string,
skill: string, skill: string,
version: string = "", version: string = "",
base: string = "",
): Promise<SkillInstallPayload> { ): Promise<SkillInstallPayload> {
const params = new URLSearchParams({ provider, source, skill }); return mutation<SkillInstallPayload>(
if (version) params.set("version", version); transport,
return request<SkillInstallPayload>( "skill.install",
`${base}/api/webui/skills/install?${params}`, { provider, source, skill, ...(version ? { version } : {}) },
token, PACKAGE_MUTATION_TIMEOUT_MS,
undefined,
150_000,
); );
} }
export async function deleteSession( export async function deleteSession(
token: string, transport: WebUIMutationTransport,
key: string, key: string,
optionsOrBase?: { deleteAutomations?: boolean } | string, optionsOrBase?: { deleteAutomations?: boolean } | string,
base: string = "",
): Promise<SessionDeleteResult> { ): Promise<SessionDeleteResult> {
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase; const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base; return mutation<SessionDeleteResult>(
const query = new URLSearchParams(); transport,
if (options?.deleteAutomations) query.set("delete_automations", "true"); "session.delete",
const suffix = query.toString() ? `?${query}` : ""; {
return request<SessionDeleteResult>( key,
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`, ...(options?.deleteAutomations ? { delete_automations: true } : {}),
token, },
); );
} }
@@ -520,56 +507,50 @@ export async function fetchApiService(token: string, base: string = ""): Promise
} }
export async function startApiService( export async function startApiService(
token: string, transport: WebUIMutationTransport,
values: { host: string; port: number; timeout: number; apiKey?: string }, values: { host: string; port: number; timeout: number; apiKey?: string },
base: string = "",
): Promise<ApiServicePayload> { ): Promise<ApiServicePayload> {
const query = new URLSearchParams({ return mutation<ApiServicePayload>(
host: values.host, transport,
port: String(values.port), "settings.api_service.start",
timeout: String(values.timeout), {
}); host: values.host,
const headers = values.apiKey === undefined port: values.port,
? undefined timeout: values.timeout,
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) }; ...(values.apiKey !== undefined ? { api_key: values.apiKey } : {}),
return request<ApiServicePayload>( },
`${base}/api/settings/api-service/start?${query}`, PACKAGE_MUTATION_TIMEOUT_MS,
token,
{ headers },
); );
} }
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> { export async function stopApiService(
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token); transport: WebUIMutationTransport,
): Promise<ApiServicePayload> {
return mutation<ApiServicePayload>(transport, "settings.api_service.stop");
} }
export async function enableNanobotFeature( export async function enableNanobotFeature(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
options: { instanceId?: string } = {}, options: { instanceId?: string } = {},
base: string = "",
): Promise<NanobotFeaturesPayload> { ): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams(); return mutation<NanobotFeaturesPayload>(
query.set("name", name); transport,
if (options.instanceId) query.set("instance_id", options.instanceId); "settings.feature.enable",
return request<NanobotFeaturesPayload>( { name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
`${base}/api/settings/nanobot-features/enable?${query}`, PACKAGE_MUTATION_TIMEOUT_MS,
token,
); );
} }
export async function disableNanobotFeature( export async function disableNanobotFeature(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
options: { instanceId?: string } = {}, options: { instanceId?: string } = {},
base: string = "",
): Promise<NanobotFeaturesPayload> { ): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams(); return mutation<NanobotFeaturesPayload>(
query.set("name", name); transport,
if (options.instanceId) query.set("instance_id", options.instanceId); "settings.feature.disable",
return request<NanobotFeaturesPayload>( { name, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
`${base}/api/settings/nanobot-features/disable?${query}`,
token,
); );
} }
@@ -586,21 +567,15 @@ export async function fetchPairingRequests(
} }
export async function runPairingAction( export async function runPairingAction(
token: string, transport: WebUIMutationTransport,
action: "approve" | "deny", action: "approve" | "deny",
code: string, code: string,
base: string = "",
): Promise<PairingPayload> { ): Promise<PairingPayload> {
const query = new URLSearchParams(); return mutation<PairingPayload>(transport, `settings.pairing.${action}`, { code });
query.set("code", code);
return request<PairingPayload>(
`${base}/api/settings/pairing/${action}?${query}`,
token,
);
} }
export async function startChannelConnect( export async function startChannelConnect(
token: string, transport: WebUIMutationTransport,
channel: string, channel: string,
options: { options: {
domain?: string; domain?: string;
@@ -608,104 +583,95 @@ export async function startChannelConnect(
mode?: "replace" | "create"; mode?: "replace" | "create";
force?: boolean; force?: boolean;
} = {}, } = {},
base: string = "",
): Promise<ChannelConnectPayload> { ): Promise<ChannelConnectPayload> {
const query = new URLSearchParams(); return mutation<ChannelConnectPayload>(
if (options.domain) query.set("domain", options.domain); transport,
if (options.instanceId) query.set("instance_id", options.instanceId); "settings.channel.connect.start",
if (options.mode) query.set("mode", options.mode); {
if (options.force) query.set("force", "true"); channel,
const suffix = query.toString(); ...(options.domain ? { domain: options.domain } : {}),
return request<ChannelConnectPayload>( ...(options.instanceId ? { instance_id: options.instanceId } : {}),
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`, ...(options.mode ? { mode: options.mode } : {}),
token, ...(options.force ? { force: true } : {}),
},
PACKAGE_MUTATION_TIMEOUT_MS,
); );
} }
export async function pollChannelConnect( export async function pollChannelConnect(
token: string, transport: WebUIMutationTransport,
channel: string, channel: string,
sessionId: string, sessionId: string,
base: string = "",
params: Readonly<Record<string, string>> = {}, params: Readonly<Record<string, string>> = {},
): Promise<ChannelConnectPayload> { ): Promise<ChannelConnectPayload> {
const query = new URLSearchParams(); const values = Object.fromEntries(
query.set("session_id", sessionId); Object.entries(params).filter(([key]) => key !== "session_id"),
Object.entries(params).forEach(([key, value]) => { );
if (key !== "session_id") query.set(key, value); return mutation<ChannelConnectPayload>(
}); transport,
return request<ChannelConnectPayload>( "settings.channel.connect.poll",
`${base}/api/settings/channels/${channel}/connect/poll?${query}`, { channel, session_id: sessionId, ...values },
token, PACKAGE_MUTATION_TIMEOUT_MS,
); );
} }
export async function cancelChannelConnect( export async function cancelChannelConnect(
token: string, transport: WebUIMutationTransport,
channel: string, channel: string,
sessionId: string, sessionId: string,
base: string = "",
): Promise<ChannelConnectPayload> { ): Promise<ChannelConnectPayload> {
const query = new URLSearchParams(); return mutation<ChannelConnectPayload>(
query.set("session_id", sessionId); transport,
return request<ChannelConnectPayload>( "settings.channel.connect.cancel",
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`, { channel, session_id: sessionId },
token,
); );
} }
export async function configureChannel( export async function configureChannel(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
values: Record<string, string>, values: Record<string, string>,
options: { enable?: boolean; instanceId?: string } = {}, options: { enable?: boolean; instanceId?: string } = {},
base: string = "",
): Promise<ChannelConfigurePayload> { ): Promise<ChannelConfigurePayload> {
const query = new URLSearchParams(); return mutation<ChannelConfigurePayload>(
query.set("name", name); transport,
if (options.enable !== undefined) query.set("enable", String(options.enable)); "settings.channel.configure",
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<ChannelConfigurePayload>(
`${base}/api/settings/channels/configure?${query}`,
token,
{ {
headers: { name,
[CHANNEL_VALUES_HEADER]: JSON.stringify(values), values,
}, ...(options.enable !== undefined ? { enable: options.enable } : {}),
...(options.instanceId ? { instance_id: options.instanceId } : {}),
}, },
PACKAGE_MUTATION_TIMEOUT_MS,
); );
} }
export async function validateChannel( export async function validateChannel(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
values: Record<string, string> = {}, values: Record<string, string> = {},
options: { instanceId?: string } = {}, options: { instanceId?: string } = {},
base: string = "",
): Promise<ChannelValidationPayload> { ): Promise<ChannelValidationPayload> {
const query = new URLSearchParams(); return mutation<ChannelValidationPayload>(
query.set("name", name); transport,
if (options.instanceId) query.set("instance_id", options.instanceId); "settings.channel.validate",
return request<ChannelValidationPayload>( { name, values, ...(options.instanceId ? { instance_id: options.instanceId } : {}) },
`${base}/api/settings/channels/validate?${query}`,
token,
{
headers: {
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
},
},
); );
} }
export async function runCliAppAction( export async function runCliAppAction(
token: string, transport: WebUIMutationTransport,
action: "install" | "update" | "uninstall" | "test", action: "install" | "update" | "uninstall" | "test",
name: string, name: string,
base: string = "",
): Promise<CliAppsPayload> { ): Promise<CliAppsPayload> {
const query = new URLSearchParams(); return mutation<CliAppsPayload>(
query.set("name", name); transport,
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token); `settings.cli_app.${action}`,
{ name },
action === "install" || action === "update"
? PACKAGE_MUTATION_TIMEOUT_MS
: API_MUTATION_TIMEOUT_MS,
);
} }
export async function fetchMcpPresets( export async function fetchMcpPresets(
@@ -736,55 +702,45 @@ export async function fetchProviderModels(
} }
export async function runMcpPresetAction( export async function runMcpPresetAction(
token: string, transport: WebUIMutationTransport,
action: "enable" | "remove" | "test", action: "enable" | "remove" | "test",
name: string, name: string,
values: Record<string, string> = {}, values: Record<string, string> = {},
base: string = "",
): Promise<McpPresetsPayload> { ): Promise<McpPresetsPayload> {
const query = new URLSearchParams(); return mutation<McpPresetsPayload>(
query.set("name", name); transport,
return request<McpPresetsPayload>( `settings.mcp.${action}`,
`${base}/api/settings/mcp-presets/${action}?${query}`, { name, ...compactMcpValues(values) },
token,
{ headers: mcpValuesHeader(values) },
); );
} }
export async function saveCustomMcpServer( export async function saveCustomMcpServer(
token: string, transport: WebUIMutationTransport,
values: Record<string, string>, values: Record<string, string>,
base: string = "",
): Promise<McpPresetsPayload> { ): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>( return mutation<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/custom`, transport,
token, "settings.mcp.custom",
{ headers: mcpValuesHeader(values) }, compactMcpValues(values),
); );
} }
export async function importMcpConfig( export async function importMcpConfig(
token: string, transport: WebUIMutationTransport,
config: string, config: string,
base: string = "",
): Promise<McpPresetsPayload> { ): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>( return mutation<McpPresetsPayload>(transport, "settings.mcp.import", { config });
`${base}/api/settings/mcp-presets/import`,
token,
{ headers: mcpValuesHeader({ config }) },
);
} }
export async function updateMcpServerTools( export async function updateMcpServerTools(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
enabledTools: string[], enabledTools: string[],
base: string = "",
): Promise<McpPresetsPayload> { ): Promise<McpPresetsPayload> {
return request<McpPresetsPayload>( return mutation<McpPresetsPayload>(
`${base}/api/settings/mcp-presets/tools`, transport,
token, "settings.mcp.tools",
{ headers: mcpValuesHeader({ name, enabled_tools: enabledTools }) }, { name, enabled_tools: enabledTools },
); );
} }
@@ -835,280 +791,228 @@ export async function fetchSidebarState(
} }
export async function updateSidebarState( export async function updateSidebarState(
token: string, transport: WebUIMutationTransport,
state: SidebarStatePayload, state: SidebarStatePayload,
base: string = "",
): Promise<SidebarStatePayload> { ): Promise<SidebarStatePayload> {
const query = new URLSearchParams(); return mutation<SidebarStatePayload>(transport, "sidebar.update", { state });
query.set("state", JSON.stringify(state));
return request<SidebarStatePayload>(
`${base}/api/webui/sidebar-state/update?${query}`,
token,
);
} }
export async function updateSettings( export async function updateSettings(
token: string, transport: WebUIMutationTransport,
update: SettingsUpdate, update: SettingsUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); const payload: Record<string, unknown> = {};
if (update.modelPreset !== undefined) { 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.model !== undefined) payload.model = update.model;
if (update.provider !== undefined) query.set("provider", update.provider); if (update.provider !== undefined) payload.provider = update.provider;
if (update.contextWindowTokens !== undefined) { 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) { if (update.toolHintMaxLength !== undefined) {
query.set("tool_hint_max_length", String(update.toolHintMaxLength)); payload.tool_hint_max_length = update.toolHintMaxLength;
} }
return request<SettingsPayload>(`${base}/api/settings/update?${query}`, token); return mutation<SettingsPayload>(transport, "settings.agent.update", payload);
} }
function appendModelGenerationSettings( function modelGenerationSettingsPayload(
query: URLSearchParams,
configuration: Pick< configuration: Pick<
ModelConfigurationCreate, ModelConfigurationCreate,
"maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort" "maxTokens" | "contextWindowTokens" | "temperature" | "reasoningEffort"
>, >,
): void { ): Record<string, unknown> {
const payload: Record<string, unknown> = {};
if (configuration.maxTokens !== undefined) { if (configuration.maxTokens !== undefined) {
query.set("max_tokens", String(configuration.maxTokens)); payload.max_tokens = configuration.maxTokens;
} }
if (configuration.contextWindowTokens !== undefined) { if (configuration.contextWindowTokens !== undefined) {
query.set("context_window_tokens", String(configuration.contextWindowTokens)); payload.context_window_tokens = configuration.contextWindowTokens;
} }
if (configuration.temperature !== undefined) { if (configuration.temperature !== undefined) {
query.set("temperature", String(configuration.temperature)); payload.temperature = configuration.temperature;
} }
if (configuration.reasoningEffort !== undefined) { if (configuration.reasoningEffort !== undefined) {
query.set("reasoning_effort", configuration.reasoningEffort ?? ""); payload.reasoning_effort = configuration.reasoningEffort ?? "";
} }
return payload;
} }
export async function createModelConfiguration( export async function createModelConfiguration(
token: string, transport: WebUIMutationTransport,
configuration: ModelConfigurationCreate, configuration: ModelConfigurationCreate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); return mutation<SettingsPayload>(
if (configuration.name !== undefined) query.set("name", configuration.name); transport,
query.set("label", configuration.label); "settings.model_configuration.create",
query.set("provider", configuration.provider); {
query.set("model", configuration.model); ...(configuration.name !== undefined ? { name: configuration.name } : {}),
appendModelGenerationSettings(query, configuration); label: configuration.label,
return request<SettingsPayload>( provider: configuration.provider,
`${base}/api/settings/model-configurations/create?${query}`, model: configuration.model,
token, ...modelGenerationSettingsPayload(configuration),
},
); );
} }
export async function updateModelConfiguration( export async function updateModelConfiguration(
token: string, transport: WebUIMutationTransport,
configuration: ModelConfigurationUpdate, configuration: ModelConfigurationUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); return mutation<SettingsPayload>(
query.set("name", configuration.name); transport,
if (configuration.label !== undefined) query.set("label", configuration.label); "settings.model_configuration.update",
if (configuration.provider !== undefined) query.set("provider", configuration.provider); {
if (configuration.model !== undefined) query.set("model", configuration.model); name: configuration.name,
appendModelGenerationSettings(query, configuration); ...(configuration.label !== undefined ? { label: configuration.label } : {}),
return request<SettingsPayload>( ...(configuration.provider !== undefined ? { provider: configuration.provider } : {}),
`${base}/api/settings/model-configurations/update?${query}`, ...(configuration.model !== undefined ? { model: configuration.model } : {}),
token, ...modelGenerationSettingsPayload(configuration),
},
); );
} }
export async function deleteModelConfiguration( export async function deleteModelConfiguration(
token: string, transport: WebUIMutationTransport,
name: string, name: string,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams({ name }); return mutation<SettingsPayload>(
return request<SettingsPayload>( transport,
`${base}/api/settings/model-configurations/delete?${query}`, "settings.model_configuration.delete",
token, { name },
); );
} }
export async function migrateModelConfigurations( export async function migrateModelConfigurations(
token: string, transport: WebUIMutationTransport,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
return request<SettingsPayload>( return mutation<SettingsPayload>(transport, "settings.model_configuration.migrate");
`${base}/api/settings/model-configurations/migrate`,
token,
);
} }
export async function updateModelCallOrder( export async function updateModelCallOrder(
token: string, transport: WebUIMutationTransport,
order: string[], order: string[],
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams({ order: JSON.stringify(order) }); return mutation<SettingsPayload>(transport, "settings.model_call_order.update", { order });
return request<SettingsPayload>(
`${base}/api/settings/model-call-order/update?${query}`,
token,
);
} }
export async function updateProviderSettings( export async function updateProviderSettings(
token: string, transport: WebUIMutationTransport,
update: ProviderSettingsUpdate, update: ProviderSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const { provider, ...values } = update; return mutation<SettingsPayload>(transport, "settings.provider.update", { ...update });
const query = new URLSearchParams({ provider });
return request<SettingsPayload>(
`${base}/api/settings/provider/update?${query}`,
token,
{
headers: {
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(values)),
},
},
);
} }
export async function createProviderSettings( export async function createProviderSettings(
token: string, transport: WebUIMutationTransport,
update: ProviderCreationUpdate, update: ProviderCreationUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
return request<SettingsPayload>( return mutation<SettingsPayload>(transport, "settings.provider.create", { ...update });
`${base}/api/settings/provider/create`,
token,
{
headers: {
[PROVIDER_VALUES_HEADER]: encodeURIComponent(JSON.stringify(update)),
},
},
);
} }
export async function loginProviderOAuth( export async function loginProviderOAuth(
token: string, transport: WebUIMutationTransport,
provider: string, provider: string,
base: string = "",
remoteBrowserAccess: boolean = false, remoteBrowserAccess: boolean = false,
): Promise<ProviderOAuthLoginResult> { ): Promise<ProviderOAuthLoginResult> {
const query = new URLSearchParams(); return mutation<ProviderOAuthLoginResult>(
query.set("provider", provider); transport,
if (remoteBrowserAccess) query.set("remote_browser", "true"); "settings.provider.oauth_login",
return request<ProviderOAuthLoginResult>( { provider, ...(remoteBrowserAccess ? { remote_browser: true } : {}) },
`${base}/api/settings/provider/oauth-login?${query}`,
token,
{ cache: "no-store" },
); );
} }
export async function completeProviderOAuth( export async function completeProviderOAuth(
token: string, transport: WebUIMutationTransport,
provider: string, provider: string,
flowId: string, flowId: string,
authorizationResponse?: string, authorizationResponse?: string,
base: string = "",
): Promise<ProviderOAuthCompletionResult> { ): Promise<ProviderOAuthCompletionResult> {
const query = new URLSearchParams(); return mutation<ProviderOAuthCompletionResult>(
query.set("provider", provider); transport,
query.set("flow_id", flowId); "settings.provider.oauth_complete",
const responseHeader = provider === "openai_codex" {
? OAUTH_CALLBACK_HEADER provider,
: OAUTH_CODE_HEADER; flow_id: flowId,
const headers = authorizationResponse ...(authorizationResponse ? { authorization_response: authorizationResponse } : {}),
? { [responseHeader]: authorizationResponse } },
: undefined;
return request<ProviderOAuthCompletionResult>(
`${base}/api/settings/provider/oauth-login/complete?${query}`,
token,
{ cache: "no-store", ...(headers ? { headers } : {}) },
); );
} }
export async function logoutProviderOAuth( export async function logoutProviderOAuth(
token: string, transport: WebUIMutationTransport,
provider: string, provider: string,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); return mutation<SettingsPayload>(transport, "settings.provider.oauth_logout", { provider });
query.set("provider", provider);
return request<SettingsPayload>(
`${base}/api/settings/provider/oauth-logout?${query}`,
token,
);
} }
export async function updateWebSearchSettings( export async function updateWebSearchSettings(
token: string, transport: WebUIMutationTransport,
update: WebSearchSettingsUpdate, update: WebSearchSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); return mutation<SettingsPayload>(
query.set("provider", update.provider); transport,
if (update.apiKey !== undefined) query.set("api_key", update.apiKey); "settings.web_search.update",
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl); {
if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults)); provider: update.provider,
if (update.timeout !== undefined) query.set("timeout", String(update.timeout)); ...(update.apiKey !== undefined ? { api_key: update.apiKey } : {}),
if (update.useJinaReader !== undefined) { ...(update.baseUrl !== undefined ? { base_url: update.baseUrl } : {}),
query.set("use_jina_reader", String(update.useJinaReader)); ...(update.maxResults !== undefined ? { max_results: update.maxResults } : {}),
} ...(update.timeout !== undefined ? { timeout: update.timeout } : {}),
return request<SettingsPayload>( ...(update.useJinaReader !== undefined
`${base}/api/settings/web-search/update?${query}`, ? { use_jina_reader: update.useJinaReader }
token, : {}),
},
); );
} }
export async function updateNetworkSafetySettings( export async function updateNetworkSafetySettings(
token: string, transport: WebUIMutationTransport,
update: NetworkSafetySettingsUpdate, update: NetworkSafetySettingsUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); return mutation<SettingsPayload>(
query.set("webui_allow_local_service_access", String(update.webuiAllowLocalServiceAccess)); transport,
query.set("webui_default_access_mode", update.webuiDefaultAccessMode); "settings.network_safety.update",
return request<SettingsPayload>( {
`${base}/api/settings/network-safety/update?${query}`, webui_allow_local_service_access: update.webuiAllowLocalServiceAccess,
token, webui_default_access_mode: update.webuiDefaultAccessMode,
},
); );
} }
export async function updateImageGenerationSettings( export async function updateImageGenerationSettings(
token: string, transport: WebUIMutationTransport,
update: ImageGenerationSettingsUpdate, update: ImageGenerationSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); return mutation<SettingsPayload>(
query.set("enabled", String(update.enabled)); transport,
query.set("provider", update.provider); "settings.image_generation.update",
query.set("model", update.model); {
query.set("default_aspect_ratio", update.defaultAspectRatio); enabled: update.enabled,
query.set("default_image_size", update.defaultImageSize); provider: update.provider,
query.set("max_images_per_turn", String(update.maxImagesPerTurn)); model: update.model,
return request<SettingsPayload>( default_aspect_ratio: update.defaultAspectRatio,
`${base}/api/settings/image-generation/update?${query}`, default_image_size: update.defaultImageSize,
token, max_images_per_turn: update.maxImagesPerTurn,
},
); );
} }
export async function updateTranscriptionSettings( export async function updateTranscriptionSettings(
token: string, transport: WebUIMutationTransport,
update: TranscriptionSettingsUpdate, update: TranscriptionSettingsUpdate,
base: string = "",
): Promise<SettingsPayload> { ): Promise<SettingsPayload> {
const query = new URLSearchParams(); return mutation<SettingsPayload>(
query.set("enabled", String(update.enabled)); transport,
query.set("provider", update.provider); "settings.transcription.update",
query.set("model", update.model); {
query.set("language", update.language); enabled: update.enabled,
query.set("max_duration_sec", String(update.maxDurationSec)); provider: update.provider,
query.set("max_upload_mb", String(update.maxUploadMb)); model: update.model,
return request<SettingsPayload>( language: update.language,
`${base}/api/settings/transcription/update?${query}`, max_duration_sec: update.maxDurationSec,
token, max_upload_mb: update.maxUploadMb,
},
); );
} }
+91 -2
View File
@@ -108,6 +108,16 @@ interface PendingRequest<T> {
timer: ReturnType<typeof setTimeout>; timer: ReturnType<typeof setTimeout>;
} }
export class WebUIMutationError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = "WebUIMutationError";
}
}
interface PendingChatRequest extends PendingRequest<string> { interface PendingChatRequest extends PendingRequest<string> {
temporary: boolean; temporary: boolean;
} }
@@ -203,6 +213,7 @@ export class NanobotClient {
private pendingNewChat: PendingChatRequest | null = null; private pendingNewChat: PendingChatRequest | null = null;
private pendingTranscriptions = new Map<string, PendingRequest<string>>(); private pendingTranscriptions = new Map<string, PendingRequest<string>>();
private pendingSystemCommands = new Map<string, PendingRequest<void>>(); private pendingSystemCommands = new Map<string, PendingRequest<void>>();
private pendingWebUIRequests = new Map<string, PendingRequest<unknown>>();
// Frames queued while the socket is not yet OPEN // Frames queued while the socket is not yet OPEN
private sendQueue: Outbound[] = []; private sendQueue: Outbound[] = [];
private reconnectAttempts = 0; 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<T>(
action: string,
payload: Record<string, unknown> = {},
timeoutMs: number = 20_000,
): Promise<T> {
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<T>((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. */ /** Ask the server to create a non-destructive fork before a user-message index. */
forkChat( forkChat(
sourceChatId: string, sourceChatId: string,
@@ -914,8 +979,8 @@ export class NanobotClient {
}); });
} }
setSidebarState(state: SidebarStatePayload): void { setSidebarState(state: SidebarStatePayload): Promise<SidebarStatePayload> {
this.queueSend({ type: "set_sidebar_state", state }); return this.requestMutation<SidebarStatePayload>("sidebar.update", { state });
} }
// -- internals --------------------------------------------------------- // -- internals ---------------------------------------------------------
@@ -965,6 +1030,23 @@ export class NanobotClient {
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed)); 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) { if (parsed.event === "error" && !parsed.turn_id) {
const fallback = this.legacyRejectionTarget(parsed); const fallback = this.legacyRejectionTarget(parsed);
if (fallback) { if (fallback) {
@@ -1151,6 +1233,13 @@ export class NanobotClient {
this.pendingNewChat = null; this.pendingNewChat = null;
} }
this.rejectAllTranscriptions("socket closed"); 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()) { for (const pending of this.pendingSystemCommands.values()) {
clearTimeout(pending.timer); clearTimeout(pending.timer);
pending.reject(new Error("socket closed")); pending.reject(new Error("socket closed"));
+18
View File
@@ -1261,6 +1261,18 @@ export type InboundEvent =
detail?: string; detail?: string;
provider?: 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"; event: "error";
chat_id?: string; chat_id?: string;
@@ -1339,6 +1351,12 @@ export interface FilePreviewPayload {
export type Outbound = export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload } | { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "new_temporary_chat" } | { type: "new_temporary_chat" }
| {
type: "webui_request";
request_id: string;
action: string;
payload: Record<string, unknown>;
}
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string } | { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string } | { type: "attach"; chat_id: string }
| { type: "set_sidebar_state"; state: SidebarStatePayload } | { type: "set_sidebar_state"; state: SidebarStatePayload }
+310 -359
View File
@@ -59,8 +59,19 @@ import {
validateChannel, validateChannel,
} from "@/lib/api"; } from "@/lib/api";
const requestMutation = vi.fn();
const mutationTransport = {
requestMutation: <T>(
action: string,
payload?: Record<string, unknown>,
timeoutMs?: number,
) => requestMutation(action, payload, timeoutMs) as Promise<T>,
};
describe("webui API helpers", () => { describe("webui API helpers", () => {
beforeEach(() => { beforeEach(() => {
requestMutation.mockReset();
requestMutation.mockResolvedValue({});
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
@@ -184,88 +195,74 @@ describe("webui API helpers", () => {
it("validates channel settings with form values", async () => { it("validates channel settings with form values", async () => {
await validateChannel( await validateChannel(
"tok", mutationTransport,
"slack", "slack",
{ "channels.slack.botToken": "xoxb-test" }, { "channels.slack.botToken": "xoxb-test" },
{ instanceId: "default" }, { instanceId: "default" },
); );
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/channels/validate?name=slack&instance_id=default", "settings.channel.validate",
expect.objectContaining({ {
headers: expect.objectContaining({ name: "slack",
Authorization: "Bearer tok", instance_id: "default",
"X-Nanobot-Channel-Values": JSON.stringify({ values: { "channels.slack.botToken": "xoxb-test" },
"channels.slack.botToken": "xoxb-test", },
}), 20_000,
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
); );
expect(fetch).not.toHaveBeenCalled();
}); });
it("configures channels through the WebSocket HTTP shim", async () => { it("configures channels through the authenticated WebSocket", async () => {
await configureChannel( await configureChannel(
"tok", mutationTransport,
"discord", "discord",
{ "channels.discord.token": "saved-secret" }, { "channels.discord.token": "saved-secret" },
{ enable: true }, { enable: true },
); );
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/channels/configure?name=discord&enable=true", "settings.channel.configure",
expect.objectContaining({ {
headers: expect.objectContaining({ name: "discord",
Authorization: "Bearer tok", enable: true,
"X-Nanobot-Channel-Values": JSON.stringify({ values: { "channels.discord.token": "saved-secret" },
"channels.discord.token": "saved-secret", },
}), 150_000,
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
); );
expect(fetch).not.toHaveBeenCalled();
}); });
it("serializes channel QR connect helpers", async () => { it("serializes channel QR connect request envelopes", async () => {
await startChannelConnect("tok", "weixin", { force: true }); await startChannelConnect(mutationTransport, "weixin", { force: true });
expect(fetch).toHaveBeenLastCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/start?force=true", "settings.channel.connect.start",
expect.objectContaining({ { channel: "weixin", force: true },
headers: { Authorization: "Bearer tok" }, 150_000,
}),
); );
await pollChannelConnect("tok", "weixin", "session+/="); await pollChannelConnect(mutationTransport, "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D", "settings.channel.connect.poll",
expect.objectContaining({ { channel: "weixin", session_id: "session+/=" },
headers: { Authorization: "Bearer tok" }, 150_000,
}),
); );
await cancelChannelConnect("tok", "weixin", "session+/="); await cancelChannelConnect(mutationTransport, "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D", "settings.channel.connect.cancel",
expect.objectContaining({ { channel: "weixin", session_id: "session+/=" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
it("serializes workspace automation actions", async () => { it("serializes workspace automation actions", async () => {
await runAutomationAction("tok", "disable", "job 1/2"); await runAutomationAction(mutationTransport, "disable", "job 1/2");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/webui/automations/disable?id=job+1%2F2", "automation.disable",
expect.objectContaining({ { id: "job 1/2" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
@@ -275,19 +272,14 @@ describe("webui API helpers", () => {
message: "Ask 今日 quiz", message: "Ask 今日 quiz",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" }, schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
} as const; } as const;
await updateAutomation("tok", "job 1/2", values); await updateAutomation(mutationTransport, "job 1/2", values);
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/webui/automations/update?id=job+1%2F2", "automation.update",
expect.objectContaining({ { id: "job 1/2", values },
headers: { 20_000,
Authorization: "Bearer tok",
"X-Nanobot-Automation-Values": encodeURIComponent(JSON.stringify(values)),
},
}),
); );
const header = vi.mocked(fetch).mock.calls[0][1]?.headers as Record<string, string>; expect(fetch).not.toHaveBeenCalled();
expect(header["X-Nanobot-Automation-Values"]).not.toContain("每日");
}); });
it("fetches the WebUI skill summary", async () => { 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( await installMarketplaceSkill(
"tok", mutationTransport,
"skillhub", "skillhub",
"@tencent/skills", "@tencent/skills",
"ima-skills", "ima-skills",
"1.1.8", "1.1.8",
); );
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/webui/skills/install?provider=skillhub&source=%40tencent%2Fskills&skill=ima-skills&version=1.1.8", "skill.install",
expect.objectContaining({ {
headers: { Authorization: "Bearer tok" }, provider: "skillhub",
}), source: "@tencent/skills",
skill: "ima-skills",
version: "1.1.8",
},
150_000,
); );
}); });
it("updates and deletes installed skills with encoded names", async () => { it("updates and deletes installed skills over the WebSocket", async () => {
await updateSkillEnabled("tok", "custom skill", false); await updateSkillEnabled(mutationTransport, "custom skill", false);
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/webui/skills/update?name=custom+skill&enabled=false", "skill.update",
expect.objectContaining({ { name: "custom skill", enabled: false },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
await deleteSkill("tok", "custom skill"); await deleteSkill(mutationTransport, "custom skill");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/webui/skills/delete?name=custom+skill", "skill.delete",
expect.objectContaining({ { name: "custom skill" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
it("percent-encodes websocket keys when deleting a session", async () => { it("sends the session key in a mutation payload", async () => {
await deleteSession("tok", "websocket:chat-1"); await deleteSession(mutationTransport, "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete", "session.delete",
expect.objectContaining({ { key: "websocket:chat-1" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
it("passes the automation cascade flag when deleting a session", async () => { 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( expect(requestMutation).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true", "session.delete",
expect.objectContaining({ { key: "websocket:chat-1", delete_automations: true },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
it("serializes settings updates as a narrow query string", async () => { it("serializes settings updates as a narrow mutation payload", async () => {
await updateSettings("tok", { await updateSettings(mutationTransport, {
modelPreset: "default", modelPreset: "default",
model: "openrouter/test", model: "openrouter/test",
provider: "openrouter", provider: "openrouter",
@@ -416,11 +408,17 @@ describe("webui API helpers", () => {
toolHintMaxLength: 120, toolHintMaxLength: 120,
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&tool_hint_max_length=120", "settings.agent.update",
expect.objectContaining({ {
headers: { Authorization: "Bearer tok" }, 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 () => { it("serializes model configuration creation", async () => {
await createModelConfiguration("tok", { await createModelConfiguration(mutationTransport, {
label: "Fast writing", label: "Fast writing",
provider: "openai", provider: "openai",
model: "openai/gpt-4.1-mini", model: "openai/gpt-4.1-mini",
@@ -446,16 +444,23 @@ describe("webui API helpers", () => {
reasoningEffort: "high", reasoningEffort: "high",
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).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", "settings.model_configuration.create",
expect.objectContaining({ {
headers: { Authorization: "Bearer tok" }, 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 () => { it("serializes model configuration updates", async () => {
await updateModelConfiguration("tok", { await updateModelConfiguration(mutationTransport, {
name: "codex", name: "codex",
label: "Codex", label: "Codex",
provider: "openai_codex", provider: "openai_codex",
@@ -466,42 +471,47 @@ describe("webui API helpers", () => {
reasoningEffort: null, reasoningEffort: null,
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).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=", "settings.model_configuration.update",
expect.objectContaining({ {
headers: { Authorization: "Bearer tok" }, 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 () => { it("serializes model preset deletion and migration", async () => {
await deleteModelConfiguration("tok", "spare"); await deleteModelConfiguration(mutationTransport, "spare");
await migrateModelConfigurations("tok"); await migrateModelConfigurations(mutationTransport);
expect(fetch).toHaveBeenNthCalledWith( expect(requestMutation).toHaveBeenNthCalledWith(
1, 1,
"/api/settings/model-configurations/delete?name=spare", "settings.model_configuration.delete",
expect.objectContaining({ { name: "spare" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
expect(fetch).toHaveBeenNthCalledWith( expect(requestMutation).toHaveBeenNthCalledWith(
2, 2,
"/api/settings/model-configurations/migrate", "settings.model_configuration.migrate",
expect.objectContaining({ {},
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
it("serializes model call order as an ordered JSON array", async () => { it("serializes model call order as an ordered JSON array", async () => {
await updateModelCallOrder("tok", ["backup", "primary"]); await updateModelCallOrder(mutationTransport, ["backup", "primary"]);
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/model-call-order/update?order=%5B%22backup%22%2C%22primary%22%5D", "settings.model_call_order.update",
expect.objectContaining({ { order: ["backup", "primary"] },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
@@ -516,28 +526,20 @@ describe("webui API helpers", () => {
}), }),
); );
await expect( await expect(fetchApiService("tok")).rejects.toMatchObject({
updateModelConfiguration("tok", {
name: "codex",
model: "openai-codex/gpt-5.5",
}),
).rejects.toMatchObject({
status: 200, status: 200,
message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.", message: "Gateway returned WebUI HTML instead of JSON. Restart nanobot gateway and try again.",
}); });
}); });
it("surfaces API error response bodies", async () => { it("surfaces correlated WebSocket mutation errors", async () => {
vi.stubGlobal( requestMutation.mockRejectedValueOnce(
"fetch", Object.assign(new Error("npm error ENOTEMPTY"), { status: 500 }),
vi.fn().mockResolvedValue({
ok: false,
status: 500,
text: async () => "npm error ENOTEMPTY",
}),
); );
await expect(runCliAppAction("tok", "install", "hyperframes")).rejects.toMatchObject({ await expect(
runCliAppAction(mutationTransport, "install", "hyperframes"),
).rejects.toMatchObject({
status: 500, status: 500,
message: "npm error ENOTEMPTY", message: "npm error ENOTEMPTY",
}); });
@@ -555,50 +557,45 @@ describe("webui API helpers", () => {
await pending; await pending;
}); });
it("serializes provider settings updates without returning secrets", async () => { it("keeps provider secrets in the WebSocket payload", async () => {
await updateProviderSettings("tok", { await updateProviderSettings(mutationTransport, {
provider: "openrouter", provider: "openrouter",
apiKey: "sk-or-test", apiKey: "sk-or-test",
apiBase: "https://openrouter.ai/api/v1", apiBase: "https://openrouter.ai/api/v1",
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=openrouter", "settings.provider.update",
expect.objectContaining({ {
headers: { provider: "openrouter",
Authorization: "Bearer tok", apiKey: "sk-or-test",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ apiBase: "https://openrouter.ai/api/v1",
apiKey: "sk-or-test", },
apiBase: "https://openrouter.ai/api/v1", 20_000,
})),
},
}),
); );
expect(fetch).not.toHaveBeenCalled();
}); });
it("serializes OAuth provider advanced settings", async () => { it("serializes OAuth provider advanced settings", async () => {
await updateProviderSettings("tok", { await updateProviderSettings(mutationTransport, {
provider: "xai_grok", provider: "xai_grok",
proxy: "http://127.0.0.1:7890", proxy: "http://127.0.0.1:7890",
extraBody: '{"tools":[]}', extraBody: '{"tools":[]}',
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/provider/update?provider=xai_grok", "settings.provider.update",
expect.objectContaining({ {
headers: { provider: "xai_grok",
Authorization: "Bearer tok", proxy: "http://127.0.0.1:7890",
"X-Nanobot-Provider-Values": encodeURIComponent(JSON.stringify({ extraBody: '{"tools":[]}',
proxy: "http://127.0.0.1:7890", },
extraBody: '{"tools":[]}', 20_000,
})),
},
}),
); );
}); });
it("serializes custom provider creation with advanced settings", async () => { it("serializes custom provider creation with advanced settings", async () => {
await createProviderSettings("tok", { const update = {
name: "Company Gateway", name: "Company Gateway",
apiKey: "sk-company", apiKey: "sk-company",
apiBase: "https://gateway.example/v1", apiBase: "https://gateway.example/v1",
@@ -607,25 +604,13 @@ describe("webui API helpers", () => {
extraQuery: '{"api-version":"2026-01-01"}', extraQuery: '{"api-version":"2026-01-01"}',
proxy: "http://127.0.0.1:7890", proxy: "http://127.0.0.1:7890",
thinkingStyle: "enable_thinking", thinkingStyle: "enable_thinking",
}); };
await createProviderSettings(mutationTransport, update);
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/provider/create", "settings.provider.create",
expect.objectContaining({ update,
headers: { 20_000,
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",
})),
},
}),
); );
}); });
@@ -641,74 +626,65 @@ describe("webui API helpers", () => {
}); });
it("serializes provider OAuth login and logout actions", async () => { it("serializes provider OAuth login and logout actions", async () => {
await loginProviderOAuth("tok", "openai_codex"); await loginProviderOAuth(mutationTransport, "openai_codex");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex", "settings.provider.oauth_login",
expect.objectContaining({ { provider: "openai_codex" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
await loginProviderOAuth("tok", "openai_codex", "", true); await loginProviderOAuth(mutationTransport, "openai_codex", true);
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true", "settings.provider.oauth_login",
expect.objectContaining({ { provider: "openai_codex", remote_browser: true },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
await completeProviderOAuth("tok", "xai_grok", "flow-123"); await completeProviderOAuth(mutationTransport, "xai_grok", "flow-123");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123", "settings.provider.oauth_complete",
expect.objectContaining({ { provider: "xai_grok", flow_id: "flow-123" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
await completeProviderOAuth( await completeProviderOAuth(
"tok", mutationTransport,
"xai_grok", "xai_grok",
"flow-123", "flow-123",
"secret", "secret",
); );
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123", "settings.provider.oauth_complete",
expect.objectContaining({ { provider: "xai_grok", flow_id: "flow-123", authorization_response: "secret" },
headers: { 20_000,
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Code": "secret",
},
}),
); );
await completeProviderOAuth( await completeProviderOAuth(
"tok", mutationTransport,
"openai_codex", "openai_codex",
"flow-codex", "flow-codex",
"http://localhost:1455/auth/callback?code=secret&state=test", "http://localhost:1455/auth/callback?code=secret&state=test",
); );
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex", "settings.provider.oauth_complete",
expect.objectContaining({ {
headers: { provider: "openai_codex",
Authorization: "Bearer tok", flow_id: "flow-codex",
"X-Nanobot-OAuth-Callback": authorization_response: "http://localhost:1455/auth/callback?code=secret&state=test",
"http://localhost:1455/auth/callback?code=secret&state=test", },
}, 20_000,
}),
); );
await logoutProviderOAuth("tok", "openai_codex"); await logoutProviderOAuth(mutationTransport, "openai_codex");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex", "settings.provider.oauth_logout",
expect.objectContaining({ { provider: "openai_codex" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
it("serializes web search settings updates", async () => { it("serializes web search settings updates", async () => {
await updateWebSearchSettings("tok", { await updateWebSearchSettings(mutationTransport, {
provider: "searxng", provider: "searxng",
baseUrl: "https://search.example.com", baseUrl: "https://search.example.com",
maxResults: 8, maxResults: 8,
@@ -716,30 +692,37 @@ describe("webui API helpers", () => {
useJinaReader: false, useJinaReader: false,
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).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", "settings.web_search.update",
expect.objectContaining({ {
headers: { Authorization: "Bearer tok" }, 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 () => { it("serializes network safety settings updates", async () => {
await updateNetworkSafetySettings("tok", { await updateNetworkSafetySettings(mutationTransport, {
webuiAllowLocalServiceAccess: false, webuiAllowLocalServiceAccess: false,
webuiDefaultAccessMode: "full", webuiDefaultAccessMode: "full",
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full", "settings.network_safety.update",
expect.objectContaining({ {
headers: { Authorization: "Bearer tok" }, webui_allow_local_service_access: false,
}), webui_default_access_mode: "full",
},
20_000,
); );
}); });
it("serializes image generation settings updates", async () => { it("serializes image generation settings updates", async () => {
await updateImageGenerationSettings("tok", { await updateImageGenerationSettings(mutationTransport, {
enabled: true, enabled: true,
provider: "openrouter", provider: "openrouter",
model: "openai/gpt-5.4-image-2", model: "openai/gpt-5.4-image-2",
@@ -748,11 +731,17 @@ describe("webui API helpers", () => {
maxImagesPerTurn: 3, maxImagesPerTurn: 3,
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).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", "settings.image_generation.update",
expect.objectContaining({ {
headers: { Authorization: "Bearer tok" }, 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"); await runCliAppAction(mutationTransport, "install", "gimp");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/cli-apps/install?name=gimp", "settings.cli_app.install",
expect.objectContaining({ { name: "gimp" },
headers: { Authorization: "Bearer tok" }, 150_000,
}),
); );
}); });
@@ -819,20 +807,18 @@ describe("webui API helpers", () => {
}), }),
); );
await enableNanobotFeature("tok", "matrix"); await enableNanobotFeature(mutationTransport, "matrix");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/nanobot-features/enable?name=matrix", "settings.feature.enable",
expect.objectContaining({ { name: "matrix" },
headers: { Authorization: "Bearer tok" }, 150_000,
}),
); );
await disableNanobotFeature("tok", "matrix"); await disableNanobotFeature(mutationTransport, "matrix");
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/nanobot-features/disable?name=matrix", "settings.feature.disable",
expect.objectContaining({ { name: "matrix" },
headers: { Authorization: "Bearer tok" }, 20_000,
}),
); );
}); });
@@ -843,34 +829,31 @@ describe("webui API helpers", () => {
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
); );
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 }); await startApiService(
expect(fetch).toHaveBeenCalledWith( mutationTransport,
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120", { host: "127.0.0.1", port: 8900, timeout: 120 },
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), );
expect(requestMutation).toHaveBeenLastCalledWith(
"settings.api_service.start",
{ host: "127.0.0.1", port: 8900, timeout: 120 },
150_000,
); );
await startApiService( await startApiService(
"tok", mutationTransport,
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" }, { host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
); );
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120", "settings.api_service.start",
expect.objectContaining({ { host: "0.0.0.0", port: 8900, timeout: 120, api_key: "secret-token" },
headers: { 150_000,
Authorization: "Bearer tok",
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
},
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.stringContaining("secret-token"),
expect.anything(),
); );
await stopApiService("tok"); await stopApiService(mutationTransport);
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/api-service/stop", "settings.api_service.stop",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), {},
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", browserbase_api_key: "bb_live_test",
}); });
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenCalledWith(
"/api/settings/mcp-presets/enable?name=browserbase", "settings.mcp.enable",
expect.objectContaining({ { name: "browserbase", browserbase_api_key: "bb_live_test" },
headers: expect.objectContaining({ 20_000,
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
browserbase_api_key: "bb_live_test",
}),
}),
}),
); );
}); });
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => { it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
await saveCustomMcpServer("tok", { const custom = {
name: "docs", name: "docs",
transport: "stdio", transport: "stdio",
command: "npx", command: "npx",
args: '["-y","docs-mcp"]', args: '["-y","docs-mcp"]',
env: '{"API_KEY":"secret"}', env: '{"API_KEY":"secret"}',
}); };
expect(fetch).toHaveBeenCalledWith( await saveCustomMcpServer(mutationTransport, custom);
"/api/settings/mcp-presets/custom", expect(requestMutation).toHaveBeenLastCalledWith(
expect.objectContaining({ "settings.mcp.custom",
headers: expect.objectContaining({ custom,
Authorization: "Bearer tok", 20_000,
"X-Nanobot-MCP-Values": JSON.stringify({
name: "docs",
transport: "stdio",
command: "npx",
args: '["-y","docs-mcp"]',
env: '{"API_KEY":"secret"}',
}),
}),
}),
); );
await importMcpConfig("tok", '{"mcpServers":{"docs":{"command":"npx"}}}'); await importMcpConfig(
expect(fetch).toHaveBeenCalledWith( mutationTransport,
"/api/settings/mcp-presets/import", '{"mcpServers":{"docs":{"command":"npx"}}}',
expect.objectContaining({ );
headers: expect.objectContaining({ expect(requestMutation).toHaveBeenLastCalledWith(
Authorization: "Bearer tok", "settings.mcp.import",
"X-Nanobot-MCP-Values": JSON.stringify({ { config: '{"mcpServers":{"docs":{"command":"npx"}}}' },
config: '{"mcpServers":{"docs":{"command":"npx"}}}', 20_000,
}),
}),
}),
); );
await updateMcpServerTools("tok", "docs", ["search", "fetch"]); await updateMcpServerTools(mutationTransport, "docs", ["search", "fetch"]);
expect(fetch).toHaveBeenCalledWith( expect(requestMutation).toHaveBeenLastCalledWith(
"/api/settings/mcp-presets/tools", "settings.mcp.tools",
expect.objectContaining({ { name: "docs", enabled_tools: ["search", "fetch"] },
headers: expect.objectContaining({ 20_000,
Authorization: "Bearer tok",
"X-Nanobot-MCP-Values": JSON.stringify({
name: "docs",
enabled_tools: ["search", "fetch"],
}),
}),
}),
); );
}); });
@@ -991,19 +949,12 @@ describe("webui API helpers", () => {
}), }),
); );
await updateSidebarState("tok", state); await updateSidebarState(mutationTransport, state);
const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!; expect(requestMutation).toHaveBeenCalledWith(
expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true); "sidebar.update",
expect(init).toEqual(expect.objectContaining({ { state },
headers: { Authorization: "Bearer tok" }, 20_000,
})); );
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" },
});
}); });
it("fetches workspace project state", async () => { it("fetches workspace project state", async () => {
+67 -74
View File
@@ -19,6 +19,7 @@ const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn(); const updateUrlSpy = vi.fn();
const attachSpy = vi.fn(); const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn(); const setSidebarStateSpy = vi.fn();
const requestMutationSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn(); const discardTemporaryChatSpy = vi.fn();
const newTemporaryChatSpy = vi.fn<() => Promise<string>>(); const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
const sendMessageSpy = vi.fn(); const sendMessageSpy = vi.fn();
@@ -242,6 +243,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
newTemporaryChat = newTemporaryChatSpy; newTemporaryChat = newTemporaryChatSpy;
attach = attachSpy; attach = attachSpy;
setSidebarState = setSidebarStateSpy; setSidebarState = setSidebarStateSpy;
requestMutation = requestMutationSpy;
discardTemporaryChat = discardTemporaryChatSpy; discardTemporaryChat = discardTemporaryChatSpy;
close = vi.fn(); close = vi.fn();
updateUrl = updateUrlSpy; updateUrl = updateUrlSpy;
@@ -270,7 +272,8 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]); getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset(); toggleThemeSpy.mockReset();
attachSpy.mockReset(); attachSpy.mockReset();
setSidebarStateSpy.mockReset(); setSidebarStateSpy.mockReset().mockResolvedValue({});
requestMutationSpy.mockReset();
discardTemporaryChatSpy.mockReset(); discardTemporaryChatSpy.mockReset();
let temporaryChatCounter = 0; let temporaryChatCounter = 0;
newTemporaryChatSpy.mockImplementation(async () => ( newTemporaryChatSpy.mockImplementation(async () => (
@@ -877,40 +880,36 @@ describe("App layout", () => {
}], }],
raw_markdown: "---\nname: github\n---\nUse GitHub CLI.", raw_markdown: "---\nname: github\n---\nUse GitHub CLI.",
}, },
"/api/webui/skills/update?name=github&enabled=false": { });
skills: [ requestMutationSpy.mockResolvedValueOnce({
{ skills: [
name: "cron", {
description: "Schedule reminders.", name: "cron",
source: "builtin", description: "Schedule reminders.",
enabled: true, source: "builtin",
deletable: false, enabled: true,
available: 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,
}, },
}, {
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(<App />); render(<App />);
@@ -1010,14 +1009,10 @@ describe("App layout", () => {
}, },
raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.", raw_markdown: "---\nname: custom-skill\n---\nWorkspace instructions.",
}, },
"/api/webui/skills/delete?name=custom-skill": { });
skills: [], requestMutationSpy.mockResolvedValueOnce({
last_action: { skills: [],
name: "custom-skill", last_action: { name: "custom-skill", enabled: false, deleted: true },
enabled: false,
deleted: true,
},
},
}); });
render(<App />); render(<App />);
@@ -1149,9 +1144,8 @@ describe("App layout", () => {
"/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": { "/api/webui/skills/trends?id=acme%2Fagent-skills%2Freact-testing": {
trends: { "acme/agent-skills/react-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(<App />); render(<App />);
@@ -1199,11 +1193,14 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Install skill" })); fireEvent.click(screen.getByRole("button", { name: "Install skill" }));
await waitFor(() => { await waitFor(() => {
expect(fetch).toHaveBeenCalledWith( expect(requestMutationSpy).toHaveBeenCalledWith(
"/api/webui/skills/install?provider=skills_sh&source=acme%2Fagent-skills&skill=react-testing", "skill.install",
expect.objectContaining({ {
headers: { Authorization: expect.any(String) }, provider: "skills_sh",
}), source: "acme/agent-skills",
skill: "react-testing",
},
150_000,
); );
}); });
fireEvent.click(screen.getByRole("tab", { name: "Installed" })); fireEvent.click(screen.getByRole("tab", { name: "Installed" }));
@@ -1361,14 +1358,12 @@ describe("App layout", () => {
mockFetchRoutes({ mockFetchRoutes({
"/api/settings": baseSettingsPayload(), "/api/settings": baseSettingsPayload(),
"/api/webui/automations": { jobs: [pastOneShot] }, "/api/webui/automations": { jobs: [pastOneShot] },
"/api/webui/automations/update?id=past-one-shot": { });
jobs: [ requestMutationSpy.mockResolvedValueOnce({
{ jobs: [{
...pastOneShot, ...pastOneShot,
payload: { ...pastOneShot.payload, message: "Updated one-shot message" }, payload: { ...pastOneShot.payload, message: "Updated one-shot message" },
}, }],
],
},
}); });
render(<App />); render(<App />);
@@ -1394,20 +1389,18 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Save" })); fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => { await waitFor(() => {
expect(fetch).toHaveBeenCalledWith( expect(requestMutationSpy).toHaveBeenCalledWith(
"/api/webui/automations/update?id=past-one-shot", "automation.update",
expect.any(Object), {
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<string, string>;
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 () => { it("keeps long automation details expandable without nested scrolling", async () => {
@@ -1829,6 +1822,9 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
act(() => {
statusHandlers.forEach((handler) => handler("open"));
});
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() => await waitFor(() =>
expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(), expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
@@ -2581,17 +2577,14 @@ describe("App layout", () => {
mockFetchRoutes({ mockFetchRoutes({
"/api/settings": initialSettings, "/api/settings": initialSettings,
}); });
const fetchMock = vi.mocked(fetch);
window.history.replaceState(null, "", "/#/settings?section=runtime"); window.history.replaceState(null, "", "/#/settings?section=runtime");
render(<App />); render(<App />);
expect(await screen.findByText("UTC")).toBeInTheDocument(); expect(await screen.findByText("UTC")).toBeInTheDocument();
expect( expect(
fetchMock.mock.calls.filter(([input]) => requestMutationSpy.mock.calls.some(([action]) => action === "settings.agent.update"),
String(input).startsWith("/api/settings/update?timezone="), ).toBe(false);
),
).toHaveLength(0);
expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Regional" })).not.toBeInTheDocument();
expect( expect(
screen.queryByText("Used for schedules and time-aware replies."), screen.queryByText("Used for schedules and time-aware replies."),
+137 -3
View File
@@ -71,6 +71,122 @@ afterEach(() => {
}); });
describe("NanobotClient", () => { 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 () => { it("keeps temporary chats out of attachment and reconnect state", async () => {
const client = new NanobotClient({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
@@ -1071,7 +1187,7 @@ describe("NanobotClient", () => {
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true); 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({ const client = new NanobotClient({
url: "ws://test", url: "ws://test",
reconnect: false, reconnect: false,
@@ -1102,11 +1218,29 @@ describe("NanobotClient", () => {
client.connect(); client.connect();
lastSocket().fakeOpen(); lastSocket().fakeOpen();
client.setSidebarState(state); const pending = client.setSidebarState(state);
const [serialized] = lastSocket().sent; const [serialized] = lastSocket().sent;
expect(new TextEncoder().encode(serialized).byteLength).toBeGreaterThan(8_192); 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 () => { it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -109,8 +109,9 @@ describe("useSessions", () => {
]); ]);
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true }); vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
const client = fakeClient();
const { result } = renderHook(() => useSessions(), { const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()), wrapper: wrap(client),
}); });
await waitFor(() => expect(result.current.sessions).toHaveLength(2)); await waitFor(() => expect(result.current.sessions).toHaveLength(2));
@@ -119,7 +120,7 @@ describe("useSessions", () => {
await result.current.deleteChat("websocket:chat-a"); 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"]); expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
}); });