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