mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-15 00:29:52 +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_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
webui_skill_state_action: Callable[[set[str]], None] | 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 = config
|
||||||
|
self._config_path = config_path.expanduser().resolve(strict=False)
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self._cron_service = cron_service
|
self._cron_service = cron_service
|
||||||
@@ -170,6 +176,7 @@ class ChannelManager:
|
|||||||
static_dist_path=static_path,
|
static_dist_path=static_path,
|
||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
default_restrict_to_workspace=self.config.tools.restrict_to_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),
|
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||||
runtime_model_name=self._webui_runtime_model_name,
|
runtime_model_name=self._webui_runtime_model_name,
|
||||||
runtime_surface=self._webui_runtime_surface,
|
runtime_surface=self._webui_runtime_surface,
|
||||||
|
|||||||
@@ -916,7 +916,11 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> 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 {
|
return {
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
@@ -946,7 +950,7 @@ async def test_cli_apps_routes_require_token_and_return_payload(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_routes.cli_apps_action",
|
"nanobot.webui.settings_routes.cli_apps_action",
|
||||||
lambda action, query: {
|
lambda action, query, *, config_path=None: {
|
||||||
"apps": [],
|
"apps": [],
|
||||||
"installed_count": 1,
|
"installed_count": 1,
|
||||||
"catalog_updated_at": "2026-04-18",
|
"catalog_updated_at": "2026-04-18",
|
||||||
@@ -1404,7 +1408,7 @@ async def test_feishu_connect_routes_write_config_and_hot_reload(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_routes.nanobot_features_action",
|
"nanobot.webui.settings_routes.nanobot_features_action",
|
||||||
lambda _action, _query, *, allow_install=True: {
|
lambda _action, _query, *, allow_install=True, config_path=None: {
|
||||||
"features": [{
|
"features": [{
|
||||||
"name": "feishu",
|
"name": "feishu",
|
||||||
"display_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]],
|
query: dict[str, list[str]],
|
||||||
*,
|
*,
|
||||||
allow_install: bool = True,
|
allow_install: bool = True,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
assert action == "enable"
|
assert action == "enable"
|
||||||
assert query == {"name": ["discord"], "instance_id": ["default"]}
|
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()
|
entered = asyncio.Event()
|
||||||
release = 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
|
assert installed_only is False
|
||||||
entered.set()
|
entered.set()
|
||||||
with suppress(asyncio.TimeoutError):
|
with suppress(asyncio.TimeoutError):
|
||||||
@@ -1941,7 +1950,11 @@ async def test_cli_apps_route_supports_installed_only_payload(
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls: list[bool] = []
|
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)
|
calls.append(installed_only)
|
||||||
return {"apps": [], "installed_count": 0, "catalog_updated_at": None}
|
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:
|
) -> None:
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.mcp_presets_api.mcp_presets_payload",
|
"nanobot.webui.mcp_presets_api.mcp_presets_payload",
|
||||||
lambda: {
|
lambda **_kwargs: {
|
||||||
"presets": [
|
"presets": [
|
||||||
{
|
{
|
||||||
"name": "browserbase",
|
"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]]]] = []
|
preset_queries: list[tuple[str, dict[str, list[str]]]] = []
|
||||||
custom_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))
|
preset_queries.append((action, query))
|
||||||
return {
|
return {
|
||||||
"presets": [],
|
"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]}"},
|
"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))
|
custom_queries.append((action, query))
|
||||||
return {
|
return {
|
||||||
"presets": [],
|
"presets": [],
|
||||||
|
|||||||
@@ -669,6 +669,7 @@ def _run_gateway(
|
|||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
|
config_path=Path(config_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
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
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
def _manager() -> CliAppManager:
|
def _manager(config_path: Path | None = None) -> CliAppManager:
|
||||||
config = load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
cli_cfg = config.tools.cli_apps
|
cli_cfg = config.tools.cli_apps
|
||||||
return CliAppManager(
|
return CliAppManager(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
@@ -102,8 +103,12 @@ def _manager() -> CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
async def cli_apps_payload(
|
||||||
manager = _manager()
|
*,
|
||||||
|
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:
|
if installed_only:
|
||||||
return manager.installed_payload()
|
return manager.installed_payload()
|
||||||
payload = manager.payload(cache_only=True)
|
payload = manager.payload(cache_only=True)
|
||||||
@@ -118,11 +123,16 @@ async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
|||||||
return payload
|
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()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise CliAppError("missing CLI app name")
|
raise CliAppError("missing CLI app name")
|
||||||
manager = _manager()
|
manager = _manager(config_path) if config_path is not None else _manager()
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return manager.install(name)
|
return manager.install(name)
|
||||||
if action == "update":
|
if action == "update":
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Any, Callable
|
|||||||
|
|
||||||
from loguru import logger as default_logger
|
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.gateway_tokens import GatewayTokenStore
|
||||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
@@ -29,6 +31,7 @@ class GatewayServices:
|
|||||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||||
|
|
||||||
http: GatewayHTTPHandler
|
http: GatewayHTTPHandler
|
||||||
|
settings: WebUISettingsServices
|
||||||
tokens: GatewayTokenStore
|
tokens: GatewayTokenStore
|
||||||
media: WebUIMediaGateway
|
media: WebUIMediaGateway
|
||||||
ingress: WebUIIngressPolicy
|
ingress: WebUIIngressPolicy
|
||||||
@@ -50,6 +53,7 @@ def build_gateway_services(
|
|||||||
static_dist_path: Path | None,
|
static_dist_path: Path | None,
|
||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
|
config_path: Path | None = None,
|
||||||
runtime_model_name: Callable[[], str | None] | None,
|
runtime_model_name: Callable[[], str | None] | None,
|
||||||
runtime_surface: str,
|
runtime_surface: str,
|
||||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||||
@@ -63,6 +67,7 @@ def build_gateway_services(
|
|||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
|
settings = WebUISettingsServices.create(config_path or get_config_path())
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||||
@@ -102,6 +107,7 @@ def build_gateway_services(
|
|||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
|
settings=settings,
|
||||||
skills_workspace_path=workspace_path,
|
skills_workspace_path=workspace_path,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
@@ -115,6 +121,7 @@ def build_gateway_services(
|
|||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
http=http,
|
http=http,
|
||||||
|
settings=settings,
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
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]]
|
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)
|
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||||
_SECRET_QUERY_RE = re.compile(
|
_SECRET_QUERY_RE = re.compile(
|
||||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||||
@@ -841,8 +844,9 @@ def mcp_presets_payload(
|
|||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
tool_preview: Mapping[str, list[str]] | None = None,
|
tool_preview: Mapping[str, list[str]] | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_config(config_path) if config_path is not None else load_config()
|
||||||
known = _known_preset_names()
|
known = _known_preset_names()
|
||||||
preset_rows = [
|
preset_rows = [
|
||||||
_preset_payload(preset, config.tools.mcp_servers)
|
_preset_payload(preset, config.tools.mcp_servers)
|
||||||
@@ -928,7 +932,11 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
|||||||
await stack.aclose()
|
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."""
|
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
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)
|
display_name = _display_name_for(name, preset)
|
||||||
|
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
return mcp_presets_payload(last_action={
|
return mcp_presets_payload(
|
||||||
"ok": False,
|
last_action={
|
||||||
"message": _scrub_test_error(str(exc)),
|
"ok": False,
|
||||||
"error": _scrub_test_error(str(exc)),
|
"message": _scrub_test_error(str(exc)),
|
||||||
"tool_count": 0,
|
"error": _scrub_test_error(str(exc)),
|
||||||
"tool_names": [],
|
"tool_count": 0,
|
||||||
"checked_at": _checked_at(),
|
"tool_names": [],
|
||||||
})
|
"checked_at": _checked_at(),
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
|
||||||
cfg = config.tools.mcp_servers.get(name)
|
cfg = config.tools.mcp_servers.get(name)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
@@ -968,7 +982,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"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):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
last_action = {
|
last_action = {
|
||||||
@@ -979,7 +993,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"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()
|
registry = ToolRegistry()
|
||||||
stacks: dict[str, Any] = {}
|
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", [])
|
tool_names = last_action.get("tool_names", [])
|
||||||
preview = {name: tool_names} if tool_names else None
|
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:
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
def custom_mcp_action(
|
||||||
config = load_config()
|
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":
|
if action == "custom":
|
||||||
name, cfg = _custom_server_from_query(query)
|
name, cfg = _custom_server_from_query(query)
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_server_action_message(action, name),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
if action in {"import", "import-cursor"}:
|
if action in {"import", "import-cursor"}:
|
||||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||||
config.tools.mcp_servers.update(servers)
|
config.tools.mcp_servers.update(servers)
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action={
|
payload = mcp_presets_payload(
|
||||||
"ok": True,
|
last_action={
|
||||||
"message": f"Imported {len(servers)} MCP server(s).",
|
"ok": True,
|
||||||
})
|
"message": f"Imported {len(servers)} MCP server(s).",
|
||||||
|
},
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1249,29 +1278,40 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
raise McpPresetError("unknown MCP server", status=404)
|
raise McpPresetError("unknown MCP server", status=404)
|
||||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_server_action_message(action, name),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
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()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise McpPresetError("missing MCP preset name")
|
raise McpPresetError("missing MCP preset name")
|
||||||
preset = _preset_by_name_optional(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)
|
existing = config.tools.mcp_servers.get(name)
|
||||||
|
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise McpPresetError("unknown MCP preset", status=404)
|
raise McpPresetError("unknown MCP preset", status=404)
|
||||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
payload = mcp_presets_payload(
|
||||||
|
last_action=_action_message(action, preset),
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1287,7 +1327,7 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
cleanup_error = str(exc)
|
cleanup_error = str(exc)
|
||||||
del config.tools.mcp_servers[name]
|
del config.tools.mcp_servers[name]
|
||||||
save_config(config)
|
save_config(config, config_path)
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
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}"
|
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||||
)
|
)
|
||||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
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
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1339,13 +1382,21 @@ async def mcp_presets_settings_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
reload_mcp: McpReload | None = None,
|
reload_mcp: McpReload | None = None,
|
||||||
|
config: WebUISettingsConfig | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""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:
|
if action is None:
|
||||||
return mcp_presets_payload()
|
return mcp_presets_payload(config_path=config_path)
|
||||||
if action == "test":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query)
|
return await mcp_presets_test_action(query, config_path=config_path)
|
||||||
if action in _CUSTOM_ACTIONS:
|
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)
|
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
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]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
def nanobot_features_payload() -> dict[str, Any]:
|
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
||||||
return optional_features_payload()
|
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:
|
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||||
@@ -32,13 +38,19 @@ def nanobot_features_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
allow_install: bool = True,
|
allow_install: bool = True,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
name = (query_first(query, "name") or "").strip()
|
name = (query_first(query, "name") or "").strip()
|
||||||
instance_id = nanobot_feature_instance_target(query)
|
instance_id = nanobot_feature_instance_target(query)
|
||||||
if not name:
|
if not name:
|
||||||
raise OptionalFeatureError("missing feature name")
|
raise OptionalFeatureError("missing feature name")
|
||||||
if action == "enable":
|
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":
|
if action == "disable":
|
||||||
try:
|
try:
|
||||||
plugin = load_channel_plugin(name)
|
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.",
|
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||||
status=400,
|
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)
|
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||||
|
|||||||
+168
-135
@@ -14,11 +14,11 @@ import math
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from contextlib import suppress
|
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
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -49,6 +49,9 @@ from nanobot.webui.workspaces import (
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
RuntimeSurface = Literal["browser", "native"]
|
RuntimeSurface = Literal["browser", "native"]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||||
|
|
||||||
|
|
||||||
def _version_payload() -> dict[str, Any]:
|
def _version_payload() -> dict[str, Any]:
|
||||||
"""Return version info for the settings payload."""
|
"""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}
|
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
||||||
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
||||||
_WEBUI_OAUTH_TIMEOUT_S = 600
|
_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_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-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
|
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:
|
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
||||||
return "native" if surface in {"native", "desktop"} else "browser"
|
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
|
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.
|
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||||
|
|
||||||
The result is advisory only: users can always type a custom model id. This
|
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:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1117,8 +1136,9 @@ def settings_payload(
|
|||||||
runtime_capability_overrides: dict[str, Any] | None = None,
|
runtime_capability_overrides: dict[str, Any] | None = None,
|
||||||
restart_required_sections: list[str] | None = None,
|
restart_required_sections: list[str] | None = None,
|
||||||
apply_state: dict[str, Any] | None = None,
|
apply_state: dict[str, Any] | None = None,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset or "default"
|
||||||
effective_preset = config.resolve_preset()
|
effective_preset = config.resolve_preset()
|
||||||
@@ -1299,7 +1319,7 @@ def settings_payload(
|
|||||||
"providers": _transcription_provider_rows(config),
|
"providers": _transcription_provider_rows(config),
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"config_path": str(get_config_path().expanduser()),
|
"config_path": str(_settings_config_path(config_path).expanduser()),
|
||||||
"workspace_path": str(config.workspace_path),
|
"workspace_path": str(config.workspace_path),
|
||||||
"gateway_host": config.gateway.host,
|
"gateway_host": config.gateway.host,
|
||||||
"gateway_port": config.gateway.port,
|
"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."""
|
"""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)
|
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||||
|
|
||||||
|
|
||||||
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
def update_agent_settings(
|
||||||
config = load_config()
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = _load_settings_config(config_path)
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
changed = False
|
changed = False
|
||||||
restart_required = False
|
restart_required = False
|
||||||
@@ -1425,11 +1449,15 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload(requires_restart=restart_required)
|
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()
|
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
||||||
raw_name = (_query_first(query, "name") or label).strip()
|
raw_name = (_query_first(query, "name") or label).strip()
|
||||||
model = (_query_first(query, "model") or "").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")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
name = _model_configuration_slug(raw_name or label)
|
name = _model_configuration_slug(raw_name or label)
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
if name in config.model_presets:
|
if name in config.model_presets:
|
||||||
raise WebUISettingsError("configuration already exists", status=409)
|
raise WebUISettingsError("configuration already exists", status=409)
|
||||||
_validate_configured_provider(config, provider)
|
_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,
|
temperature=temperature if temperature is not None else base.temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
payload = settings_payload()
|
payload = settings_payload(config_path=config_path)
|
||||||
payload["created_model_preset"] = name
|
payload["created_model_preset"] = name
|
||||||
return payload
|
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()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
preset = config.model_presets.get(name)
|
preset = config.model_presets.get(name)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
@@ -1554,11 +1586,15 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
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")
|
raw_order = _query_first_alias(query, "order", "presetNames")
|
||||||
if raw_order is None:
|
if raw_order is None:
|
||||||
raise WebUISettingsError("model call order is required")
|
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()
|
cast(str, name).strip()
|
||||||
for name in cast(list[object], order)
|
for name in cast(list[object], order)
|
||||||
]
|
]
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
_, editable = _model_call_order_state(config)
|
_, editable = _model_call_order_state(config)
|
||||||
if not editable:
|
if not editable:
|
||||||
raise WebUISettingsError(
|
raise WebUISettingsError(
|
||||||
@@ -1599,13 +1635,17 @@ def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
|||||||
):
|
):
|
||||||
defaults.model_preset = normalized_order[0]
|
defaults.model_preset = normalized_order[0]
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
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."""
|
"""Materialize legacy primary/inline model settings as named presets."""
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
primary = config.resolve_preset()
|
primary = config.resolve_preset()
|
||||||
created: list[str] = []
|
created: list[str] = []
|
||||||
@@ -1658,16 +1698,20 @@ def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str,
|
|||||||
|
|
||||||
if created:
|
if created:
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
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()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
if name not in config.model_presets:
|
if name not in config.model_presets:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
@@ -1681,11 +1725,15 @@ def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
del config.model_presets[name]
|
del config.model_presets[name]
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
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()
|
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
||||||
if not display_name:
|
if not display_name:
|
||||||
raise WebUISettingsError("provider name is required")
|
raise WebUISettingsError("provider name is required")
|
||||||
@@ -1710,7 +1758,7 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not api_base:
|
if not api_base:
|
||||||
raise WebUISettingsError("API base is required")
|
raise WebUISettingsError("API base is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
if _provider_display_name_exists(config, display_name):
|
if _provider_display_name_exists(config, display_name):
|
||||||
raise WebUISettingsError("provider already exists", status=409)
|
raise WebUISettingsError("provider already exists", status=409)
|
||||||
|
|
||||||
@@ -1719,18 +1767,22 @@ def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
updates["api_type"] = "auto"
|
updates["api_type"] = "auto"
|
||||||
provider_config = _validated_provider_config(None, updates)
|
provider_config = _validated_provider_config(None, updates)
|
||||||
setattr(config.providers, provider_key, provider_config)
|
setattr(config.providers, provider_key, provider_config)
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
payload = settings_payload()
|
payload = settings_payload(config_path=config_path)
|
||||||
payload["created_provider"] = provider_key
|
payload["created_provider"] = provider_key
|
||||||
return payload
|
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()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1772,7 +1824,7 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = updated_provider_config != provider_config
|
changed = updated_provider_config != provider_config
|
||||||
if changed:
|
if changed:
|
||||||
setattr(config.providers, provider_key, updated_provider_config)
|
setattr(config.providers, provider_key, updated_provider_config)
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
restart_required = (
|
restart_required = (
|
||||||
changed
|
changed
|
||||||
@@ -1780,10 +1832,15 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
and image_config.provider == provider_key
|
and image_config.provider == provider_key
|
||||||
and get_image_gen_provider(provider_key) is not None
|
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()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
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
|
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
||||||
|
|
||||||
try:
|
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:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
remote_browser_value = _query_first(query, "remote_browser")
|
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:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
oauth_flows.register(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"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)
|
token = login_github_copilot(print_fn=lambda _message: None)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
if spec.name == "xai_grok":
|
if spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
||||||
|
|
||||||
try:
|
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:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
try:
|
try:
|
||||||
@@ -1857,7 +1920,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
oauth_flows.register(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
@@ -1873,6 +1936,9 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
def complete_oauth_provider(
|
def complete_oauth_provider(
|
||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
authorization_response: str | None = None,
|
authorization_response: str | None = None,
|
||||||
|
*,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
|
config_path: Path | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||||
@@ -1882,7 +1948,7 @@ def complete_oauth_provider(
|
|||||||
if not flow_id:
|
if not flow_id:
|
||||||
raise WebUISettingsError("flow_id is required")
|
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:
|
if flow is None:
|
||||||
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
||||||
|
|
||||||
@@ -1904,7 +1970,7 @@ def complete_oauth_provider(
|
|||||||
except WebUISettingsError:
|
except WebUISettingsError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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
|
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
||||||
if token is None:
|
if token is None:
|
||||||
return {
|
return {
|
||||||
@@ -1912,13 +1978,18 @@ def complete_oauth_provider(
|
|||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
"flow_id": flow_id,
|
"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:
|
if not token.access:
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
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()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
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
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
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()
|
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||||
elif spec.name == "github_copilot":
|
elif spec.name == "github_copilot":
|
||||||
try:
|
try:
|
||||||
@@ -1943,77 +2014,23 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
elif spec.name == "xai_grok":
|
elif spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||||
|
|
||||||
_clear_webui_oauth_flows(spec.name)
|
oauth_flows.clear(spec.name)
|
||||||
logout_xai_oauth()
|
logout_xai_oauth()
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
else:
|
else:
|
||||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||||
|
|
||||||
for path in (token_path, token_path.with_suffix(".lock")):
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
path.unlink()
|
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:
|
def update_network_safety_settings(
|
||||||
discarded: list[Any] = []
|
query: QueryParams,
|
||||||
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,
|
|
||||||
*,
|
*,
|
||||||
cancel: bool = True,
|
config_path: Path | None = None,
|
||||||
) -> None:
|
) -> dict[str, Any]:
|
||||||
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]:
|
|
||||||
raw_allow = (
|
raw_allow = (
|
||||||
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
||||||
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
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:
|
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")
|
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
|
changed = False
|
||||||
if raw_allow is not None:
|
if raw_allow is not None:
|
||||||
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
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
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
if raw_default_access_mode is not None:
|
if raw_default_access_mode is not None:
|
||||||
default_access_mode = raw_default_access_mode.strip().lower()
|
default_access_mode = raw_default_access_mode.strip().lower()
|
||||||
if default_access_mode == "restricted":
|
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)
|
write_webui_default_access_mode(default_access_mode)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise WebUISettingsError(str(exc)) from 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_name = (_query_first(query, "provider") or "").strip().lower()
|
||||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||||
if provider_option is None:
|
if provider_option is None:
|
||||||
raise WebUISettingsError("unknown web search provider")
|
raise WebUISettingsError("unknown web search provider")
|
||||||
|
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
search_config = config.tools.web.search
|
search_config = config.tools.web.search
|
||||||
web_config = config.tools.web
|
web_config = config.tools.web
|
||||||
previous_provider = search_config.provider
|
previous_provider = search_config.provider
|
||||||
@@ -2130,13 +2151,17 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload(requires_restart=restart_required)
|
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."""
|
"""Update the managed OpenAI-compatible API configuration."""
|
||||||
config = load_config()
|
config = _load_settings_config(config_path)
|
||||||
api = config.api
|
api = config.api
|
||||||
|
|
||||||
host = _query_first(query, "host")
|
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():
|
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")
|
raise WebUISettingsError("an API key is required when the API is available on the network")
|
||||||
|
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
return settings_payload(config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
def update_image_generation_settings(
|
||||||
config = load_config()
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = _load_settings_config(config_path)
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2271,12 +2300,16 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
raise WebUISettingsError("image generation provider is not configured")
|
raise WebUISettingsError("image generation provider is not configured")
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload(requires_restart=changed)
|
return settings_payload(requires_restart=changed, config_path=config_path)
|
||||||
|
|
||||||
|
|
||||||
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
def update_transcription_settings(
|
||||||
config = load_config()
|
query: QueryParams,
|
||||||
|
*,
|
||||||
|
config_path: Path | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = _load_settings_config(config_path)
|
||||||
transcription = config.transcription
|
transcription = config.transcription
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2341,5 +2374,5 @@ def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
save_config(config)
|
_save_settings_config(config, config_path)
|
||||||
return settings_payload()
|
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.registry import load_channel_plugin
|
||||||
from nanobot.channels.validation import validate_channel_config
|
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 (
|
from nanobot.optional_features import (
|
||||||
OptionalFeatureError,
|
OptionalFeatureError,
|
||||||
extra_installed,
|
extra_installed,
|
||||||
@@ -70,6 +70,7 @@ from nanobot.webui.settings_api import (
|
|||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
from nanobot.webui.version_check import check_for_update
|
from nanobot.webui.version_check import check_for_update
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
@@ -165,6 +166,7 @@ class WebUISettingsRouter:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
settings: WebUISettingsServices,
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
logger: Any,
|
logger: Any,
|
||||||
check_api_token: Callable[[WsRequest], bool],
|
check_api_token: Callable[[WsRequest], bool],
|
||||||
@@ -176,6 +178,7 @@ class WebUISettingsRouter:
|
|||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.settings = settings
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._check_api_token = check_api_token
|
self._check_api_token = check_api_token
|
||||||
@@ -335,7 +338,8 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
return self._json_response(
|
return self._json_response(
|
||||||
self._with_restart_state(
|
self._with_restart_state(
|
||||||
settings_payload(
|
self.settings.read(
|
||||||
|
settings_payload,
|
||||||
surface=self._runtime_surface,
|
surface=self._runtime_surface,
|
||||||
runtime_capability_overrides=self._runtime_capabilities,
|
runtime_capability_overrides=self._runtime_capabilities,
|
||||||
)
|
)
|
||||||
@@ -345,7 +349,7 @@ class WebUISettingsRouter:
|
|||||||
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
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:
|
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -391,7 +395,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_agent_settings(self._query(request))
|
payload = self.settings.mutate(update_agent_settings, self._query(request))
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -400,7 +404,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = create_model_configuration(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
create_model_configuration,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -409,7 +416,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_model_configuration(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_model_configuration,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -418,7 +428,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = delete_model_configuration(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
delete_model_configuration,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -427,7 +440,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = migrate_model_configurations(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
migrate_model_configurations,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -436,7 +452,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_model_call_order(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_model_call_order,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -445,7 +464,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
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:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -455,7 +477,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
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:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -464,7 +489,11 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
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:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -482,7 +511,12 @@ class WebUISettingsRouter:
|
|||||||
query = self._query(request)
|
query = self._query(request)
|
||||||
try:
|
try:
|
||||||
if action == "login":
|
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":
|
elif action == "complete":
|
||||||
raw_response = (_mutation_payload(request) or {}).get(
|
raw_response = (_mutation_payload(request) or {}).get(
|
||||||
"authorization_response"
|
"authorization_response"
|
||||||
@@ -491,12 +525,19 @@ class WebUISettingsRouter:
|
|||||||
raise WebUISettingsError("OAuth authorization response must be a string")
|
raise WebUISettingsError("OAuth authorization response must be a string")
|
||||||
authorization_response = raw_response
|
authorization_response = raw_response
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
|
self.settings.read,
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
query,
|
query,
|
||||||
authorization_response or None,
|
authorization_response or None,
|
||||||
|
oauth_flows=self.settings.oauth_flows,
|
||||||
)
|
)
|
||||||
else:
|
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:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
if payload.get("status") in {"authorization_required", "pending"}:
|
if payload.get("status") in {"authorization_required", "pending"}:
|
||||||
@@ -507,7 +548,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_web_search_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_web_search_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="browser"))
|
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||||
@@ -526,19 +570,22 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
{"name": ["api"]},
|
{"name": ["api"]},
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
update_api_settings(self._parse_api_service_settings_query(request))
|
self.settings.mutate(
|
||||||
config = load_config()
|
update_api_settings,
|
||||||
|
self._parse_api_service_settings_query(request),
|
||||||
|
)
|
||||||
|
config = self.settings.config.load()
|
||||||
runtime = self._api_runtime()
|
runtime = self._api_runtime()
|
||||||
options = ApiStartOptions(
|
options = ApiStartOptions(
|
||||||
host=config.api.host,
|
host=config.api.host,
|
||||||
port=config.api.port,
|
port=config.api.port,
|
||||||
workspace=str(config.workspace_path),
|
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()
|
current = runtime.status()
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
@@ -574,13 +621,11 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, self._api_runtime_message(result.message))
|
return self._error_response(500, self._api_runtime_message(result.message))
|
||||||
return self._json_response(self._api_service_payload(last_action="stopped"))
|
return self._json_response(self._api_service_payload(last_action="stopped"))
|
||||||
|
|
||||||
@staticmethod
|
def _api_runtime(self) -> ApiRuntime:
|
||||||
def _api_runtime() -> ApiRuntime:
|
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
||||||
config_path = get_config_path().expanduser().resolve(strict=False)
|
|
||||||
return ApiRuntime(paths=api_runtime_paths(config_path))
|
|
||||||
|
|
||||||
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
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()
|
status = self._api_runtime().status()
|
||||||
extras = optional_dependency_groups()
|
extras = optional_dependency_groups()
|
||||||
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
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):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_image_generation_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_image_generation_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -659,7 +707,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_transcription_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_transcription_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -668,7 +719,10 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = update_network_safety_settings(self._query(request))
|
payload = self.settings.mutate(
|
||||||
|
update_network_safety_settings,
|
||||||
|
self._query(request),
|
||||||
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -682,7 +736,10 @@ class WebUISettingsRouter:
|
|||||||
"yes",
|
"yes",
|
||||||
}
|
}
|
||||||
try:
|
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:
|
except Exception:
|
||||||
self.logger.exception("failed to load CLI Apps payload")
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
return self._error_response(500, "failed to load CLI Apps")
|
return self._error_response(500, "failed to load CLI Apps")
|
||||||
@@ -696,7 +753,12 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
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:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -711,12 +773,29 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(nanobot_features_payload)
|
payload = await asyncio.to_thread(self._nanobot_features_payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load nanobot features")
|
self.logger.exception("failed to load nanobot features")
|
||||||
return self._error_response(500, "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))
|
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(
|
async def _handle_settings_nanobot_features_action(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -727,7 +806,7 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
action,
|
action,
|
||||||
self._query(request),
|
self._query(request),
|
||||||
allow_install=action != "enable"
|
allow_install=action != "enable"
|
||||||
@@ -850,7 +929,7 @@ class WebUISettingsRouter:
|
|||||||
"saved_keys": saved,
|
"saved_keys": saved,
|
||||||
}
|
}
|
||||||
if not enable:
|
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)
|
features = self._with_channel_runtime_status(features)
|
||||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
@@ -861,7 +940,7 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
feature_query,
|
feature_query,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
@@ -929,44 +1008,47 @@ class WebUISettingsRouter:
|
|||||||
if not raw_values:
|
if not raw_values:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
config = load_config()
|
def update(config: Config) -> list[str]:
|
||||||
section = getattr(config.channels, name, None)
|
section = getattr(config.channels, name, None)
|
||||||
channel_config = channel_instance_config(
|
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(
|
|
||||||
plugin,
|
plugin,
|
||||||
section,
|
section,
|
||||||
channel_config,
|
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
|
||||||
raise WebUISettingsError(
|
saved: list[str] = []
|
||||||
f"Invalid {name} configuration: {exc}",
|
prefix = f"channels.{name}."
|
||||||
status=400,
|
for raw_key, raw_value in raw_values.items():
|
||||||
) from exc
|
if not raw_key:
|
||||||
setattr(config.channels, name, updated_section)
|
raise WebUISettingsError(
|
||||||
save_config(config)
|
"channel settings payload contains an invalid key"
|
||||||
return saved
|
)
|
||||||
|
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
|
@staticmethod
|
||||||
def _coerce_channel_value(
|
def _coerce_channel_value(
|
||||||
@@ -1089,14 +1171,14 @@ class WebUISettingsRouter:
|
|||||||
target["instance_id"] = [str(payload["instance_id"])]
|
target["instance_id"] = [str(payload["instance_id"])]
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
nanobot_features_action,
|
self._nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
target,
|
target,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
except OptionalFeatureError as exc:
|
except OptionalFeatureError as exc:
|
||||||
features = self._feature_runtime_fallback(
|
features = self._feature_runtime_fallback(
|
||||||
nanobot_features_payload(),
|
self._nanobot_features_payload(),
|
||||||
message=(
|
message=(
|
||||||
f"{channel_name} connected, but enabling channel support failed: "
|
f"{channel_name} connected, but enabling channel support failed: "
|
||||||
f"{exc.message}"
|
f"{exc.message}"
|
||||||
@@ -1117,7 +1199,9 @@ class WebUISettingsRouter:
|
|||||||
if _is_local_browser_request(connection, request.headers):
|
if _is_local_browser_request(connection, request.headers):
|
||||||
return True
|
return True
|
||||||
try:
|
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:
|
except Exception:
|
||||||
self.logger.exception("failed to load remote package install policy")
|
self.logger.exception("failed to load remote package install policy")
|
||||||
return False
|
return False
|
||||||
@@ -1134,6 +1218,7 @@ class WebUISettingsRouter:
|
|||||||
action,
|
action,
|
||||||
self._parse_mcp_settings_query(request),
|
self._parse_mcp_settings_query(request),
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
|
config=self.settings.config,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = getattr(e, "status", 500)
|
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.cron.service import CronService
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
def _decode_api_key(raw_key: str) -> str | None:
|
def _decode_api_key(raw_key: str) -> str | None:
|
||||||
key = unquote(raw_key)
|
key = unquote(raw_key)
|
||||||
@@ -292,6 +293,7 @@ class GatewayHTTPHandler:
|
|||||||
media: WebUIMediaGateway,
|
media: WebUIMediaGateway,
|
||||||
ingress: WebUIIngressPolicy,
|
ingress: WebUIIngressPolicy,
|
||||||
workspaces: WebUIWorkspaceController,
|
workspaces: WebUIWorkspaceController,
|
||||||
|
settings: WebUISettingsServices,
|
||||||
skills_workspace_path: Path,
|
skills_workspace_path: Path,
|
||||||
disabled_skills: set[str] | None = None,
|
disabled_skills: set[str] | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
@@ -312,6 +314,7 @@ class GatewayHTTPHandler:
|
|||||||
self.media = media
|
self.media = media
|
||||||
self.ingress = ingress
|
self.ingress = ingress
|
||||||
self.workspaces = workspaces
|
self.workspaces = workspaces
|
||||||
|
self.settings = settings
|
||||||
self.skills_workspace_path = skills_workspace_path
|
self.skills_workspace_path = skills_workspace_path
|
||||||
self.disabled_skills: set[str] = (
|
self.disabled_skills: set[str] = (
|
||||||
disabled_skills if disabled_skills is not None else set()
|
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._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||||
self.settings_routes = WebUISettingsRouter(
|
self.settings_routes = WebUISettingsRouter(
|
||||||
|
settings=settings,
|
||||||
bus=bus,
|
bus=bus,
|
||||||
logger=self._log,
|
logger=self._log,
|
||||||
check_api_token=self.check_api_token,
|
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.channels.validation import validate_channel_config
|
||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
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),
|
_channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC),
|
||||||
)
|
)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
|
router.settings = WebUISettingsServices.create(config_path)
|
||||||
|
|
||||||
saved = router._save_channel_config_values(
|
saved = router._save_channel_config_values(
|
||||||
"setupplugin",
|
"setupplugin",
|
||||||
@@ -738,6 +740,7 @@ def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tm
|
|||||||
from nanobot.config import loader
|
from nanobot.config import loader
|
||||||
from nanobot.webui.settings_api import WebUISettingsError
|
from nanobot.webui.settings_api import WebUISettingsError
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
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")
|
before = config_path.read_text(encoding="utf-8")
|
||||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||||
router = object.__new__(WebUISettingsRouter)
|
router = object.__new__(WebUISettingsRouter)
|
||||||
|
router.settings = WebUISettingsServices.create(config_path)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error:
|
||||||
router._save_channel_config_values(
|
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.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
WebUISettingsError,
|
WebUISettingsError,
|
||||||
_clear_webui_oauth_flows,
|
|
||||||
_docs_version,
|
_docs_version,
|
||||||
_model_catalog_kind,
|
_model_catalog_kind,
|
||||||
_oauth_provider_status,
|
_oauth_provider_status,
|
||||||
@@ -36,11 +35,17 @@ from nanobot.webui.settings_api import (
|
|||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
||||||
|
|
||||||
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def oauth_flows() -> WebUIOAuthFlowRegistry:
|
||||||
|
return WebUIOAuthFlowRegistry()
|
||||||
|
|
||||||
|
|
||||||
def test_settings_payload_propagates_preset_resolution_failure(
|
def test_settings_payload_propagates_preset_resolution_failure(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1490,6 +1495,7 @@ def test_xai_grok_status_accepts_refreshable_login(
|
|||||||
def test_openai_codex_oauth_login_passes_configured_proxy(
|
def test_openai_codex_oauth_login_passes_configured_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -1522,7 +1528,10 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
fake_start,
|
fake_start,
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = login_oauth_provider({"provider": ["openai-codex"]})
|
payload = login_oauth_provider(
|
||||||
|
{"provider": ["openai-codex"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"proxy": proxy,
|
"proxy": proxy,
|
||||||
@@ -1548,15 +1557,17 @@ def test_openai_codex_oauth_login_passes_configured_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda: {"settings": "ready"},
|
lambda **_kwargs: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]},
|
||||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
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(
|
def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1599,10 +1611,11 @@ def test_openai_codex_remote_login_uses_headless_dependency_mode(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = login_oauth_provider(
|
payload = login_oauth_provider(
|
||||||
{"provider": ["openai-codex"], "remote_browser": ["true"]}
|
{"provider": ["openai-codex"], "remote_browser": ["true"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
_clear_webui_oauth_flows("openai_codex")
|
oauth_flows.clear("openai_codex")
|
||||||
|
|
||||||
assert payload["completion_input"] == "callback_url"
|
assert payload["completion_input"] == "callback_url"
|
||||||
assert captured["open_browser"] is False
|
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(
|
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
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)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
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) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"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(
|
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
real_import = builtins.__import__
|
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)
|
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
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) == (
|
assert str(exc.value) == (
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
"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(
|
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
config_path = tmp_path / "config.json"
|
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)
|
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["proxy"] == proxy
|
||||||
assert captured["timeout_s"] == 600
|
assert captured["timeout_s"] == 600
|
||||||
@@ -1699,15 +1724,17 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.settings_api.settings_payload",
|
"nanobot.webui.settings_api.settings_payload",
|
||||||
lambda: {"settings": "ready"},
|
lambda **_kwargs: {"settings": "ready"},
|
||||||
)
|
)
|
||||||
|
|
||||||
pending = complete_oauth_provider(
|
pending = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
completed = complete_oauth_provider(
|
completed = complete_oauth_provider(
|
||||||
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
|
||||||
"secret",
|
"secret",
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert pending == {
|
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(
|
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
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)
|
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
|
||||||
|
|
||||||
with pytest.raises(WebUISettingsError) as exc:
|
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 exc.value.status == 502
|
||||||
assert str(exc.value) == (
|
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(
|
def test_xai_grok_logout_removes_token_through_shared_lock(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
oauth_flows: WebUIOAuthFlowRegistry,
|
||||||
) -> None:
|
) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
save_config(Config(), config_path)
|
save_config(Config(), config_path)
|
||||||
@@ -1759,7 +1791,10 @@ def test_xai_grok_logout_removes_token_through_shared_lock(
|
|||||||
lambda: token_path,
|
lambda: token_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
logout_oauth_provider({"provider": ["xai-grok"]})
|
logout_oauth_provider(
|
||||||
|
{"provider": ["xai-grok"]},
|
||||||
|
oauth_flows=oauth_flows,
|
||||||
|
)
|
||||||
|
|
||||||
assert not token_path.exists()
|
assert not token_path.exists()
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ from urllib.parse import parse_qs, urlsplit
|
|||||||
import pytest
|
import pytest
|
||||||
from websockets.datastructures import Headers
|
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.http_utils import http_json_response
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
from nanobot.webui.settings_services import WebUISettingsServices
|
||||||
|
|
||||||
|
|
||||||
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
def _router(*, authorized: bool = True) -> WebUISettingsRouter:
|
||||||
return WebUISettingsRouter(
|
return WebUISettingsRouter(
|
||||||
|
settings=WebUISettingsServices.create(get_config_path()),
|
||||||
bus=SimpleNamespace(),
|
bus=SimpleNamespace(),
|
||||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
logger=SimpleNamespace(exception=lambda *_args: None),
|
||||||
check_api_token=lambda _request: authorized,
|
check_api_token=lambda _request: authorized,
|
||||||
@@ -54,7 +57,13 @@ async def test_oauth_completion_reads_websocket_payload(
|
|||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
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)
|
captured.update(query=query, authorization_response=authorization_response)
|
||||||
return {
|
return {
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
@@ -127,7 +136,7 @@ async def test_model_preset_mutation_routes(
|
|||||||
) -> None:
|
) -> None:
|
||||||
captured: dict[str, object] = {}
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
def mutate(query):
|
def mutate(query, *, config_path=None):
|
||||||
captured["query"] = query
|
captured["query"] = query
|
||||||
return {"routed": function_name}
|
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