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