refactor(webui): make gateway own settings services (#5321)

This commit is contained in:
chengyongru
2026-08-10 18:10:55 +08:00
committed by GitHub
parent 85a452e5c7
commit c281e090d0
15 changed files with 874 additions and 271 deletions
+7
View File
@@ -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,
@@ -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": [],
+1
View File
@@ -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]:
+16 -6
View File
@@ -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":
+7
View File
@@ -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,
+86 -35
View File
@@ -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)
+20 -4
View File
@@ -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)
+168 -135
View File
@@ -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)
+155 -70
View File
@@ -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)
+148
View File
@@ -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,
)
)
+4
View File
@@ -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,
+4
View File
@@ -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(
+46 -11
View File
@@ -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()
+11 -2
View File
@@ -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}
+170
View File
@@ -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