fix(settings): serialize gateway configuration updates

This commit is contained in:
Xubin Ren
2026-08-16 11:50:56 +08:00
parent 731b8fc2ed
commit dec89a49a3
13 changed files with 347 additions and 75 deletions
+16 -2
View File
@@ -380,6 +380,10 @@ class WebSocketChannel(BaseChannel):
tuple[ServerConnection, str], tuple[ServerConnection, str],
asyncio.Task[None], asyncio.Task[None],
] = {} ] = {}
# Preserve request/response order for non-replayable mutations from one
# UI. Without this, an earlier slow settings response can overwrite a
# newer settings snapshot in the client.
self._webui_request_locks: dict[ServerConnection, asyncio.Lock] = {}
self._stop_event: asyncio.Event | None = None self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None self._server_task: asyncio.Task[None] | None = None
@@ -476,6 +480,7 @@ class WebSocketChannel(BaseChannel):
await self._discard_connection_owned_chat(connection, cid) await self._discard_connection_owned_chat(connection, cid)
self._conn_default.pop(connection, None) self._conn_default.pop(connection, None)
self._webui_connections.discard(connection) self._webui_connections.discard(connection)
self._webui_request_locks.pop(connection, None)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None: async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed. """Replay an active sustained goal from session metadata after *chat_id* is subscribed.
@@ -900,7 +905,10 @@ class WebSocketChannel(BaseChannel):
) )
return return
if t == "transcribe_audio": if t == "transcribe_audio":
event, payload = await webui_transcription_event(envelope) event, payload = await webui_transcription_event(
envelope,
config_path=self.gateway.settings.config.path,
)
await self._send_event(connection, event, **payload) await self._send_event(connection, event, **payload)
return return
if t == "message": if t == "message":
@@ -1039,7 +1047,10 @@ class WebSocketChannel(BaseChannel):
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps")) cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps: if cli_apps:
metadata["cli_apps"] = cli_apps metadata["cli_apps"] = cli_apps
mcp_presets = normalize_mcp_preset_mentions(envelope.get("mcp_presets")) mcp_presets = normalize_mcp_preset_mentions(
envelope.get("mcp_presets"),
config_path=self.gateway.settings.config.path,
)
if mcp_presets: if mcp_presets:
metadata["mcp_presets"] = mcp_presets metadata["mcp_presets"] = mcp_presets
session_mentions: list[SessionMention] = [] session_mentions: list[SessionMention] = []
@@ -1198,6 +1209,8 @@ class WebSocketChannel(BaseChannel):
payload: dict[str, Any], payload: dict[str, Any],
) -> None: ) -> None:
try: try:
lock = self._webui_request_locks.setdefault(connection, asyncio.Lock())
async with lock:
response = await self._http_router.dispatch_webui_mutation( response = await self._http_router.dispatch_webui_mutation(
connection, connection,
action, action,
@@ -1321,6 +1334,7 @@ class WebSocketChannel(BaseChannel):
if mutation_tasks: if mutation_tasks:
await asyncio.gather(*mutation_tasks, return_exceptions=True) await asyncio.gather(*mutation_tasks, return_exceptions=True)
self._webui_request_tasks.clear() self._webui_request_tasks.clear()
self._webui_request_locks.clear()
self._subs.clear() self._subs.clear()
self._conn_chats.clear() self._conn_chats.clear()
self._conn_default.clear() self._conn_default.clear()
@@ -943,6 +943,65 @@ async def test_authenticated_webui_request_returns_correlated_success(bus: Magic
} }
@pytest.mark.asyncio
async def test_webui_mutations_preserve_request_and_response_order(bus: MagicMock) -> None:
channel = _ch(bus)
conn = AsyncMock()
channel._webui_connections.add(conn)
first_started = asyncio.Event()
release_first = asyncio.Event()
dispatch_order: list[str] = []
async def dispatch(
_connection: object,
action: str,
_payload: dict[str, object],
) -> Any:
dispatch_order.append(action)
if action == "settings.provider.update":
first_started.set()
await release_first.wait()
return _http_json_response({"action": action})
channel.gateway.http.dispatch_webui_mutation = dispatch
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-first",
"action": "settings.provider.update",
"payload": {},
},
)
await first_started.wait()
await channel._dispatch_envelope(
conn,
"webui-client",
{
"type": "webui_request",
"request_id": "request-second",
"action": "settings.agent.update",
"payload": {},
},
)
await asyncio.sleep(0)
assert dispatch_order == ["settings.provider.update"]
release_first.set()
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
assert dispatch_order == [
"settings.provider.update",
"settings.agent.update",
]
responses = [json.loads(call.args[0]) for call in conn.send.await_args_list]
assert [response["request_id"] for response in responses] == [
"request-first",
"request-second",
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None: async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
channel = _ch(bus) channel = _ch(bus)
@@ -16,6 +16,7 @@ import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
from nanobot.config.loader import load_config, save_config
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.cron.types import CronJob, CronPayload, CronSchedule
from nanobot.optional_features import InstallResult from nanobot.optional_features import InstallResult
@@ -636,6 +637,7 @@ async def test_webui_skill_management_routes(
*, *,
enabled: bool, enabled: bool,
disabled_skills: set[str], disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
assert workspace == tmp_path assert workspace == tmp_path
assert name == "custom-skill" assert name == "custom-skill"
@@ -648,6 +650,7 @@ async def test_webui_skill_management_routes(
name: str, name: str,
*, *,
disabled_skills: set[str], disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
assert workspace == tmp_path assert workspace == tmp_path
assert name == "custom-skill" assert name == "custom-skill"
@@ -926,10 +929,6 @@ async def test_webui_skill_install_honors_remote_install_opt_in(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
policy = MagicMock()
policy.tools.webui_allow_remote_package_install = True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
async def install( async def install(
source: str, source: str,
skill_id: str, skill_id: str,
@@ -956,6 +955,9 @@ async def test_webui_skill_install_honors_remote_install_opt_in(
workspace_path=tmp_path, workspace_path=tmp_path,
port=_free_port(), port=_free_port(),
) )
policy = load_config(channel.gateway.settings.config.path)
policy.tools.webui_allow_remote_package_install = True
save_config(policy, channel.gateway.settings.config.path)
response = await _webui_mutate( response = await _webui_mutate(
channel, channel,
"skill.install", "skill.install",
@@ -3699,7 +3701,7 @@ def test_authenticated_bootstrap_returns_distinct_api_token(bus: MagicMock) -> N
def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config", "nanobot.webui.ws_http._default_model_name_from_config",
lambda: "from-disk", lambda _config_path=None: "from-disk",
) )
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ") channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ")
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
@@ -3711,7 +3713,7 @@ def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytes
def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config", "nanobot.webui.ws_http._default_model_name_from_config",
lambda: "from-disk", lambda _config_path=None: "from-disk",
) )
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ") channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ")
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ) resp = channel.gateway.http._handle_bootstrap(_LOCAL, _LOCAL_BROWSER_REQ)
@@ -3723,7 +3725,7 @@ def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeyp
def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.webui.ws_http._default_model_name_from_config", "nanobot.webui.ws_http._default_model_name_from_config",
lambda: "from-disk", lambda _config_path=None: "from-disk",
) )
def boom(): def boom():
+8 -4
View File
@@ -502,10 +502,10 @@ def _known_preset_names() -> set[str]:
return {preset.name for preset in MCP_PRESETS} return {preset.name for preset in MCP_PRESETS}
def _known_mcp_names() -> set[str]: def _known_mcp_names(config_path: Path | None = None) -> set[str]:
names = _known_preset_names() names = _known_preset_names()
with suppress(Exception): with suppress(Exception):
names.update(load_config().tools.mcp_servers) names.update(load_config(config_path).tools.mcp_servers)
return names return names
@@ -518,11 +518,15 @@ def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
return text[:limit] return text[:limit]
def normalize_mcp_preset_mentions(raw: Any) -> list[dict[str, Any]]: def normalize_mcp_preset_mentions(
raw: Any,
*,
config_path: Path | None = None,
) -> list[dict[str, Any]]:
"""Sanitize structured MCP preset mentions sent by the WebUI.""" """Sanitize structured MCP preset mentions sent by the WebUI."""
if not isinstance(raw, list): if not isinstance(raw, list):
return [] return []
known = _known_mcp_names() known = _known_mcp_names(config_path)
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
seen: set[str] = set() seen: set[str] = set()
for item_value in cast(list[object], raw)[:8]: for item_value in cast(list[object], raw)[:8]:
+10 -5
View File
@@ -8,6 +8,8 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, TypeVar from typing import Any, TypeVar
from filelock import FileLock
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config from nanobot.config.schema import Config
@@ -16,11 +18,14 @@ _WEBUI_OAUTH_MAX_FLOWS = 8
class WebUISettingsConfig: class WebUISettingsConfig:
"""Instance-scoped config access with serialized read-modify-write operations.""" """Path-scoped config access with process-safe read-modify-write operations."""
def __init__(self, config_path: Path) -> None: def __init__(self, config_path: Path) -> None:
self.path = config_path.expanduser().resolve(strict=False) self.path = config_path.expanduser().resolve(strict=False)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock() self._lock = threading.RLock()
lock_path = self.path.with_suffix(f"{self.path.suffix}.lock")
self._file_lock = FileLock(str(lock_path))
def load(self) -> Config: def load(self) -> Config:
"""Load this gateway's config without consulting the process-global path.""" """Load this gateway's config without consulting the process-global path."""
@@ -28,16 +33,16 @@ class WebUISettingsConfig:
return load_config(self.path) return load_config(self.path)
def update(self, mutation: Callable[[Config], _T]) -> _T: def update(self, mutation: Callable[[Config], _T]) -> _T:
"""Apply and atomically persist one in-process read-modify-write operation.""" """Apply and atomically persist one path-scoped read-modify-write operation."""
with self._lock: with self._lock, self._file_lock:
config = load_config(self.path) config = load_config(self.path)
result = mutation(config) result = mutation(config)
save_config(config, self.path) save_config(config, self.path)
return result return result
def run_serialized(self, operation: Callable[[Path], _T]) -> _T: def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
"""Run a path-aware read-modify-write operation under the instance lock.""" """Run a path-aware read-modify-write operation under the config-file lock."""
with self._lock: with self._lock, self._file_lock:
return operation(self.path) return operation(self.path)
+6 -4
View File
@@ -73,10 +73,11 @@ def set_webui_skill_enabled(
*, *,
enabled: bool, enabled: bool,
disabled_skills: set[str], disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Persist and apply one skill's enabled state.""" """Persist and apply one skill's enabled state."""
_require_skill_entry(workspace_path, name) _require_skill_entry(workspace_path, name)
config = load_config() config = load_config(config_path)
next_disabled = set(config.agents.defaults.disabled_skills) next_disabled = set(config.agents.defaults.disabled_skills)
if enabled: if enabled:
next_disabled.discard(name) next_disabled.discard(name)
@@ -84,7 +85,7 @@ def set_webui_skill_enabled(
next_disabled.add(name) next_disabled.add(name)
if next_disabled != set(config.agents.defaults.disabled_skills): if next_disabled != set(config.agents.defaults.disabled_skills):
config.agents.defaults.disabled_skills = sorted(next_disabled) config.agents.defaults.disabled_skills = sorted(next_disabled)
save_config(config) save_config(config, config_path)
disabled_skills.clear() disabled_skills.clear()
disabled_skills.update(next_disabled) disabled_skills.update(next_disabled)
return {"name": name, "enabled": enabled, "deleted": False} return {"name": name, "enabled": enabled, "deleted": False}
@@ -95,6 +96,7 @@ def delete_webui_skill(
name: str, name: str,
*, *,
disabled_skills: set[str], disabled_skills: set[str],
config_path: Path | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Delete one workspace skill and remove its disabled-state entry.""" """Delete one workspace skill and remove its disabled-state entry."""
entry = _require_skill_entry(workspace_path, name) entry = _require_skill_entry(workspace_path, name)
@@ -116,7 +118,7 @@ def delete_webui_skill(
if not target.is_symlink() and not target.is_dir(): if not target.is_symlink() and not target.is_dir():
raise SkillManagementError("skill directory was not found", status=404) raise SkillManagementError("skill directory was not found", status=404)
config = load_config() config = load_config(config_path)
original_disabled = list(config.agents.defaults.disabled_skills) original_disabled = list(config.agents.defaults.disabled_skills)
next_disabled = set(original_disabled) next_disabled = set(original_disabled)
if name in next_disabled: if name in next_disabled:
@@ -127,7 +129,7 @@ def delete_webui_skill(
try: try:
if next_disabled != set(original_disabled): if next_disabled != set(original_disabled):
config.agents.defaults.disabled_skills = sorted(next_disabled) config.agents.defaults.disabled_skills = sorted(next_disabled)
save_config(config) save_config(config, config_path)
except Exception: except Exception:
config.agents.defaults.disabled_skills = original_disabled config.agents.defaults.disabled_skills = original_disabled
staged_target.replace(target) staged_target.replace(target)
+7 -2
View File
@@ -6,6 +6,7 @@ the WebUI-specific audio transcription action carried over that socket.
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from typing import Any from typing import Any
from nanobot.audio.transcription import ( from nanobot.audio.transcription import (
@@ -18,7 +19,11 @@ from nanobot.config.loader import load_config
_MAX_REQUEST_ID_LENGTH = 80 _MAX_REQUEST_ID_LENGTH = 80
async def webui_transcription_event(envelope: dict[str, Any]) -> tuple[str, dict[str, Any]]: async def webui_transcription_event(
envelope: dict[str, Any],
*,
config_path: Path | None = None,
) -> tuple[str, dict[str, Any]]:
"""Return the WS event name and payload for one WebUI transcription request.""" """Return the WS event name and payload for one WebUI transcription request."""
request_id = envelope.get("request_id") request_id = envelope.get("request_id")
valid_request_id = ( valid_request_id = (
@@ -38,7 +43,7 @@ async def webui_transcription_event(envelope: dict[str, Any]) -> tuple[str, dict
try: try:
text = await transcribe_audio_data_url( text = await transcribe_audio_data_url(
envelope.get("data_url"), envelope.get("data_url"),
resolve_transcription_config(load_config()), resolve_transcription_config(load_config(config_path)),
duration_ms=envelope.get("duration_ms"), duration_ms=envelope.get("duration_ms"),
) )
except TranscriptionIngressError as exc: except TranscriptionIngressError as exc:
+23 -10
View File
@@ -254,10 +254,10 @@ def _request_query(request: WsRequest) -> dict[str, list[str]]:
return query return query
def _default_model_name_from_config() -> str | None: def _default_model_name_from_config(config_path: Path | None = None) -> str | None:
try: try:
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
model = load_config().resolve_preset().model.strip() model = load_config(config_path).resolve_preset().model.strip()
return model or None return model or None
except Exception as e: except Exception as e:
logger.debug("bootstrap model_name could not load from config: {}", e) logger.debug("bootstrap model_name could not load from config: {}", e)
@@ -266,6 +266,7 @@ def _default_model_name_from_config() -> str | None:
def _resolve_bootstrap_model_name( def _resolve_bootstrap_model_name(
runtime_name: Callable[[], str | None] | None, runtime_name: Callable[[], str | None] | None,
config_path: Path | None = None,
) -> str: ) -> str:
if runtime_name is not None: if runtime_name is not None:
try: try:
@@ -277,7 +278,7 @@ def _resolve_bootstrap_model_name(
stripped = raw.strip() stripped = raw.strip()
if stripped: if stripped:
return stripped return stripped
return _default_model_name_from_config() or "" return _default_model_name_from_config(config_path) or ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -603,7 +604,10 @@ class GatewayHTTPHandler:
"limits": self.ingress.bootstrap_limits( "limits": self.ingress.bootstrap_limits(
max_frame_bytes=self.config.max_message_bytes, max_frame_bytes=self.config.max_message_bytes,
), ),
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name), "model_name": _resolve_bootstrap_model_name(
self.runtime_model_name,
self.settings.config.path,
),
"runtime_surface": self._runtime_surface, "runtime_surface": self._runtime_surface,
"runtime_capabilities": self._capabilities, "runtime_capabilities": self._capabilities,
} }
@@ -634,7 +638,10 @@ class GatewayHTTPHandler:
"limits": self.ingress.bootstrap_limits( "limits": self.ingress.bootstrap_limits(
max_frame_bytes=self.config.max_message_bytes, max_frame_bytes=self.config.max_message_bytes,
), ),
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name), "model_name": _resolve_bootstrap_model_name(
self.runtime_model_name,
self.settings.config.path,
),
"runtime_surface": self._runtime_surface, "runtime_surface": self._runtime_surface,
"runtime_capabilities": self._capabilities, "runtime_capabilities": self._capabilities,
} }
@@ -1236,9 +1243,9 @@ class GatewayHTTPHandler:
if _is_local_browser_request(connection, request.headers): if _is_local_browser_request(connection, request.headers):
return True return True
try: try:
from nanobot.config.loader import load_config return bool(
self.settings.config.load().tools.webui_allow_remote_package_install
return bool(load_config().tools.webui_allow_remote_package_install) )
except Exception: except Exception:
self._log.exception("failed to load remote package install policy") self._log.exception("failed to load remote package install policy")
return False return False
@@ -1252,11 +1259,14 @@ class GatewayHTTPHandler:
if raw_enabled not in {"true", "false"}: if raw_enabled not in {"true", "false"}:
return _http_error(400, "enabled must be true or false") return _http_error(400, "enabled must be true or false")
try: try:
action = set_webui_skill_enabled( action = self.settings.config.run_serialized(
lambda config_path: set_webui_skill_enabled(
self.skills_workspace_path, self.skills_workspace_path,
name, name,
enabled=raw_enabled == "true", enabled=raw_enabled == "true",
disabled_skills=self.disabled_skills, disabled_skills=self.disabled_skills,
config_path=config_path,
)
) )
except SkillManagementError as exc: except SkillManagementError as exc:
return _http_error(exc.status, exc.message) return _http_error(exc.status, exc.message)
@@ -1280,10 +1290,13 @@ class GatewayHTTPHandler:
return _http_error(403, "remote skill deletion is disabled") return _http_error(403, "remote skill deletion is disabled")
name = _query_first(_request_query(request), "name") or "" name = _query_first(_request_query(request), "name") or ""
try: try:
action = delete_webui_skill( action = self.settings.config.run_serialized(
lambda config_path: delete_webui_skill(
self.skills_workspace_path, self.skills_workspace_path,
name, name,
disabled_skills=self.disabled_skills, disabled_skills=self.disabled_skills,
config_path=config_path,
)
) )
except SkillManagementError as exc: except SkillManagementError as exc:
return _http_error(exc.status, exc.message) return _http_error(exc.status, exc.message)
+27
View File
@@ -11,6 +11,7 @@ from mcp.shared.auth import OAuthToken
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui.mcp_presets_api import ( from nanobot.webui.mcp_presets_api import (
McpPresetError, McpPresetError,
custom_mcp_action, custom_mcp_action,
@@ -722,3 +723,29 @@ def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
]) ])
assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}] assert payload == [{"name": "docs", "display_name": "Docs", "transport": "streamableHttp"}]
def test_normalize_mcp_mentions_uses_explicit_gateway_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
default_path = tmp_path / "default.json"
config_path = tmp_path / "gateway.json"
save_config(Config(), default_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", default_path)
custom_mcp_action(
"custom",
{
"name": ["gateway-docs"],
"transport": ["streamableHttp"],
"url": ["https://example.com/mcp"],
},
config_path=config_path,
)
payload = normalize_mcp_preset_mentions(
[{"name": "gateway-docs", "display_name": "Gateway docs"}],
config_path=config_path,
)
assert payload == [{"name": "gateway-docs", "display_name": "Gateway docs"}]
+8 -2
View File
@@ -1466,7 +1466,10 @@ def test_settings_payload_includes_token_usage_summary(
from nanobot.webui.token_usage import record_token_usage from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 10, "completion_tokens": 5}) record_token_usage(
{"prompt_tokens": 10, "completion_tokens": 5},
timezone_name=config.agents.defaults.timezone,
)
payload = settings_payload() payload = settings_payload()
@@ -1491,7 +1494,10 @@ def test_settings_usage_payload_returns_lightweight_token_usage(
from nanobot.webui.token_usage import record_token_usage from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 20, "completion_tokens": 2}) record_token_usage(
{"prompt_tokens": 20, "completion_tokens": 2},
timezone_name=config.agents.defaults.timezone,
)
payload = settings_usage_payload() payload = settings_usage_payload()
+75
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import threading import threading
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
@@ -81,6 +82,15 @@ def test_gateway_settings_services_isolate_config_paths_and_oauth_flows(
assert second_flow.cancel_count == 0 assert second_flow.cancel_count == 0
def test_settings_service_supports_a_new_config_directory(tmp_path: Path) -> None:
config_path = tmp_path / "new" / "nested" / "config.json"
services = WebUISettingsServices.create(config_path)
services.mutate(update_api_settings, {"port": ["19001"]})
assert load_config(config_path).api.port == 19001
def test_settings_mutations_serialize_read_modify_write( def test_settings_mutations_serialize_read_modify_write(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -142,6 +152,71 @@ def test_settings_mutations_serialize_read_modify_write(
assert saved.api.host == "127.0.0.9" assert saved.api.host == "127.0.0.9"
def test_distinct_gateways_serialize_mutations_for_the_same_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
first_services = WebUISettingsServices.create(config_path)
second_services = WebUISettingsServices.create(config_path)
first_loaded = threading.Event()
release_first = threading.Event()
second_loaded = threading.Event()
errors: list[BaseException] = []
from nanobot.webui import settings_api
original_load = settings_api._load_settings_config
def controlled_load(path: Path | None) -> Config:
config = original_load(path)
if threading.current_thread().name == "gateway-first":
first_loaded.set()
if not release_first.wait(timeout=2):
raise TimeoutError("timed out waiting to release first gateway")
elif threading.current_thread().name == "gateway-second":
second_loaded.set()
return config
monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load)
def mutate(
services: WebUISettingsServices,
operation: Callable[..., object],
query: dict[str, list[str]],
) -> None:
try:
services.mutate(operation, query)
except BaseException as exc: # noqa: BLE001 - re-raised in the test thread
errors.append(exc)
first = threading.Thread(
target=mutate,
args=(first_services, update_agent_settings, {"timezone": ["Asia/Tokyo"]}),
name="gateway-first",
)
second = threading.Thread(
target=mutate,
args=(second_services, update_api_settings, {"host": ["127.0.0.9"]}),
name="gateway-second",
)
first.start()
assert first_loaded.wait(timeout=2)
second.start()
assert not second_loaded.wait(timeout=0.1)
release_first.set()
first.join(timeout=2)
second.join(timeout=2)
assert not first.is_alive()
assert not second.is_alive()
assert not errors
saved = load_config(config_path)
assert saved.agents.defaults.timezone == "Asia/Tokyo"
assert saved.api.host == "127.0.0.9"
def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None: def test_oauth_registry_preserves_expiry_capacity_completion_and_cancel() -> None:
registry = WebUIOAuthFlowRegistry(max_flows=2) registry = WebUIOAuthFlowRegistry(max_flows=2)
expired = _Flow(expired=True) expired = _Flow(expired=True)
+38 -6
View File
@@ -3,6 +3,8 @@ from types import SimpleNamespace
import pytest import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui.skills_api import ( from nanobot.webui.skills_api import (
SkillManagementError, SkillManagementError,
delete_webui_skill, delete_webui_skill,
@@ -79,8 +81,11 @@ def test_set_webui_skill_enabled_persists_and_updates_runtime(
_write_skill(tmp_path, "custom-skill") _write_skill(tmp_path, "custom-skill")
config = _config() config = _config()
saved: list[object] = [] saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config) monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append) monkeypatch.setattr(
"nanobot.webui.skills_api.save_config",
lambda value, _path=None: saved.append(value),
)
disabled: set[str] = set() disabled: set[str] = set()
action = set_webui_skill_enabled( action = set_webui_skill_enabled(
@@ -100,6 +105,30 @@ def test_set_webui_skill_enabled_persists_and_updates_runtime(
assert saved == [config] assert saved == [config]
def test_skill_state_uses_explicit_gateway_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
workspace = tmp_path / "workspace"
_write_skill(workspace, "custom-skill")
default_path = tmp_path / "default.json"
gateway_path = tmp_path / "gateway.json"
save_config(Config(), default_path)
save_config(Config(), gateway_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", default_path)
set_webui_skill_enabled(
workspace,
"custom-skill",
enabled=False,
disabled_skills=set(),
config_path=gateway_path,
)
assert load_config(default_path).agents.defaults.disabled_skills == []
assert load_config(gateway_path).agents.defaults.disabled_skills == ["custom-skill"]
def test_delete_webui_skill_only_deletes_workspace_skills( def test_delete_webui_skill_only_deletes_workspace_skills(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -107,8 +136,11 @@ def test_delete_webui_skill_only_deletes_workspace_skills(
directory = _write_skill(tmp_path, "custom-skill") directory = _write_skill(tmp_path, "custom-skill")
config = _config("custom-skill") config = _config("custom-skill")
saved: list[object] = [] saved: list[object] = []
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config) monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.webui.skills_api.save_config", saved.append) monkeypatch.setattr(
"nanobot.webui.skills_api.save_config",
lambda value, _path=None: saved.append(value),
)
disabled = {"custom-skill"} disabled = {"custom-skill"}
action = delete_webui_skill( action = delete_webui_skill(
@@ -158,9 +190,9 @@ def test_delete_webui_skill_restores_directory_when_config_save_fails(
) -> None: ) -> None:
directory = _write_skill(tmp_path, "custom-skill") directory = _write_skill(tmp_path, "custom-skill")
config = _config("custom-skill") config = _config("custom-skill")
monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda: config) monkeypatch.setattr("nanobot.webui.skills_api.load_config", lambda _path=None: config)
def fail_save(_config: object) -> None: def fail_save(_config: object, _path: Path | None = None) -> None:
raise OSError("disk full") raise OSError("disk full")
monkeypatch.setattr("nanobot.webui.skills_api.save_config", fail_save) monkeypatch.setattr("nanobot.webui.skills_api.save_config", fail_save)
+28
View File
@@ -41,6 +41,34 @@ async def test_webui_transcribe_audio_rejects_unconfigured_provider(
} }
@pytest.mark.asyncio
async def test_webui_transcription_uses_explicit_gateway_config(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
default_path = tmp_path / "default.json"
gateway_path = tmp_path / "gateway.json"
default = Config()
default.transcription.provider = "groq"
default.providers.groq.api_key = "gsk-global"
gateway = Config()
gateway.transcription.provider = "groq"
save_config(default, default_path)
save_config(gateway, gateway_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", default_path)
event, payload = await webui_transcription_event(
{
"request_id": "voice-explicit",
"data_url": _audio_data_url(),
},
config_path=gateway_path,
)
assert event == "transcription_error"
assert payload["detail"] == "not_configured"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_webui_transcribe_audio_rejects_unsupported_mime( async def test_webui_transcribe_audio_rejects_unsupported_mime(
tmp_path, tmp_path,