mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 14:28:38 +03:00
refactor(webui): make gateway own settings services (#5321)
This commit is contained in:
@@ -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": [],
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user