From c281e090d03e24220308bdb1dc9049b28d73f054 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:10:55 +0800 Subject: [PATCH] refactor(webui): make gateway own settings services (#5321) --- nanobot/channels/manager.py | 7 + .../tests/test_websocket_http_routes.py | 39 ++- nanobot/cli/gateway_runtime.py | 1 + nanobot/webui/cli_apps_api.py | 22 +- nanobot/webui/gateway_services.py | 7 + nanobot/webui/mcp_presets_api.py | 121 +++++-- nanobot/webui/nanobot_features_api.py | 24 +- nanobot/webui/settings_api.py | 303 ++++++++++-------- nanobot/webui/settings_routes.py | 225 +++++++++---- nanobot/webui/settings_services.py | 148 +++++++++ nanobot/webui/ws_http.py | 4 + tests/channels/test_channel_plugins.py | 4 + tests/webui/test_settings_api.py | 57 +++- tests/webui/test_settings_routes.py | 13 +- tests/webui/test_settings_services.py | 170 ++++++++++ 15 files changed, 874 insertions(+), 271 deletions(-) create mode 100644 nanobot/webui/settings_services.py create mode 100644 tests/webui/test_settings_services.py diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 06e3bd3d8..15fd52768 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -101,8 +101,14 @@ class ChannelManager: webui_runtime_surface: str = "browser", webui_runtime_capabilities: dict[str, Any] | None = None, webui_skill_state_action: Callable[[set[str]], None] | None = None, + config_path: Path | None = None, ): + if config_path is None: + from nanobot.config.loader import get_config_path + + config_path = get_config_path() self.config = config + self._config_path = config_path.expanduser().resolve(strict=False) self.bus = bus self._session_manager = session_manager self._cron_service = cron_service @@ -170,6 +176,7 @@ class ChannelManager: static_dist_path=static_path, workspace_path=workspace, default_restrict_to_workspace=self.config.tools.restrict_to_workspace, + config_path=self._config_path, disabled_skills=set(self.config.agents.defaults.disabled_skills), runtime_model_name=self._webui_runtime_model_name, runtime_surface=self._webui_runtime_surface, diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 564537e68..023e05367 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -916,7 +916,11 @@ async def test_cli_apps_routes_require_token_and_return_payload( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - async def payload(*, installed_only: bool = False) -> dict[str, Any]: + async def payload( + *, + installed_only: bool = False, + config_path: Path | None = None, + ) -> dict[str, Any]: return { "apps": [ { @@ -946,7 +950,7 @@ async def test_cli_apps_routes_require_token_and_return_payload( ) monkeypatch.setattr( "nanobot.webui.settings_routes.cli_apps_action", - lambda action, query: { + lambda action, query, *, config_path=None: { "apps": [], "installed_count": 1, "catalog_updated_at": "2026-04-18", @@ -1404,7 +1408,7 @@ async def test_feishu_connect_routes_write_config_and_hot_reload( ) monkeypatch.setattr( "nanobot.webui.settings_routes.nanobot_features_action", - lambda _action, _query, *, allow_install=True: { + lambda _action, _query, *, allow_install=True, config_path=None: { "features": [{ "name": "feishu", "display_name": "Feishu", @@ -1572,6 +1576,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads( query: dict[str, list[str]], *, allow_install: bool = True, + config_path: Path | None = None, ) -> dict[str, Any]: assert action == "enable" assert query == {"name": ["discord"], "instance_id": ["default"]} @@ -1898,7 +1903,11 @@ async def test_cli_apps_catalog_does_not_block_other_webui_http_routes( entered = asyncio.Event() release = asyncio.Event() - async def slow_payload(*, installed_only: bool = False) -> dict[str, Any]: + async def slow_payload( + *, + installed_only: bool = False, + config_path: Path | None = None, + ) -> dict[str, Any]: assert installed_only is False entered.set() with suppress(asyncio.TimeoutError): @@ -1941,7 +1950,11 @@ async def test_cli_apps_route_supports_installed_only_payload( ) -> None: calls: list[bool] = [] - async def payload(*, installed_only: bool = False) -> dict[str, Any]: + async def payload( + *, + installed_only: bool = False, + config_path: Path | None = None, + ) -> dict[str, Any]: calls.append(installed_only) return {"apps": [], "installed_count": 0, "catalog_updated_at": None} @@ -1973,7 +1986,7 @@ async def test_mcp_presets_routes_require_token_and_return_payload( ) -> None: monkeypatch.setattr( "nanobot.webui.mcp_presets_api.mcp_presets_payload", - lambda: { + lambda **_kwargs: { "presets": [ { "name": "browserbase", @@ -2001,7 +2014,12 @@ async def test_mcp_presets_routes_require_token_and_return_payload( preset_queries: list[tuple[str, dict[str, list[str]]]] = [] custom_queries: list[tuple[str, dict[str, list[str]]]] = [] - def _mcp_preset_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]: + def _mcp_preset_action( + action: str, + query: dict[str, list[str]], + *, + config_path: Path | None = None, + ) -> dict[str, Any]: preset_queries.append((action, query)) return { "presets": [], @@ -2010,7 +2028,12 @@ async def test_mcp_presets_routes_require_token_and_return_payload( "last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"}, } - def _custom_action(action: str, query: dict[str, list[str]]) -> dict[str, Any]: + def _custom_action( + action: str, + query: dict[str, list[str]], + *, + config_path: Path | None = None, + ) -> dict[str, Any]: custom_queries.append((action, query)) return { "presets": [], diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py index 7c66717bb..10a3e061e 100644 --- a/nanobot/cli/gateway_runtime.py +++ b/nanobot/cli/gateway_runtime.py @@ -669,6 +669,7 @@ def _run_gateway( webui_runtime_surface=webui_runtime_surface, webui_runtime_capabilities=webui_runtime_capabilities, webui_skill_state_action=_webui_skill_state_action, + config_path=Path(config_path), ) def _pick_heartbeat_target() -> tuple[str, str]: diff --git a/nanobot/webui/cli_apps_api.py b/nanobot/webui/cli_apps_api.py index b0d1640e5..0d7e81b08 100644 --- a/nanobot/webui/cli_apps_api.py +++ b/nanobot/webui/cli_apps_api.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import re import time +from pathlib import Path from typing import Any, cast from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig @@ -89,8 +90,8 @@ def _query_first(query: QueryParams, key: str) -> str | None: return values[0] if values else None -def _manager() -> CliAppManager: - config = load_config() +def _manager(config_path: Path | None = None) -> CliAppManager: + config = load_config(config_path) if config_path is not None else load_config() cli_cfg = config.tools.cli_apps return CliAppManager( workspace=config.workspace_path, @@ -102,8 +103,12 @@ def _manager() -> CliAppManager: ) -async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]: - manager = _manager() +async def cli_apps_payload( + *, + installed_only: bool = False, + config_path: Path | None = None, +) -> dict[str, Any]: + manager = _manager(config_path) if config_path is not None else _manager() if installed_only: return manager.installed_payload() payload = manager.payload(cache_only=True) @@ -118,11 +123,16 @@ async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]: return payload -def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]: +def cli_apps_action( + action: str, + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: name = (_query_first(query, "name") or "").strip() if not name: raise CliAppError("missing CLI app name") - manager = _manager() + manager = _manager(config_path) if config_path is not None else _manager() if action == "install": return manager.install(name) if action == "update": diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index a55a8c713..f5c65a83d 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Any, Callable from loguru import logger as default_logger +from nanobot.config.loader import get_config_path from nanobot.webui.gateway_tokens import GatewayTokenStore from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy from nanobot.webui.media_gateway import WebUIMediaGateway +from nanobot.webui.settings_services import WebUISettingsServices from nanobot.webui.temporary_chats import WebUITemporaryChats from nanobot.webui.transcript import WebUITranscriptRecorder from nanobot.webui.workspaces import WebUIWorkspaceController @@ -29,6 +31,7 @@ class GatewayServices: """Explicit dependencies shared by WebSocket transport and HTTP routes.""" http: GatewayHTTPHandler + settings: WebUISettingsServices tokens: GatewayTokenStore media: WebUIMediaGateway ingress: WebUIIngressPolicy @@ -50,6 +53,7 @@ def build_gateway_services( static_dist_path: Path | None, workspace_path: Path, default_restrict_to_workspace: bool, + config_path: Path | None = None, runtime_model_name: Callable[[], str | None] | None, runtime_surface: str, runtime_capabilities_overrides: dict[str, Any] | None, @@ -63,6 +67,7 @@ def build_gateway_services( skill_state_action: Callable[[set[str]], None] | None = None, logger: Any = default_logger, ) -> GatewayServices: + settings = WebUISettingsServices.create(config_path or get_config_path()) tokens = GatewayTokenStore() ingress = DEFAULT_WEBUI_INGRESS_POLICY minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes() @@ -102,6 +107,7 @@ def build_gateway_services( media=media, ingress=ingress, workspaces=workspaces, + settings=settings, skills_workspace_path=workspace_path, disabled_skills=disabled_skills, cron_service=cron_service, @@ -115,6 +121,7 @@ def build_gateway_services( ) return GatewayServices( http=http, + settings=settings, tokens=tokens, media=media, ingress=ingress, diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index ca4147a33..162c793fa 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -14,7 +14,7 @@ from contextlib import suppress from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path -from typing import Any, Literal, Mapping, cast +from typing import TYPE_CHECKING, Any, Literal, Mapping, cast from nanobot.agent.tools.registry import ToolRegistry from nanobot.apps.protocol import app_manifest, compact_dict @@ -25,6 +25,9 @@ from nanobot.utils.helpers import ensure_dir QueryParams = dict[str, list[str]] +if TYPE_CHECKING: + from nanobot.webui.settings_services import WebUISettingsConfig + _MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE) _SECRET_QUERY_RE = re.compile( r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+", @@ -841,8 +844,9 @@ def mcp_presets_payload( *, last_action: dict[str, Any] | None = None, tool_preview: Mapping[str, list[str]] | None = None, + config_path: Path | None = None, ) -> dict[str, Any]: - config = load_config() + config = load_config(config_path) if config_path is not None else load_config() known = _known_preset_names() preset_rows = [ _preset_payload(preset, config.tools.mcp_servers) @@ -928,7 +932,11 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None: await stack.aclose() -async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]: +async def mcp_presets_test_action( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: """Connect to an enabled MCP preset and report its tool surface.""" from nanobot.agent.tools.mcp import connect_mcp_servers @@ -941,16 +949,22 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]: display_name = _display_name_for(name, preset) try: - config = resolve_config_env_vars(load_config()) + config = resolve_config_env_vars( + load_config(config_path) if config_path is not None else load_config(), + config_path=config_path, + ) except ValueError as exc: - return mcp_presets_payload(last_action={ - "ok": False, - "message": _scrub_test_error(str(exc)), - "error": _scrub_test_error(str(exc)), - "tool_count": 0, - "tool_names": [], - "checked_at": _checked_at(), - }) + return mcp_presets_payload( + last_action={ + "ok": False, + "message": _scrub_test_error(str(exc)), + "error": _scrub_test_error(str(exc)), + "tool_count": 0, + "tool_names": [], + "checked_at": _checked_at(), + }, + config_path=config_path, + ) cfg = config.tools.mcp_servers.get(name) if cfg is None: @@ -968,7 +982,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]: "tool_names": [], "checked_at": _checked_at(), } - return mcp_presets_payload(last_action=last_action) + return mcp_presets_payload(last_action=last_action, config_path=config_path) if cfg.command and not _command_available(cfg.command): last_action = { @@ -979,7 +993,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]: "tool_names": [], "checked_at": _checked_at(), } - return mcp_presets_payload(last_action=last_action) + return mcp_presets_payload(last_action=last_action, config_path=config_path) registry = ToolRegistry() stacks: dict[str, Any] = {} @@ -1040,7 +1054,11 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]: tool_names = last_action.get("tool_names", []) preview = {name: tool_names} if tool_names else None - return mcp_presets_payload(last_action=last_action, tool_preview=preview) + return mcp_presets_payload( + last_action=last_action, + tool_preview=preview, + config_path=config_path, + ) def _parse_json_value(raw: str | None, *, fallback: Any) -> Any: @@ -1221,24 +1239,35 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]: return out -def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]: - config = load_config() +def custom_mcp_action( + action: str, + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: + config = load_config(config_path) if config_path is not None else load_config() if action == "custom": name, cfg = _custom_server_from_query(query) config.tools.mcp_servers[name] = cfg - save_config(config) - payload = mcp_presets_payload(last_action=_server_action_message(action, name)) + save_config(config, config_path) + payload = mcp_presets_payload( + last_action=_server_action_message(action, name), + config_path=config_path, + ) payload["requires_restart"] = True return payload if action in {"import", "import-cursor"}: servers = _import_mcp_servers(_query_first(query, "config")) config.tools.mcp_servers.update(servers) - save_config(config) - payload = mcp_presets_payload(last_action={ - "ok": True, - "message": f"Imported {len(servers)} MCP server(s).", - }) + save_config(config, config_path) + payload = mcp_presets_payload( + last_action={ + "ok": True, + "message": f"Imported {len(servers)} MCP server(s).", + }, + config_path=config_path, + ) payload["requires_restart"] = True return payload @@ -1249,29 +1278,40 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]: raise McpPresetError("unknown MCP server", status=404) cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools")) config.tools.mcp_servers[name] = cfg - save_config(config) - payload = mcp_presets_payload(last_action=_server_action_message(action, name)) + save_config(config, config_path) + payload = mcp_presets_payload( + last_action=_server_action_message(action, name), + config_path=config_path, + ) payload["requires_restart"] = True return payload raise McpPresetError(f"unknown MCP action '{action}'", status=404) -def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]: +def mcp_presets_action( + action: str, + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: name = (_query_first(query, "name") or "").strip() if not name: raise McpPresetError("missing MCP preset name") preset = _preset_by_name_optional(name) - config = load_config() + config = load_config(config_path) if config_path is not None else load_config() existing = config.tools.mcp_servers.get(name) if action == "enable": if preset is None: raise McpPresetError("unknown MCP preset", status=404) config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing) - save_config(config) - payload = mcp_presets_payload(last_action=_action_message(action, preset)) + save_config(config, config_path) + payload = mcp_presets_payload( + last_action=_action_message(action, preset), + config_path=config_path, + ) payload["requires_restart"] = True return payload @@ -1287,7 +1327,7 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]: except OSError as exc: cleanup_error = str(exc) del config.tools.mcp_servers[name] - save_config(config) + save_config(config, config_path) last_action = ( _action_message(action, preset) if preset is not None @@ -1303,7 +1343,10 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]: f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}" ) last_action["verification_failed"] = ["managed_paths_absent"] - payload = mcp_presets_payload(last_action=last_action) + payload = mcp_presets_payload( + last_action=last_action, + config_path=config_path, + ) payload["requires_restart"] = True return payload @@ -1339,13 +1382,21 @@ async def mcp_presets_settings_action( query: QueryParams, *, reload_mcp: McpReload | None = None, + config: WebUISettingsConfig | None = None, ) -> dict[str, Any]: """Run a WebUI MCP preset action and hot-reload the agent when config changes.""" + config_path = config.path if config is not None else None if action is None: - return mcp_presets_payload() + return mcp_presets_payload(config_path=config_path) if action == "test": - return await mcp_presets_test_action(query) - if action in _CUSTOM_ACTIONS: + return await mcp_presets_test_action(query, config_path=config_path) + if config is not None: + operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action + payload = await asyncio.to_thread( + config.run_serialized, + lambda path: operation(action, query, config_path=path), + ) + elif action in _CUSTOM_ACTIONS: payload = await asyncio.to_thread(custom_mcp_action, action, query) else: payload = await asyncio.to_thread(mcp_presets_action, action, query) diff --git a/nanobot/webui/nanobot_features_api.py b/nanobot/webui/nanobot_features_api.py index 5e70a6a6e..3dc430b9d 100644 --- a/nanobot/webui/nanobot_features_api.py +++ b/nanobot/webui/nanobot_features_api.py @@ -1,6 +1,7 @@ """Nanobot optional feature helpers for WebUI Settings.""" from __future__ import annotations +from pathlib import Path from typing import Any from nanobot.channels.registry import load_channel_plugin @@ -15,8 +16,13 @@ from nanobot.webui.http_utils import query_first QueryParams = dict[str, list[str]] -def nanobot_features_payload() -> dict[str, Any]: - return optional_features_payload() +def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]: + if config_path is None: + return optional_features_payload() + + from nanobot.config.loader import load_config + + return optional_features_payload(config=load_config(config_path)) def nanobot_feature_instance_target(query: QueryParams) -> str | None: @@ -32,13 +38,19 @@ def nanobot_features_action( query: QueryParams, *, allow_install: bool = True, + config_path: Path | None = None, ) -> dict[str, Any]: name = (query_first(query, "name") or "").strip() instance_id = nanobot_feature_instance_target(query) if not name: raise OptionalFeatureError("missing feature name") if action == "enable": - return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id) + return enable_optional_feature( + name, + config_path=config_path, + allow_install=allow_install, + instance_id=instance_id, + ) if action == "disable": try: plugin = load_channel_plugin(name) @@ -50,5 +62,9 @@ def nanobot_features_action( f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.", status=400, ) - return disable_optional_feature(name, instance_id=instance_id) + return disable_optional_feature( + name, + config_path=config_path, + instance_id=instance_id, + ) raise OptionalFeatureError(f"unknown feature action '{action}'", status=404) diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 1c77a293e..eb0b4eff0 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -14,11 +14,11 @@ import math import os import re import secrets -import threading import time from collections.abc import Iterable from contextlib import suppress -from typing import Any, Literal, cast +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, cast from zoneinfo import ZoneInfo import httpx @@ -49,6 +49,9 @@ from nanobot.webui.workspaces import ( QueryParams = dict[str, list[str]] RuntimeSurface = Literal["browser", "native"] +if TYPE_CHECKING: + from nanobot.webui.settings_services import WebUIOAuthFlowRegistry + def _version_payload() -> dict[str, Any]: """Return version info for the settings payload.""" @@ -133,9 +136,6 @@ _IMAGE_GENERATION_ASPECT_RATIOS = { _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576} _OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"} _WEBUI_OAUTH_TIMEOUT_S = 600 -_WEBUI_OAUTH_MAX_FLOWS = 8 -_webui_oauth_flows: dict[str, tuple[str, Any]] = {} -_webui_oauth_flows_lock = threading.Lock() _MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") _ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") @@ -148,6 +148,21 @@ class WebUISettingsError(ValueError): self.status = status +def _load_settings_config(config_path: Path | None) -> Config: + return load_config(config_path) if config_path is not None else load_config() + + +def _save_settings_config(config: Config, config_path: Path | None) -> None: + if config_path is None: + save_config(config) + else: + save_config(config, config_path) + + +def _settings_config_path(config_path: Path | None) -> Path: + return config_path if config_path is not None else get_config_path() + + def _normalize_surface(surface: str | None) -> RuntimeSurface: return "native" if surface in {"native", "desktop"} else "browser" @@ -764,7 +779,11 @@ def _extract_model_rows(body: Any) -> list[dict[str, Any]]: return rows -def provider_models_payload(query: QueryParams) -> dict[str, Any]: +def provider_models_payload( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: """Fetch an OpenAI-compatible provider's model list for Settings. The result is advisory only: users can always type a custom model id. This @@ -775,7 +794,7 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]: if not provider_name: raise WebUISettingsError("provider is required") - config = load_config() + config = _load_settings_config(config_path) resolved_provider = _resolve_settings_provider(config, provider_name) if resolved_provider is None: raise WebUISettingsError("unknown provider") @@ -1117,8 +1136,9 @@ def settings_payload( runtime_capability_overrides: dict[str, Any] | None = None, restart_required_sections: list[str] | None = None, apply_state: dict[str, Any] | None = None, + config_path: Path | None = None, ) -> dict[str, Any]: - config = load_config() + config = _load_settings_config(config_path) defaults = config.agents.defaults active_preset_name = defaults.model_preset or "default" effective_preset = config.resolve_preset() @@ -1299,7 +1319,7 @@ def settings_payload( "providers": _transcription_provider_rows(config), }, "runtime": { - "config_path": str(get_config_path().expanduser()), + "config_path": str(_settings_config_path(config_path).expanduser()), "workspace_path": str(config.workspace_path), "gateway_host": config.gateway.host, "gateway_port": config.gateway.port, @@ -1341,14 +1361,18 @@ def settings_payload( ) -def settings_usage_payload() -> dict[str, Any]: +def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]: """Return the lightweight token usage slice for Overview refreshes.""" - config = load_config() + config = _load_settings_config(config_path) return token_usage_payload(timezone_name=config.agents.defaults.timezone) -def update_agent_settings(query: QueryParams) -> dict[str, Any]: - config = load_config() +def update_agent_settings( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: + config = _load_settings_config(config_path) defaults = config.agents.defaults changed = False restart_required = False @@ -1425,11 +1449,15 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]: restart_required = True if changed: - save_config(config) - return settings_payload(requires_restart=restart_required) + _save_settings_config(config, config_path) + return settings_payload(requires_restart=restart_required, config_path=config_path) -def create_model_configuration(query: QueryParams) -> dict[str, Any]: +def create_model_configuration( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: label = (_query_first_alias(query, "label", "displayName") or "").strip() raw_name = (_query_first(query, "name") or label).strip() model = (_query_first(query, "model") or "").strip() @@ -1443,7 +1471,7 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]: raise WebUISettingsError("provider is required") name = _model_configuration_slug(raw_name or label) - config = load_config() + config = _load_settings_config(config_path) if name in config.model_presets: raise WebUISettingsError("configuration already exists", status=409) _validate_configured_provider(config, provider) @@ -1476,18 +1504,22 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]: temperature=temperature if temperature is not None else base.temperature, reasoning_effort=reasoning_effort, ) - save_config(config) - payload = settings_payload() + _save_settings_config(config, config_path) + payload = settings_payload(config_path=config_path) payload["created_model_preset"] = name return payload -def update_model_configuration(query: QueryParams) -> dict[str, Any]: +def update_model_configuration( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: name = (_query_first(query, "name") or "").strip() if not name or name == "default": raise WebUISettingsError("model configuration is required") - config = load_config() + config = _load_settings_config(config_path) preset = config.model_presets.get(name) if preset is None: raise WebUISettingsError("unknown model configuration") @@ -1554,11 +1586,15 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]: changed = True if changed: - save_config(config) - return settings_payload() + _save_settings_config(config, config_path) + return settings_payload(config_path=config_path) -def update_model_call_order(query: QueryParams) -> dict[str, Any]: +def update_model_call_order( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: raw_order = _query_first_alias(query, "order", "presetNames") if raw_order is None: raise WebUISettingsError("model call order is required") @@ -1580,7 +1616,7 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]: cast(str, name).strip() for name in cast(list[object], order) ] - config = load_config() + config = _load_settings_config(config_path) _, editable = _model_call_order_state(config) if not editable: raise WebUISettingsError( @@ -1599,13 +1635,17 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]: ): defaults.model_preset = normalized_order[0] defaults.fallback_models = fallback_models - save_config(config) - return settings_payload() + _save_settings_config(config, config_path) + return settings_payload(config_path=config_path) -def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]: +def migrate_model_configurations( + _query: QueryParams | None = None, + *, + config_path: Path | None = None, +) -> dict[str, Any]: """Materialize legacy primary/inline model settings as named presets.""" - config = load_config() + config = _load_settings_config(config_path) defaults = config.agents.defaults primary = config.resolve_preset() created: list[str] = [] @@ -1658,16 +1698,20 @@ def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, if created: defaults.fallback_models = fallback_models - save_config(config) - return settings_payload() + _save_settings_config(config, config_path) + return settings_payload(config_path=config_path) -def delete_model_configuration(query: QueryParams) -> dict[str, Any]: +def delete_model_configuration( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: name = (_query_first(query, "name") or "").strip() if not name or name == "default": raise WebUISettingsError("model configuration is required") - config = load_config() + config = _load_settings_config(config_path) if name not in config.model_presets: raise WebUISettingsError("unknown model configuration") defaults = config.agents.defaults @@ -1681,11 +1725,15 @@ def delete_model_configuration(query: QueryParams) -> dict[str, Any]: ) del config.model_presets[name] - save_config(config) - return settings_payload() + _save_settings_config(config, config_path) + return settings_payload(config_path=config_path) -def create_provider_settings(query: QueryParams) -> dict[str, Any]: +def create_provider_settings( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: display_name = (_query_first_alias(query, "name", "displayName") or "").strip() if not display_name: raise WebUISettingsError("provider name is required") @@ -1710,7 +1758,7 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]: if not api_base: raise WebUISettingsError("API base is required") - config = load_config() + config = _load_settings_config(config_path) if _provider_display_name_exists(config, display_name): raise WebUISettingsError("provider already exists", status=409) @@ -1719,18 +1767,22 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]: updates["api_type"] = "auto" provider_config = _validated_provider_config(None, updates) setattr(config.providers, provider_key, provider_config) - save_config(config) - payload = settings_payload() + _save_settings_config(config, config_path) + payload = settings_payload(config_path=config_path) payload["created_provider"] = provider_key return payload -def update_provider_settings(query: QueryParams) -> dict[str, Any]: +def update_provider_settings( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: provider_name = (_query_first(query, "provider") or "").strip() if not provider_name: raise WebUISettingsError("provider is required") - config = load_config() + config = _load_settings_config(config_path) resolved_provider = _resolve_settings_provider(config, provider_name) if resolved_provider is None: raise WebUISettingsError("unknown provider") @@ -1772,7 +1824,7 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]: changed = updated_provider_config != provider_config if changed: setattr(config.providers, provider_key, updated_provider_config) - save_config(config) + _save_settings_config(config, config_path) image_config = config.tools.image_generation restart_required = ( changed @@ -1780,10 +1832,15 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]: and image_config.provider == provider_key and get_image_gen_provider(provider_key) is not None ) - return settings_payload(requires_restart=restart_required) + return settings_payload(requires_restart=restart_required, config_path=config_path) -def login_oauth_provider(query: QueryParams) -> dict[str, Any]: +def login_oauth_provider( + query: QueryParams, + *, + oauth_flows: WebUIOAuthFlowRegistry, + config_path: Path | None = None, +) -> dict[str, Any]: provider_name = (_query_first(query, "provider") or "").strip() if not provider_name: raise WebUISettingsError("provider is required") @@ -1798,7 +1855,10 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None try: - proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None + proxy = resolve_config_env_vars( + _load_settings_config(config_path), + config_path=config_path, + ).providers.openai_codex.proxy or None except ValueError as e: raise WebUISettingsError(str(e), status=400) from e remote_browser_value = _query_first(query, "remote_browser") @@ -1816,7 +1876,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: except Exception as e: raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e flow_id = secrets.token_urlsafe(24) - _register_webui_oauth_flow(spec.name, flow_id, flow) + oauth_flows.register(spec.name, flow_id, flow) return { "status": "authorization_required", "provider": spec.name, @@ -1840,13 +1900,16 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: token = login_github_copilot(print_fn=lambda _message: None) if not (token and token.access): raise WebUISettingsError("OAuth login failed", status=401) - return settings_payload() + return settings_payload(config_path=config_path) if spec.name == "xai_grok": from nanobot.providers.xai_oauth import start_xai_oauth_login try: - proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None + proxy = resolve_config_env_vars( + _load_settings_config(config_path), + config_path=config_path, + ).providers.xai_grok.proxy or None except ValueError as e: raise WebUISettingsError(str(e), status=400) from e try: @@ -1857,7 +1920,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: except Exception as e: raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e flow_id = secrets.token_urlsafe(24) - _register_webui_oauth_flow(spec.name, flow_id, flow) + oauth_flows.register(spec.name, flow_id, flow) return { "status": "authorization_required", "provider": spec.name, @@ -1873,6 +1936,9 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: def complete_oauth_provider( query: QueryParams, authorization_response: str | None = None, + *, + oauth_flows: WebUIOAuthFlowRegistry, + config_path: Path | None = None, ) -> dict[str, Any]: provider_name = (_query_first(query, "provider") or "").strip() flow_id = (_query_first(query, "flow_id") or "").strip() @@ -1882,7 +1948,7 @@ def complete_oauth_provider( if not flow_id: raise WebUISettingsError("flow_id is required") - flow = _get_webui_oauth_flow(spec.name, flow_id) + flow = oauth_flows.get(spec.name, flow_id) if flow is None: raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410) @@ -1904,7 +1970,7 @@ def complete_oauth_provider( except WebUISettingsError: raise except Exception as e: - _remove_webui_oauth_flow(spec.name, flow_id, flow) + oauth_flows.remove(spec.name, flow_id, flow) raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e if token is None: return { @@ -1912,13 +1978,18 @@ def complete_oauth_provider( "provider": spec.name, "flow_id": flow_id, } - _remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False) + oauth_flows.remove(spec.name, flow_id, flow, cancel=False) if not token.access: raise WebUISettingsError("OAuth login failed", status=401) - return settings_payload() + return settings_payload(config_path=config_path) -def logout_oauth_provider(query: QueryParams) -> dict[str, Any]: +def logout_oauth_provider( + query: QueryParams, + *, + oauth_flows: WebUIOAuthFlowRegistry, + config_path: Path | None = None, +) -> dict[str, Any]: provider_name = (_query_first(query, "provider") or "").strip() if not provider_name: raise WebUISettingsError("provider is required") @@ -1932,7 +2003,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]: from oauth_cli_kit.storage import FileTokenStorage except ImportError: raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None - _clear_webui_oauth_flows(spec.name) + oauth_flows.clear(spec.name) token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path() elif spec.name == "github_copilot": try: @@ -1943,77 +2014,23 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]: elif spec.name == "xai_grok": from nanobot.providers.xai_oauth import logout_xai_oauth - _clear_webui_oauth_flows(spec.name) + oauth_flows.clear(spec.name) logout_xai_oauth() - return settings_payload() + return settings_payload(config_path=config_path) else: raise WebUISettingsError("OAuth logout is not supported for this provider") for path in (token_path, token_path.with_suffix(".lock")): with suppress(FileNotFoundError): path.unlink() - return settings_payload() + return settings_payload(config_path=config_path) -def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None: - discarded: list[Any] = [] - with _webui_oauth_flows_lock: - for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()): - if existing.expired: - discarded.append(_webui_oauth_flows.pop(existing_id)[1]) - while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS: - oldest_id = next(iter(_webui_oauth_flows)) - discarded.append(_webui_oauth_flows.pop(oldest_id)[1]) - _webui_oauth_flows[flow_id] = (provider_name, flow) - for existing in discarded: - existing.cancel() - - -def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None: - with _webui_oauth_flows_lock: - registered = _webui_oauth_flows.get(flow_id) - if registered is None or registered[0] != provider_name: - return None - flow = registered[1] - if not flow.expired: - return flow - _webui_oauth_flows.pop(flow_id, None) - flow.cancel() - return None - - -def _remove_webui_oauth_flow( - provider_name: str, - flow_id: str, - flow: Any, +def update_network_safety_settings( + query: QueryParams, *, - cancel: bool = True, -) -> None: - with _webui_oauth_flows_lock: - registered = _webui_oauth_flows.get(flow_id) - if ( - registered is not None - and registered[0] == provider_name - and registered[1] is flow - ): - _webui_oauth_flows.pop(flow_id) - if cancel: - flow.cancel() - - -def _clear_webui_oauth_flows(provider_name: str) -> None: - with _webui_oauth_flows_lock: - flow_ids = [ - flow_id - for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items() - if registered_provider == provider_name - ] - flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids] - for flow in flows: - flow.cancel() - - -def update_network_safety_settings(query: QueryParams) -> dict[str, Any]: + config_path: Path | None = None, +) -> dict[str, Any]: raw_allow = ( _query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess") or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess") @@ -2022,7 +2039,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]: if raw_allow is None and raw_default_access_mode is None: raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required") - config = load_config() + config = _load_settings_config(config_path) changed = False if raw_allow is not None: webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access") @@ -2031,7 +2048,7 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]: changed = True if changed: - save_config(config) + _save_settings_config(config, config_path) if raw_default_access_mode is not None: default_access_mode = raw_default_access_mode.strip().lower() if default_access_mode == "restricted": @@ -2042,16 +2059,20 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]: write_webui_default_access_mode(default_access_mode) except ValueError as exc: raise WebUISettingsError(str(exc)) from exc - return settings_payload(requires_restart=changed) + return settings_payload(requires_restart=changed, config_path=config_path) -def update_web_search_settings(query: QueryParams) -> dict[str, Any]: +def update_web_search_settings( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: provider_name = (_query_first(query, "provider") or "").strip().lower() provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name) if provider_option is None: raise WebUISettingsError("unknown web search provider") - config = load_config() + config = _load_settings_config(config_path) search_config = config.tools.web.search web_config = config.tools.web previous_provider = search_config.provider @@ -2130,13 +2151,17 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]: restart_required = True if changed: - save_config(config) - return settings_payload(requires_restart=restart_required) + _save_settings_config(config, config_path) + return settings_payload(requires_restart=restart_required, config_path=config_path) -def update_api_settings(query: QueryParams) -> dict[str, Any]: +def update_api_settings( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: """Update the managed OpenAI-compatible API configuration.""" - config = load_config() + config = _load_settings_config(config_path) api = config.api host = _query_first(query, "host") @@ -2173,12 +2198,16 @@ def update_api_settings(query: QueryParams) -> dict[str, Any]: if not is_loopback_host(api.host) and not api.api_key.strip(): raise WebUISettingsError("an API key is required when the API is available on the network") - save_config(config) - return settings_payload() + _save_settings_config(config, config_path) + return settings_payload(config_path=config_path) -def update_image_generation_settings(query: QueryParams) -> dict[str, Any]: - config = load_config() +def update_image_generation_settings( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: + config = _load_settings_config(config_path) image_config = config.tools.image_generation changed = False @@ -2271,12 +2300,16 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]: raise WebUISettingsError("image generation provider is not configured") if changed: - save_config(config) - return settings_payload(requires_restart=changed) + _save_settings_config(config, config_path) + return settings_payload(requires_restart=changed, config_path=config_path) -def update_transcription_settings(query: QueryParams) -> dict[str, Any]: - config = load_config() +def update_transcription_settings( + query: QueryParams, + *, + config_path: Path | None = None, +) -> dict[str, Any]: + config = _load_settings_config(config_path) transcription = config.transcription changed = False @@ -2341,5 +2374,5 @@ def update_transcription_settings(query: QueryParams) -> dict[str, Any]: changed = True if changed: - save_config(config) - return settings_payload() + _save_settings_config(config, config_path) + return settings_payload(config_path=config_path) diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index ee9760104..bd50124dc 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -30,7 +30,7 @@ from nanobot.channels.contracts import ( ) from nanobot.channels.registry import load_channel_plugin from nanobot.channels.validation import validate_channel_config -from nanobot.config.loader import get_config_path, load_config, save_config +from nanobot.config.schema import Config from nanobot.optional_features import ( OptionalFeatureError, extra_installed, @@ -70,6 +70,7 @@ from nanobot.webui.settings_api import ( update_transcription_settings, update_web_search_settings, ) +from nanobot.webui.settings_services import WebUISettingsServices from nanobot.webui.version_check import check_for_update QueryParams = dict[str, list[str]] @@ -165,6 +166,7 @@ class WebUISettingsRouter: def __init__( self, *, + settings: WebUISettingsServices, bus: MessageBus, logger: Any, check_api_token: Callable[[WsRequest], bool], @@ -176,6 +178,7 @@ class WebUISettingsRouter: channel_feature_action: Callable[..., Any] | None = None, channel_runtime_status: Callable[[], dict[str, Any]] | None = None, ) -> None: + self.settings = settings self.bus = bus self.logger = logger self._check_api_token = check_api_token @@ -335,7 +338,8 @@ class WebUISettingsRouter: return self._unauthorized() return self._json_response( self._with_restart_state( - settings_payload( + self.settings.read( + settings_payload, surface=self._runtime_surface, runtime_capability_overrides=self._runtime_capabilities, ) @@ -345,7 +349,7 @@ class WebUISettingsRouter: def _handle_settings_usage(self, request: WsRequest) -> Response: if not self._authorized(request): return self._unauthorized() - return self._json_response(settings_usage_payload()) + return self._json_response(self.settings.read(settings_usage_payload)) def _handle_settings_pairing(self, request: WsRequest) -> Response: if not self._authorized(request): @@ -391,7 +395,7 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_agent_settings(self._query(request)) + payload = self.settings.mutate(update_agent_settings, self._query(request)) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload, section="runtime")) @@ -400,7 +404,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = create_model_configuration(self._query(request)) + payload = self.settings.mutate( + create_model_configuration, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload)) @@ -409,7 +416,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_model_configuration(self._query(request)) + payload = self.settings.mutate( + update_model_configuration, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload)) @@ -418,7 +428,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = delete_model_configuration(self._query(request)) + payload = self.settings.mutate( + delete_model_configuration, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload)) @@ -427,7 +440,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = migrate_model_configurations(self._query(request)) + payload = self.settings.mutate( + migrate_model_configurations, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload)) @@ -436,7 +452,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_model_call_order(self._query(request)) + payload = self.settings.mutate( + update_model_call_order, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload)) @@ -445,7 +464,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_provider_settings(self._parse_provider_settings_query(request)) + payload = self.settings.mutate( + update_provider_settings, + self._parse_provider_settings_query(request) + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) payload = await self._apply_image_generation_runtime_change(payload) @@ -455,7 +477,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = create_provider_settings(self._parse_provider_settings_query(request)) + payload = self.settings.mutate( + create_provider_settings, + self._parse_provider_settings_query(request) + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload)) @@ -464,7 +489,11 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = await asyncio.to_thread(provider_models_payload, self._query(request)) + payload = await asyncio.to_thread( + self.settings.read, + provider_models_payload, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) except Exception: @@ -482,7 +511,12 @@ class WebUISettingsRouter: query = self._query(request) try: if action == "login": - payload = await asyncio.to_thread(login_oauth_provider, query) + payload = await asyncio.to_thread( + self.settings.read, + login_oauth_provider, + query, + oauth_flows=self.settings.oauth_flows, + ) elif action == "complete": raw_response = (_mutation_payload(request) or {}).get( "authorization_response" @@ -491,12 +525,19 @@ class WebUISettingsRouter: raise WebUISettingsError("OAuth authorization response must be a string") authorization_response = raw_response payload = await asyncio.to_thread( + self.settings.read, complete_oauth_provider, query, authorization_response or None, + oauth_flows=self.settings.oauth_flows, ) else: - payload = await asyncio.to_thread(logout_oauth_provider, query) + payload = await asyncio.to_thread( + self.settings.read, + logout_oauth_provider, + query, + oauth_flows=self.settings.oauth_flows, + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) if payload.get("status") in {"authorization_required", "pending"}: @@ -507,7 +548,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_web_search_settings(self._query(request)) + payload = self.settings.mutate( + update_web_search_settings, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload, section="browser")) @@ -526,19 +570,22 @@ class WebUISettingsRouter: return self._unauthorized() try: await asyncio.to_thread( - nanobot_features_action, + self._nanobot_features_action, "enable", {"name": ["api"]}, allow_install=self._allow_feature_package_install(connection, request), ) - update_api_settings(self._parse_api_service_settings_query(request)) - config = load_config() + self.settings.mutate( + update_api_settings, + self._parse_api_service_settings_query(request), + ) + config = self.settings.config.load() runtime = self._api_runtime() options = ApiStartOptions( host=config.api.host, port=config.api.port, workspace=str(config.workspace_path), - config_path=str(get_config_path().expanduser().resolve(strict=False)), + config_path=str(self.settings.config.path), ) current = runtime.status() result = await asyncio.to_thread( @@ -574,13 +621,11 @@ class WebUISettingsRouter: return self._error_response(500, self._api_runtime_message(result.message)) return self._json_response(self._api_service_payload(last_action="stopped")) - @staticmethod - def _api_runtime() -> ApiRuntime: - config_path = get_config_path().expanduser().resolve(strict=False) - return ApiRuntime(paths=api_runtime_paths(config_path)) + def _api_runtime(self) -> ApiRuntime: + return ApiRuntime(paths=api_runtime_paths(self.settings.config.path)) def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]: - config = load_config() + config = self.settings.config.load() status = self._api_runtime().status() extras = optional_dependency_groups() connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host @@ -624,7 +669,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_image_generation_settings(self._query(request)) + payload = self.settings.mutate( + update_image_generation_settings, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) payload = await self._apply_image_generation_runtime_change(payload) @@ -659,7 +707,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_transcription_settings(self._query(request)) + payload = self.settings.mutate( + update_transcription_settings, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload)) @@ -668,7 +719,10 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = update_network_safety_settings(self._query(request)) + payload = self.settings.mutate( + update_network_safety_settings, + self._query(request), + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) return self._json_response(self._with_restart_state(payload, section="runtime")) @@ -682,7 +736,10 @@ class WebUISettingsRouter: "yes", } try: - payload = await cli_apps_payload(installed_only=installed_only) + payload = await cli_apps_payload( + installed_only=installed_only, + config_path=self.settings.config.path, + ) except Exception: self.logger.exception("failed to load CLI Apps payload") return self._error_response(500, "failed to load CLI Apps") @@ -696,7 +753,12 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = await asyncio.to_thread(cli_apps_action, action, self._query(request)) + payload = await asyncio.to_thread( + cli_apps_action, + action, + self._query(request), + config_path=self.settings.config.path, + ) except WebUISettingsError as e: return self._error_response(e.status, e.message) except Exception as e: @@ -711,12 +773,29 @@ class WebUISettingsRouter: if not self._authorized(request): return self._unauthorized() try: - payload = await asyncio.to_thread(nanobot_features_payload) + payload = await asyncio.to_thread(self._nanobot_features_payload) except Exception: self.logger.exception("failed to load nanobot features") return self._error_response(500, "failed to load nanobot features") return self._json_response(self._with_channel_runtime_status(payload)) + def _nanobot_features_payload(self) -> dict[str, Any]: + return nanobot_features_payload(config_path=self.settings.config.path) + + def _nanobot_features_action( + self, + action: str, + query: QueryParams, + *, + allow_install: bool = True, + ) -> dict[str, Any]: + return self.settings.mutate( + nanobot_features_action, + action, + query, + allow_install=allow_install, + ) + async def _handle_settings_nanobot_features_action( self, connection: Any, @@ -727,7 +806,7 @@ class WebUISettingsRouter: return self._unauthorized() try: payload = await asyncio.to_thread( - nanobot_features_action, + self._nanobot_features_action, action, self._query(request), allow_install=action != "enable" @@ -850,7 +929,7 @@ class WebUISettingsRouter: "saved_keys": saved, } if not enable: - features = await asyncio.to_thread(nanobot_features_payload) + features = await asyncio.to_thread(self._nanobot_features_payload) features = self._with_channel_runtime_status(features) payload["nanobot_features"] = self._with_restart_state(features, section="runtime") return self._json_response(payload) @@ -861,7 +940,7 @@ class WebUISettingsRouter: try: features = await asyncio.to_thread( - nanobot_features_action, + self._nanobot_features_action, "enable", feature_query, allow_install=self._allow_feature_package_install(connection, request), @@ -929,44 +1008,47 @@ class WebUISettingsRouter: if not raw_values: return [] - config = load_config() - section = getattr(config.channels, name, None) - channel_config = channel_instance_config( - plugin, - section, - instance_id=instance_id, - ) - - saved: list[str] = [] - prefix = f"channels.{name}." - for raw_key, raw_value in raw_values.items(): - if not raw_key: - raise WebUISettingsError("channel settings payload contains an invalid key") - field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key - value_type = field_types.get(field) - if value_type is None: - raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI") - value = self._coerce_channel_value(raw_key, raw_value, value_type) - if value is _SKIP_FIELD: - continue - self._assign_channel_config_value(channel_config, field, value) - saved.append(raw_key) - - try: - updated_section = channel_update_instance_config( + def update(config: Config) -> list[str]: + section = getattr(config.channels, name, None) + channel_config = channel_instance_config( plugin, section, - channel_config, instance_id=instance_id, ) - except ValueError as exc: - raise WebUISettingsError( - f"Invalid {name} configuration: {exc}", - status=400, - ) from exc - setattr(config.channels, name, updated_section) - save_config(config) - return saved + + saved: list[str] = [] + prefix = f"channels.{name}." + for raw_key, raw_value in raw_values.items(): + if not raw_key: + raise WebUISettingsError( + "channel settings payload contains an invalid key" + ) + field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key + value_type = field_types.get(field) + if value_type is None: + raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI") + value = self._coerce_channel_value(raw_key, raw_value, value_type) + if value is _SKIP_FIELD: + continue + self._assign_channel_config_value(channel_config, field, value) + saved.append(raw_key) + + try: + updated_section = channel_update_instance_config( + plugin, + section, + channel_config, + instance_id=instance_id, + ) + except ValueError as exc: + raise WebUISettingsError( + f"Invalid {name} configuration: {exc}", + status=400, + ) from exc + setattr(config.channels, name, updated_section) + return saved + + return self.settings.config.update(update) @staticmethod def _coerce_channel_value( @@ -1089,14 +1171,14 @@ class WebUISettingsRouter: target["instance_id"] = [str(payload["instance_id"])] try: features = await asyncio.to_thread( - nanobot_features_action, + self._nanobot_features_action, "enable", target, allow_install=self._allow_feature_package_install(connection, request), ) except OptionalFeatureError as exc: features = self._feature_runtime_fallback( - nanobot_features_payload(), + self._nanobot_features_payload(), message=( f"{channel_name} connected, but enabling channel support failed: " f"{exc.message}" @@ -1117,7 +1199,9 @@ class WebUISettingsRouter: if _is_local_browser_request(connection, request.headers): return True try: - return bool(load_config().tools.webui_allow_remote_package_install) + return bool( + self.settings.config.load().tools.webui_allow_remote_package_install + ) except Exception: self.logger.exception("failed to load remote package install policy") return False @@ -1134,6 +1218,7 @@ class WebUISettingsRouter: action, self._parse_mcp_settings_query(request), reload_mcp=lambda: request_mcp_reload(self.bus), + config=self.settings.config, ) except Exception as e: status = getattr(e, "status", 500) diff --git a/nanobot/webui/settings_services.py b/nanobot/webui/settings_services.py new file mode 100644 index 000000000..1ca30543a --- /dev/null +++ b/nanobot/webui/settings_services.py @@ -0,0 +1,148 @@ +"""Gateway-owned state for the WebUI settings surface.""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, TypeVar + +from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config + +_T = TypeVar("_T") +_WEBUI_OAUTH_MAX_FLOWS = 8 + + +class WebUISettingsConfig: + """Instance-scoped config access with serialized read-modify-write operations.""" + + def __init__(self, config_path: Path) -> None: + self.path = config_path.expanduser().resolve(strict=False) + self._lock = threading.RLock() + + def load(self) -> Config: + """Load this gateway's config without consulting the process-global path.""" + with self._lock: + return load_config(self.path) + + def update(self, mutation: Callable[[Config], _T]) -> _T: + """Apply and atomically persist one in-process read-modify-write operation.""" + with self._lock: + config = load_config(self.path) + result = mutation(config) + save_config(config, self.path) + return result + + def run_serialized(self, operation: Callable[[Path], _T]) -> _T: + """Run a path-aware read-modify-write operation under the instance lock.""" + with self._lock: + return operation(self.path) + + +class WebUIOAuthFlowRegistry: + """Bounded, thread-safe OAuth flows owned by one gateway instance.""" + + def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None: + if max_flows < 1: + raise ValueError("max_flows must be at least one") + self._max_flows = max_flows + self._flows: dict[str, tuple[str, Any]] = {} + self._lock = threading.Lock() + + def register(self, provider_name: str, flow_id: str, flow: Any) -> None: + discarded: list[Any] = [] + with self._lock: + for existing_id, (_provider_name, existing) in list(self._flows.items()): + if existing.expired: + discarded.append(self._flows.pop(existing_id)[1]) + while len(self._flows) >= self._max_flows: + oldest_id = next(iter(self._flows)) + discarded.append(self._flows.pop(oldest_id)[1]) + self._flows[flow_id] = (provider_name, flow) + for existing in discarded: + existing.cancel() + + def get(self, provider_name: str, flow_id: str) -> Any | None: + with self._lock: + registered = self._flows.get(flow_id) + if registered is None or registered[0] != provider_name: + return None + flow = registered[1] + if not flow.expired: + return flow + self._flows.pop(flow_id, None) + flow.cancel() + return None + + def remove( + self, + provider_name: str, + flow_id: str, + flow: Any, + *, + cancel: bool = True, + ) -> None: + with self._lock: + registered = self._flows.get(flow_id) + if ( + registered is not None + and registered[0] == provider_name + and registered[1] is flow + ): + self._flows.pop(flow_id) + if cancel: + flow.cancel() + + def clear(self, provider_name: str) -> None: + with self._lock: + flow_ids = [ + flow_id + for flow_id, (registered_provider, _flow) in self._flows.items() + if registered_provider == provider_name + ] + flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids] + for flow in flows: + flow.cancel() + + +@dataclass(frozen=True) +class WebUISettingsServices: + """Settings dependencies composed once for a gateway instance.""" + + config: WebUISettingsConfig + oauth_flows: WebUIOAuthFlowRegistry + + @classmethod + def create(cls, config_path: Path) -> WebUISettingsServices: + return cls( + config=WebUISettingsConfig(config_path), + oauth_flows=WebUIOAuthFlowRegistry(), + ) + + def read( + self, + operation: Callable[..., _T], + /, + *args: Any, + **kwargs: Any, + ) -> _T: + """Run a settings read against this gateway's explicit config path.""" + return operation(*args, config_path=self.config.path, **kwargs) + + def mutate( + self, + operation: Callable[..., _T], + /, + *args: Any, + **kwargs: Any, + ) -> _T: + """Serialize a path-aware settings read-modify-write operation.""" + return self.config.run_serialized( + lambda config_path: operation( + *args, + config_path=config_path, + **kwargs, + ) + ) diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 90148c371..a298a979b 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -204,6 +204,7 @@ if TYPE_CHECKING: from nanobot.cron.service import CronService from nanobot.session.manager import SessionManager from nanobot.triggers.local_store import LocalTriggerStore + from nanobot.webui.settings_services import WebUISettingsServices def _decode_api_key(raw_key: str) -> str | None: key = unquote(raw_key) @@ -292,6 +293,7 @@ class GatewayHTTPHandler: media: WebUIMediaGateway, ingress: WebUIIngressPolicy, workspaces: WebUIWorkspaceController, + settings: WebUISettingsServices, skills_workspace_path: Path, disabled_skills: set[str] | None = None, cron_service: CronService | None = None, @@ -312,6 +314,7 @@ class GatewayHTTPHandler: self.media = media self.ingress = ingress self.workspaces = workspaces + self.settings = settings self.skills_workspace_path = skills_workspace_path self.disabled_skills: set[str] = ( disabled_skills if disabled_skills is not None else set() @@ -330,6 +333,7 @@ class GatewayHTTPHandler: self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {}) self.settings_routes = WebUISettingsRouter( + settings=settings, bus=bus, logger=self._log, check_api_token=self.check_api_token, diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 8b85c50f1..c75b72e26 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -651,6 +651,7 @@ def test_plugin_setup_contract_drives_save_and_validation( from nanobot.channels.validation import validate_channel_config from nanobot.config import loader from nanobot.webui.settings_routes import WebUISettingsRouter + from nanobot.webui.settings_services import WebUISettingsServices config_path = tmp_path / "config.json" save_config(Config(), config_path) @@ -660,6 +661,7 @@ def test_plugin_setup_contract_drives_save_and_validation( _channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC), ) router = object.__new__(WebUISettingsRouter) + router.settings = WebUISettingsServices.create(config_path) saved = router._save_channel_config_values( "setupplugin", @@ -738,6 +740,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm from nanobot.config import loader from nanobot.webui.settings_api import WebUISettingsError from nanobot.webui.settings_routes import WebUISettingsRouter + from nanobot.webui.settings_services import WebUISettingsServices config_path = tmp_path / "config.json" config_path.write_text( @@ -756,6 +759,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm before = config_path.read_text(encoding="utf-8") monkeypatch.setattr(loader, "_current_config_path", config_path) router = object.__new__(WebUISettingsRouter) + router.settings = WebUISettingsServices.create(config_path) with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error: router._save_channel_config_values( diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 1151d902f..8b3615222 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -12,7 +12,6 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi from nanobot.providers.registry import find_by_name from nanobot.webui.settings_api import ( WebUISettingsError, - _clear_webui_oauth_flows, _docs_version, _model_catalog_kind, _oauth_provider_status, @@ -36,11 +35,17 @@ from nanobot.webui.settings_api import ( update_transcription_settings, update_web_search_settings, ) +from nanobot.webui.settings_services import WebUIOAuthFlowRegistry DYNAMIC_PROVIDER_NAME = "my-company-api" DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1" +@pytest.fixture +def oauth_flows() -> WebUIOAuthFlowRegistry: + return WebUIOAuthFlowRegistry() + + def test_settings_payload_propagates_preset_resolution_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1490,6 +1495,7 @@ def test_xai_grok_status_accepts_refreshable_login( def test_openai_codex_oauth_login_passes_configured_proxy( tmp_path, monkeypatch: pytest.MonkeyPatch, + oauth_flows: WebUIOAuthFlowRegistry, ) -> None: proxy = "http://127.0.0.1:23458" config_path = tmp_path / "config.json" @@ -1522,7 +1528,10 @@ def test_openai_codex_oauth_login_passes_configured_proxy( fake_start, ) - payload = login_oauth_provider({"provider": ["openai-codex"]}) + payload = login_oauth_provider( + {"provider": ["openai-codex"]}, + oauth_flows=oauth_flows, + ) assert captured == { "proxy": proxy, @@ -1548,15 +1557,17 @@ def test_openai_codex_oauth_login_passes_configured_proxy( ) monkeypatch.setattr( "nanobot.webui.settings_api.settings_payload", - lambda: {"settings": "ready"}, + lambda **_kwargs: {"settings": "ready"}, ) pending = complete_oauth_provider( {"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]}, + oauth_flows=oauth_flows, ) completed = complete_oauth_provider( {"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]}, "http://localhost:1455/auth/callback?code=secret&state=test", + oauth_flows=oauth_flows, ) assert pending == { @@ -1574,6 +1585,7 @@ def test_openai_codex_oauth_login_passes_configured_proxy( def test_openai_codex_remote_login_uses_headless_dependency_mode( tmp_path, monkeypatch: pytest.MonkeyPatch, + oauth_flows: WebUIOAuthFlowRegistry, ) -> None: config_path = tmp_path / "config.json" save_config(Config(), config_path) @@ -1599,10 +1611,11 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode( try: payload = login_oauth_provider( - {"provider": ["openai-codex"], "remote_browser": ["true"]} + {"provider": ["openai-codex"], "remote_browser": ["true"]}, + oauth_flows=oauth_flows, ) finally: - _clear_webui_oauth_flows("openai_codex") + oauth_flows.clear("openai_codex") assert payload["completion_input"] == "callback_url" assert captured["open_browser"] is False @@ -1611,6 +1624,7 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode( def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit( monkeypatch: pytest.MonkeyPatch, + oauth_flows: WebUIOAuthFlowRegistry, ) -> None: real_import = builtins.__import__ @@ -1622,7 +1636,10 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit( monkeypatch.setattr(builtins, "__import__", fake_import) with pytest.raises(WebUISettingsError) as exc: - login_oauth_provider({"provider": ["openai-codex"]}) + login_oauth_provider( + {"provider": ["openai-codex"]}, + oauth_flows=oauth_flows, + ) assert str(exc.value) == ( "This nanobot installation is missing the required oauth-cli-kit package. " @@ -1632,6 +1649,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit( def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit( monkeypatch: pytest.MonkeyPatch, + oauth_flows: WebUIOAuthFlowRegistry, ) -> None: real_import = builtins.__import__ @@ -1643,7 +1661,10 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit( monkeypatch.setattr(builtins, "__import__", fake_import) with pytest.raises(WebUISettingsError) as exc: - login_oauth_provider({"provider": ["github-copilot"]}) + login_oauth_provider( + {"provider": ["github-copilot"]}, + oauth_flows=oauth_flows, + ) assert str(exc.value) == ( "This nanobot installation is missing the required oauth-cli-kit package. " @@ -1654,6 +1675,7 @@ def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit( def test_xai_grok_login_starts_fresh_browser_flow_with_proxy( tmp_path, monkeypatch: pytest.MonkeyPatch, + oauth_flows: WebUIOAuthFlowRegistry, ) -> None: proxy = "http://127.0.0.1:23458" config_path = tmp_path / "config.json" @@ -1675,7 +1697,10 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy( monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start) - payload = login_oauth_provider({"provider": ["xai-grok"]}) + payload = login_oauth_provider( + {"provider": ["xai-grok"]}, + oauth_flows=oauth_flows, + ) assert captured["proxy"] == proxy assert captured["timeout_s"] == 600 @@ -1699,15 +1724,17 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy( ) monkeypatch.setattr( "nanobot.webui.settings_api.settings_payload", - lambda: {"settings": "ready"}, + lambda **_kwargs: {"settings": "ready"}, ) pending = complete_oauth_provider( {"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]}, + oauth_flows=oauth_flows, ) completed = complete_oauth_provider( {"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]}, "secret", + oauth_flows=oauth_flows, ) assert pending == { @@ -1722,6 +1749,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy( def test_xai_grok_login_reports_upstream_failure_as_bad_gateway( tmp_path, monkeypatch: pytest.MonkeyPatch, + oauth_flows: WebUIOAuthFlowRegistry, ) -> None: config_path = tmp_path / "config.json" save_config(Config(), config_path) @@ -1734,7 +1762,10 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway( monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start) with pytest.raises(WebUISettingsError) as exc: - login_oauth_provider({"provider": ["xai-grok"]}) + login_oauth_provider( + {"provider": ["xai-grok"]}, + oauth_flows=oauth_flows, + ) assert exc.value.status == 502 assert str(exc.value) == ( @@ -1746,6 +1777,7 @@ def test_xai_grok_login_reports_upstream_failure_as_bad_gateway( def test_xai_grok_logout_removes_token_through_shared_lock( tmp_path, monkeypatch: pytest.MonkeyPatch, + oauth_flows: WebUIOAuthFlowRegistry, ) -> None: config_path = tmp_path / "config.json" save_config(Config(), config_path) @@ -1759,7 +1791,10 @@ def test_xai_grok_logout_removes_token_through_shared_lock( lambda: token_path, ) - logout_oauth_provider({"provider": ["xai-grok"]}) + logout_oauth_provider( + {"provider": ["xai-grok"]}, + oauth_flows=oauth_flows, + ) assert not token_path.exists() diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index 9180a1614..f0a469f14 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -8,12 +8,15 @@ from urllib.parse import parse_qs, urlsplit import pytest from websockets.datastructures import Headers +from nanobot.config.loader import get_config_path from nanobot.webui.http_utils import http_json_response from nanobot.webui.settings_routes import WebUISettingsRouter +from nanobot.webui.settings_services import WebUISettingsServices def _router(*, authorized: bool = True) -> WebUISettingsRouter: return WebUISettingsRouter( + settings=WebUISettingsServices.create(get_config_path()), bus=SimpleNamespace(), logger=SimpleNamespace(exception=lambda *_args: None), check_api_token=lambda _request: authorized, @@ -54,7 +57,13 @@ async def test_oauth_completion_reads_websocket_payload( ) -> None: captured: dict[str, object] = {} - def complete(query, authorization_response=None): + def complete( + query, + authorization_response=None, + *, + oauth_flows=None, + config_path=None, + ): captured.update(query=query, authorization_response=authorization_response) return { "status": "pending", @@ -127,7 +136,7 @@ async def test_model_preset_mutation_routes( ) -> None: captured: dict[str, object] = {} - def mutate(query): + def mutate(query, *, config_path=None): captured["query"] = query return {"routed": function_name} diff --git a/tests/webui/test_settings_services.py b/tests/webui/test_settings_services.py new file mode 100644 index 000000000..5ea45f447 --- /dev/null +++ b/tests/webui/test_settings_services.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import threading +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from nanobot.channels.websocket.runtime import WebSocketConfig +from nanobot.config.loader import load_config, save_config +from nanobot.config.schema import Config +from nanobot.webui.gateway_services import build_gateway_services +from nanobot.webui.settings_api import settings_payload, update_agent_settings, update_api_settings +from nanobot.webui.settings_services import ( + WebUIOAuthFlowRegistry, + WebUISettingsServices, +) + + +class _Flow: + def __init__(self, *, expired: bool = False) -> None: + self.expired = expired + self.cancel_count = 0 + + def cancel(self) -> None: + self.cancel_count += 1 + + +def _gateway(config_path: Path, workspace: Path): + return build_gateway_services( + config=WebSocketConfig(), + bus=MagicMock(), + session_manager=None, + static_dist_path=None, + workspace_path=workspace, + default_restrict_to_workspace=False, + config_path=config_path, + runtime_model_name=None, + runtime_surface="browser", + runtime_capabilities_overrides=None, + ) + + +def test_gateway_settings_services_isolate_config_paths_and_oauth_flows( + tmp_path: Path, +) -> None: + first_path = tmp_path / "first" / "config.json" + second_path = tmp_path / "second" / "config.json" + first_config = Config() + first_config.api.host = "127.0.0.2" + second_config = Config() + second_config.api.host = "127.0.0.3" + save_config(first_config, first_path) + save_config(second_config, second_path) + + first = _gateway(first_path, tmp_path / "first-workspace") + second = _gateway(second_path, tmp_path / "second-workspace") + + assert first.settings.config.path == first_path.resolve() + assert second.settings.config.path == second_path.resolve() + assert first.http.settings_routes.settings is first.settings + assert second.http.settings_routes.settings is second.settings + assert first.settings.config.load().api.host == "127.0.0.2" + assert second.settings.config.load().api.host == "127.0.0.3" + assert first.settings.read(settings_payload)["api"]["host"] == "127.0.0.2" + assert second.settings.read(settings_payload)["api"]["host"] == "127.0.0.3" + + first.settings.mutate(update_api_settings, {"port": ["19001"]}) + assert load_config(first_path).api.port == 19001 + assert load_config(second_path).api.port != 19001 + + first_flow = _Flow() + second_flow = _Flow() + first.settings.oauth_flows.register("openai_codex", "same-id", first_flow) + second.settings.oauth_flows.register("openai_codex", "same-id", second_flow) + + assert first.settings.oauth_flows.get("openai_codex", "same-id") is first_flow + assert second.settings.oauth_flows.get("openai_codex", "same-id") is second_flow + first.settings.oauth_flows.clear("openai_codex") + assert first_flow.cancel_count == 1 + assert second_flow.cancel_count == 0 + + +def test_settings_mutations_serialize_read_modify_write( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + services = WebUISettingsServices.create(config_path) + first_loaded = threading.Event() + release_first = threading.Event() + second_started = 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 == "settings-first": + first_loaded.set() + if not release_first.wait(timeout=2): + raise TimeoutError("timed out waiting to release first settings mutation") + elif threading.current_thread().name == "settings-second": + second_loaded.set() + return config + + monkeypatch.setattr(settings_api, "_load_settings_config", controlled_load) + + def run_first() -> None: + try: + services.mutate(update_agent_settings, {"timezone": ["Asia/Tokyo"]}) + except BaseException as exc: # noqa: BLE001 - re-raised in the test thread + errors.append(exc) + + def run_second() -> None: + try: + second_started.set() + services.mutate(update_api_settings, {"host": ["127.0.0.9"]}) + except BaseException as exc: # noqa: BLE001 - re-raised in the test thread + errors.append(exc) + + first = threading.Thread(target=run_first, name="settings-first") + second = threading.Thread(target=run_second, name="settings-second") + first.start() + assert first_loaded.wait(timeout=2) + second.start() + assert second_started.wait(timeout=2) + 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: + registry = WebUIOAuthFlowRegistry(max_flows=2) + expired = _Flow(expired=True) + oldest = _Flow() + newest = _Flow() + replacement = _Flow() + + registry.register("openai_codex", "expired", expired) + registry.register("openai_codex", "oldest", oldest) + assert expired.cancel_count == 1 + assert registry.get("openai_codex", "expired") is None + + registry.register("xai_grok", "newest", newest) + registry.register("openai_codex", "replacement", replacement) + assert oldest.cancel_count == 1 + assert registry.get("openai_codex", "oldest") is None + assert registry.get("xai_grok", "newest") is newest + assert registry.get("openai_codex", "newest") is None + + registry.remove("xai_grok", "newest", newest, cancel=False) + assert newest.cancel_count == 0 + assert registry.get("xai_grok", "newest") is None + + registry.clear("openai_codex") + assert replacement.cancel_count == 1 + assert registry.get("openai_codex", "replacement") is None