refactor(config): make load semantics explicit

This commit is contained in:
chengyongru 2026-07-14 14:39:06 +08:00
parent 27f7549c84
commit 4e065cfffe
37 changed files with 239 additions and 235 deletions

View File

@ -30,6 +30,6 @@ Configuration must be declared explicitly in `config/schema.py` Pydantic models.
## Configuration has an explicit owner ## Configuration has an explicit owner
`FileConfigRepository` owns config-file reads, validation, revisions, and atomic writes. Runtime code may use the compatibility helpers in `config/loader.py`, but new components with an explicit config path should keep their own repository instance instead of importing a mutable global config object. `FileConfigRepository` owns config-file reads, validation, revisions, and atomic writes. Process entry points may use the explicit functions in `config/loader.py`; components with an explicit config path should keep their own repository instance instead of importing a mutable global config object.
Persisted and runtime views are separate: `load_raw()` preserves `${VAR}` references for editing, while `load_effective()` returns an isolated snapshot with references resolved. Read-modify-write flows must use `update()` / `update_config()` so a stale object cannot silently overwrite a newer change. Loading config is side-effect free; process-wide policies are applied explicitly during runtime startup. Persisted and runtime views are separate: `load_raw_config()` / `load_raw()` preserve `${VAR}` references for editing, while `load_effective_config()` / `load_effective()` return an isolated snapshot with references resolved. Read-modify-write flows must use `update()` / `update_config()` so a stale object cannot silently overwrite a newer change. Loading config is side-effect free; process-wide policies are applied explicitly during runtime startup.

View File

@ -6,7 +6,7 @@
## Config `${VAR}` References ## Config `${VAR}` References
`load_config()` and `FileConfigRepository.load_raw()` preserve `${VAR}` patterns so Settings can safely edit and save the persisted representation. Runtime entry points use `load_effective_config()` / `load_effective()` to resolve them in an isolated snapshot. This is **not** a shell-like default-value syntax. If a referenced variable is missing, effective loading raises `ValueError`. `load_raw_config()` and `FileConfigRepository.load_raw()` preserve `${VAR}` patterns so Settings can safely edit and save the persisted representation. Runtime entry points use `load_effective_config()` / `load_effective()` to resolve them in an isolated snapshot. This is **not** a shell-like default-value syntax. If a referenced variable is missing, effective loading raises `ValueError`.
Example valid usage: Example valid usage:
```json ```json

View File

@ -13,6 +13,46 @@ For setup and runtime failures, follow the diagnosis order in [`troubleshooting.
> [!NOTE] > [!NOTE]
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings. > If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
## Python Configuration API
> [!WARNING]
> `nanobot.config.load_config` and `nanobot.config.loader.load_config` have been
> removed. There is intentionally no compatibility alias because the old name
> did not distinguish the persisted representation from the runtime view.
Python embedders and plugins must choose the view they need explicitly:
```python
from pathlib import Path
from nanobot.config import (
apply_config_runtime_policies,
load_effective_config,
load_raw_config,
)
path = Path.home() / ".nanobot" / "config.json"
# For settings editors and persistence flows: preserves ${VAR} placeholders.
persisted = load_raw_config(path)
# For agents, providers, channels, and tools: resolves ${VAR} placeholders.
runtime = load_effective_config(path)
apply_config_runtime_policies(runtime)
```
Use this migration mapping:
| Previous call | Replacement |
|---|---|
| `load_config(path)` used to inspect or edit persisted values | `load_raw_config(path)` |
| `resolve_config_env_vars(load_config(path))` used at runtime | `load_effective_config(path)` |
| Loading followed by implicit process-policy setup | `load_effective_config(path)`, then `apply_config_runtime_policies(config)` at the runtime boundary |
This is a Python API breaking change for embedders and plugins that import the
old function. The `config.json` format is unchanged, and the bundled CLI,
gateway, and WebUI already use the explicit APIs.
## Configuration Guides ## Configuration Guides
This page is the complete configuration reference. For task-oriented setup, use This page is the complete configuration reference. For task-oriented setup, use

View File

@ -1162,9 +1162,9 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True, "requires_restart": True,
} }
try: try:
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import load_effective_config
config = resolve_config_env_vars(load_config()) config = load_effective_config()
next_servers = dict(config.tools.mcp_servers) next_servers = dict(config.tools.mcp_servers)
except Exception as exc: except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc) logger.warning("MCP hot reload could not read config: {}", exc)

View File

@ -315,8 +315,9 @@ class WebSearchTool(Tool):
config_loader = None config_loader = None
if ctx.provider_snapshot_loader is not None: if ctx.provider_snapshot_loader is not None:
def config_loader(): def config_loader():
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import load_effective_config
return resolve_config_env_vars(load_config()).tools.web.search
return load_effective_config().tools.web.search
return cls( return cls(
config=ctx.config.web.search, config=ctx.config.web.search,
proxy=ctx.config.web.proxy, proxy=ctx.config.web.proxy,

View File

@ -52,9 +52,9 @@ class BaseChannel(ABC):
resolve_transcription_config, resolve_transcription_config,
transcribe_audio_file, transcribe_audio_file,
) )
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
return await transcribe_audio_file(file_path, resolve_transcription_config(load_config())) return await transcribe_audio_file(file_path, resolve_transcription_config(load_raw_config()))
except Exception: except Exception:
self.logger.exception("Audio transcription failed") self.logger.exception("Audio transcription failed")
return "" return ""

View File

@ -740,9 +740,9 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
if not FEISHU_AVAILABLE: if not FEISHU_AVAILABLE:
return False return False
from nanobot.config.loader import load_config, update_config from nanobot.config.loader import load_raw_config, update_config
source_config = config or load_config() source_config = config or load_raw_config()
feishu_cfg = getattr(source_config.channels, "feishu", None) feishu_cfg = getattr(source_config.channels, "feishu", None)
defaults = FeishuChannel.default_config() defaults = FeishuChannel.default_config()
specs = feishu_instance_specs(feishu_cfg, defaults) specs = feishu_instance_specs(feishu_cfg, defaults)

View File

@ -364,9 +364,9 @@ class ChannelManager:
"message": "WebSocket hosts the WebUI and is applied on restart.", "message": "WebSocket hosts the WebUI and is applied on restart.",
} }
from nanobot.config.loader import load_config from nanobot.config.loader import load_effective_config
self.config = load_config() self.config = load_effective_config()
section = self._channel_section(name) section = self._channel_section(name)
if action == "disable": if action == "disable":
runtime_names = [name if not instance_id else f"{name}.{instance_id}"] runtime_names = [name if not instance_id else f"{name}.{instance_id}"]

View File

@ -640,7 +640,7 @@ def onboard(
non_interactive_refresh: bool = typer.Option(False, "--refresh", help="Refresh config, preserving existing settings without prompting"), non_interactive_refresh: bool = typer.Option(False, "--refresh", help="Refresh config, preserving existing settings without prompting"),
): ):
"""Initialize nanobot configuration and workspace.""" """Initialize nanobot configuration and workspace."""
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path from nanobot.config.loader import get_config_path, load_raw_config, save_config, set_config_path
from nanobot.config.schema import Config from nanobot.config.schema import Config
if config: if config:
@ -658,7 +658,7 @@ def onboard(
# Create or update config # Create or update config
if config_path.exists(): if config_path.exists():
if wizard: if wizard:
config = _apply_workspace_override(load_config(config_path)) config = _apply_workspace_override(load_raw_config(config_path))
else: else:
should_refresh = non_interactive_refresh should_refresh = non_interactive_refresh
if not non_interactive_refresh: if not non_interactive_refresh:
@ -677,7 +677,7 @@ def onboard(
should_refresh = True should_refresh = True
if should_refresh: if should_refresh:
config = _apply_workspace_override(load_config(config_path)) config = _apply_workspace_override(load_raw_config(config_path))
save_config(config, config_path) save_config(config, config_path)
console.print( console.print(
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)" f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
@ -861,7 +861,7 @@ def _load_inspection_config(
workspace: str | None = None, workspace: str | None = None,
) -> tuple[Path, Config]: ) -> tuple[Path, Config]:
"""Load config for diagnostic commands without resolving secret env refs.""" """Load config for diagnostic commands without resolving secret env refs."""
from nanobot.config.loader import get_config_path, load_config, set_config_path from nanobot.config.loader import get_config_path, load_raw_config, set_config_path
config_path = None config_path = None
if config: if config:
@ -871,7 +871,7 @@ def _load_inspection_config(
display_path = config_path or get_config_path() display_path = config_path or get_config_path()
try: try:
loaded = load_config(config_path) loaded = load_raw_config(config_path)
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc raise typer.Exit(1) from exc
@ -923,10 +923,10 @@ def _resolve_webui_config_path(config: str | None) -> Path:
def _load_webui_setup_config(config_path: Path) -> Config: def _load_webui_setup_config(config_path: Path) -> Config:
"""Load config for first-run mutation without resolving env-var placeholders.""" """Load config for first-run mutation without resolving env-var placeholders."""
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
try: try:
return load_config(config_path) return load_raw_config(config_path)
except ValueError as e: except ValueError as e:
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) from e raise typer.Exit(1) from e
@ -934,7 +934,7 @@ def _load_webui_setup_config(config_path: Path) -> Config:
def _provider_setup_error(config: Config) -> str | None: def _provider_setup_error(config: Config) -> str | None:
"""Return the provider setup error, or None when the current model can start.""" """Return the provider setup error, or None when the current model can start."""
from nanobot.config.loader import resolve_config_env_vars from nanobot.config.repository import resolve_config_env_vars
from nanobot.providers.factory import build_provider_snapshot from nanobot.providers.factory import build_provider_snapshot
try: try:
@ -2520,7 +2520,7 @@ def plugins_list(
): ):
"""List optional nanobot features.""" """List optional nanobot features."""
from nanobot.channels.registry import discover_channel_names, discover_plugins from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config, set_config_path from nanobot.config.loader import load_raw_config, set_config_path
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None: if resolved_config_path is not None:
@ -2530,7 +2530,7 @@ def plugins_list(
feature_support.optional_dependency_groups(), feature_support.optional_dependency_groups(),
set(discover_channel_names()), set(discover_channel_names()),
discover_plugins(), discover_plugins(),
load_config(resolved_config_path), load_raw_config(resolved_config_path),
) )
@ -2767,11 +2767,11 @@ def _login_openai_codex() -> None:
try: try:
from oauth_cli_kit import get_token, login_oauth_interactive from oauth_cli_kit import get_token, login_oauth_interactive
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import load_effective_config
proxy = None proxy = None
try: try:
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None proxy = load_effective_config().providers.openai_codex.proxy or None
except ValueError as e: except ValueError as e:
console.print(f"[red]{e}[/red]") console.print(f"[red]{e}[/red]")
raise typer.Exit(1) from e raise typer.Exit(1) from e

View File

@ -22,7 +22,7 @@ from nanobot.cli.models import (
get_model_context_limit, get_model_context_limit,
get_model_suggestions, get_model_suggestions,
) )
from nanobot.config.loader import get_config_path, load_config from nanobot.config.loader import get_config_path, load_raw_config
from nanobot.config.schema import Config, ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
console = Console() console = Console()
@ -1940,7 +1940,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
else: else:
config_path = get_config_path() config_path = get_config_path()
if config_path.exists(): if config_path.exists():
base_config = load_config() base_config = load_raw_config()
else: else:
base_config = Config() base_config = Config()

View File

@ -4,8 +4,8 @@ from nanobot.config.loader import (
apply_config_runtime_policies, apply_config_runtime_policies,
get_config_path, get_config_path,
get_config_repository, get_config_repository,
load_config,
load_effective_config, load_effective_config,
load_raw_config,
update_config, update_config,
) )
from nanobot.config.paths import ( from nanobot.config.paths import (
@ -37,8 +37,8 @@ __all__ = [
"FileConfigRepository", "FileConfigRepository",
"PersistedConfigSnapshot", "PersistedConfigSnapshot",
"apply_config_runtime_policies", "apply_config_runtime_policies",
"load_config",
"load_effective_config", "load_effective_config",
"load_raw_config",
"update_config", "update_config",
"get_config_path", "get_config_path",
"get_config_repository", "get_config_repository",

View File

@ -1,4 +1,4 @@
"""Compatibility helpers for configuration loading and persistence.""" """Configuration loading and persistence entry points."""
from __future__ import annotations from __future__ import annotations
@ -6,28 +6,18 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from loguru import logger as logger # compatibility: callers patch loader.logger
from nanobot.config.repository import ( from nanobot.config.repository import (
ConfigCommit, ConfigCommit,
FileConfigRepository, FileConfigRepository,
resolve_config_env_vars,
)
from nanobot.config.repository import (
_migrate_config as _migrate_config,
)
from nanobot.config.repository import (
_resolve_env_vars as _resolve_env_vars,
) )
from nanobot.config.schema import Config from nanobot.config.schema import Config
# Legacy default-instance path. Runtime code should prefer an explicitly scoped # Default path for process entry points that do not receive an explicit path.
# FileConfigRepository; these helpers remain for CLI and plugin compatibility.
_current_config_path: Path | None = None _current_config_path: Path | None = None
def set_config_path(path: Path) -> None: def set_config_path(path: Path) -> None:
"""Set the default config path used by compatibility helpers.""" """Set the default configuration path for subsequent entry-point calls."""
global _current_config_path global _current_config_path
_current_config_path = path _current_config_path = path
@ -44,18 +34,14 @@ def get_config_repository(config_path: Path | None = None) -> FileConfigReposito
return FileConfigRepository(config_path or get_config_path()) return FileConfigRepository(config_path or get_config_path())
def load_config(config_path: Path | None = None) -> Config: def load_raw_config(config_path: Path | None = None) -> Config:
"""Load raw persisted config without applying process runtime policy.""" """Load raw persisted config without applying process runtime policy."""
return get_config_repository(config_path).load_raw().config return get_config_repository(config_path).load_raw().config
def load_effective_config(config_path: Path | None = None) -> Config: def load_effective_config(config_path: Path | None = None) -> Config:
"""Load a fresh runtime config with environment references resolved. """Load a fresh runtime config with environment references resolved."""
return get_config_repository(config_path).load_effective().config
Route through ``load_config`` so existing embedders that replace the legacy
loader hook keep working during the repository migration.
"""
return resolve_config_env_vars(load_config(config_path))
def save_config(config: Config, config_path: Path | None = None) -> None: def save_config(config: Config, config_path: Path | None = None) -> None:
@ -102,14 +88,12 @@ def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
__all__ = [ __all__ = [
"FileConfigRepository",
"apply_config_runtime_policies", "apply_config_runtime_policies",
"get_config_path", "get_config_path",
"get_config_repository", "get_config_repository",
"load_config", "load_raw_config",
"load_effective_config", "load_effective_config",
"merge_missing_defaults", "merge_missing_defaults",
"resolve_config_env_vars",
"save_config", "save_config",
"set_config_path", "set_config_path",
"update_config", "update_config",

View File

@ -253,17 +253,6 @@ def _resolve_in_place(obj: Any) -> Any:
return obj return obj
def _resolve_env_vars(obj: object) -> object:
"""Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists."""
if isinstance(obj, str):
return _ENV_REF_PATTERN.sub(_env_replace, obj)
if isinstance(obj, dict):
return {key: _resolve_env_vars(value) for key, value in obj.items()}
if isinstance(obj, list):
return [_resolve_env_vars(value) for value in obj]
return obj
def _env_replace(match: re.Match[str]) -> str: def _env_replace(match: re.Match[str]) -> str:
name = match.group(1) name = match.group(1)
value = os.environ.get(name) value = os.environ.get(name)

View File

@ -440,16 +440,16 @@ def optional_features_payload(
last_action: dict[str, Any] | None = None, last_action: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
config_provided = config is not None config_provided = config is not None
config = config or load_config() config = config or load_raw_config()
if not config_provided: if not config_provided:
with suppress(Exception): with suppress(Exception):
from nanobot.channels.feishu import refresh_saved_feishu_identities from nanobot.channels.feishu import refresh_saved_feishu_identities
if refresh_saved_feishu_identities(config): if refresh_saved_feishu_identities(config):
config = load_config() config = load_raw_config()
extras = optional_dependency_groups() extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names()) builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins() plugin_channels = discover_plugins()

View File

@ -278,9 +278,9 @@ def load_provider_snapshot(
*, *,
preset_name: str | None = None, preset_name: str | None = None,
) -> ProviderSnapshot: ) -> ProviderSnapshot:
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import load_effective_config
return build_provider_snapshot( return build_provider_snapshot(
resolve_config_env_vars(load_config(config_path)), load_effective_config(config_path),
preset_name=preset_name, preset_name=preset_name,
) )

View File

@ -13,7 +13,7 @@ import httpx
from nanobot.channels import feishu from nanobot.channels import feishu
from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID, validate_instance_id from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID, validate_instance_id
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
class ChannelConnectError(Exception): class ChannelConnectError(Exception):
@ -394,7 +394,7 @@ class WeixinConnectStore:
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin import WeixinChannel from nanobot.channels.weixin import WeixinChannel
section = getattr(load_config().channels, "weixin", None) section = getattr(load_raw_config().channels, "weixin", None)
if hasattr(section, "model_dump"): if hasattr(section, "model_dump"):
config = section.model_dump(mode="json", by_alias=True) config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict): elif isinstance(section, dict):

View File

@ -16,7 +16,7 @@ from typing import Any
import httpx import httpx
from nanobot.channels._setup import channel_setup_spec from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
from nanobot.security.network import resolve_url_target from nanobot.security.network import resolve_url_target
CheckStatus = str CheckStatus = str
@ -42,7 +42,7 @@ def validate_channel_config(
if not channel: if not channel:
return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")]) return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")])
config = load_config() config = load_raw_config()
section = getattr(config.channels, channel, None) section = getattr(config.channels, channel, None)
values = _channel_config(channel, section, instance_id=instance_id) values = _channel_config(channel, section, instance_id=instance_id)
values = _merge_form_values(channel, values, raw_values or {}) values = _merge_form_values(channel, values, raw_values or {})

View File

@ -8,7 +8,7 @@ import time
from typing import Any from typing import Any
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
QueryParams = dict[str, list[str]] QueryParams = dict[str, list[str]]
@ -85,7 +85,7 @@ def _query_first(query: QueryParams, key: str) -> str | None:
def _manager() -> CliAppManager: def _manager() -> CliAppManager:
config = load_config() config = load_raw_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,

View File

@ -18,7 +18,7 @@ from typing import Any, Literal, Mapping
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
from nanobot.config.loader import load_config, resolve_config_env_vars, update_config from nanobot.config.loader import load_effective_config, load_raw_config, update_config
from nanobot.config.paths import get_runtime_subdir from nanobot.config.paths import get_runtime_subdir
from nanobot.config.schema import Config, MCPServerConfig from nanobot.config.schema import Config, MCPServerConfig
from nanobot.utils.helpers import ensure_dir from nanobot.utils.helpers import ensure_dir
@ -416,7 +416,7 @@ def _known_preset_names() -> set[str]:
def _known_mcp_names() -> set[str]: def _known_mcp_names() -> 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_raw_config().tools.mcp_servers)
return names return names
@ -822,7 +822,7 @@ 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,
) -> dict[str, Any]: ) -> dict[str, Any]:
config = load_config() config = load_raw_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)
@ -921,7 +921,7 @@ 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 = load_effective_config()
except ValueError as exc: except ValueError as exc:
return mcp_presets_payload(last_action={ return mcp_presets_payload(last_action={
"ok": False, "ok": False,

View File

@ -24,8 +24,8 @@ from nanobot.audio.transcription_registry import (
) )
from nanobot.config.loader import ( from nanobot.config.loader import (
get_config_path, get_config_path,
load_config, load_effective_config,
resolve_config_env_vars, load_raw_config,
update_config, update_config,
) )
from nanobot.config.schema import Config, ModelPresetConfig, ProviderConfig from nanobot.config.schema import Config, ModelPresetConfig, ProviderConfig
@ -517,7 +517,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_raw_config()
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")
@ -754,7 +754,7 @@ def settings_payload(
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,
) -> dict[str, Any]: ) -> dict[str, Any]:
config = load_config() config = load_raw_config()
defaults = config.agents.defaults defaults = config.agents.defaults
active_preset_name = defaults.model_preset or "default" active_preset_name = defaults.model_preset or "default"
try: try:
@ -961,7 +961,7 @@ def settings_payload(
def settings_usage_payload() -> dict[str, Any]: def settings_usage_payload() -> 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_raw_config()
return token_usage_payload(timezone_name=config.agents.defaults.timezone) return token_usage_payload(timezone_name=config.agents.defaults.timezone)
@ -1188,7 +1188,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
) from None ) from None
try: try:
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None proxy = load_effective_config().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
token = None token = None

View File

@ -21,7 +21,7 @@ from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels._setup import channel_setup_spec from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import get_config_path, load_config, update_config from nanobot.config.loader import get_config_path, load_raw_config, update_config
from nanobot.optional_features import ( from nanobot.optional_features import (
OptionalFeatureError, OptionalFeatureError,
extra_installed, extra_installed,
@ -397,7 +397,7 @@ class WebUISettingsRouter:
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)) update_api_settings(self._parse_api_service_settings_query(request))
config = load_config() config = load_raw_config()
runtime = self._api_runtime() runtime = self._api_runtime()
options = ApiStartOptions( options = ApiStartOptions(
host=config.api.host, host=config.api.host,
@ -465,7 +465,7 @@ class WebUISettingsRouter:
return ApiRuntime(paths=api_runtime_paths(config_path)) 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 = load_raw_config()
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
@ -1063,7 +1063,7 @@ 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(load_raw_config().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

View File

@ -13,7 +13,7 @@ from nanobot.audio.transcription import (
resolve_transcription_config, resolve_transcription_config,
transcribe_audio_data_url, transcribe_audio_data_url,
) )
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
_MAX_REQUEST_ID_LENGTH = 80 _MAX_REQUEST_ID_LENGTH = 80
@ -38,7 +38,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_raw_config()),
duration_ms=envelope.get("duration_ms"), duration_ms=envelope.get("duration_ms"),
) )
except TranscriptionIngressError as exc: except TranscriptionIngressError as exc:

View File

@ -107,8 +107,8 @@ def _decode_api_key(raw_key: str) -> str | None:
def _default_model_name_from_config() -> str | None: def _default_model_name_from_config() -> str | None:
try: try:
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
model = load_config().resolve_preset().model.strip() model = load_raw_config().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)

View File

@ -20,7 +20,7 @@ from nanobot.agent.tools import mcp as mcp_runtime
from nanobot.agent.tools.base import Tool from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.mcp import MCPResourceWrapper, MCPToolWrapper from nanobot.agent.tools.mcp import MCPResourceWrapper, MCPToolWrapper
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_raw_config, save_config
from nanobot.config.schema import MCPServerConfig from nanobot.config.schema import MCPServerConfig
@ -201,7 +201,7 @@ async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
): ):
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config() config = load_raw_config()
config.tools.mcp_servers["browserbase"] = MCPServerConfig( config.tools.mcp_servers["browserbase"] = MCPServerConfig(
type="stdio", type="stdio",
command="browserbase-mcp", command="browserbase-mcp",
@ -233,7 +233,7 @@ async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
assert loop.tools.has("mcp_browserbase_navigate") assert loop.tools.has("mcp_browserbase_navigate")
assert "browserbase" in loop._mcp_stacks assert "browserbase" in loop._mcp_stacks
config = load_config() config = load_raw_config()
del config.tools.mcp_servers["browserbase"] del config.tools.mcp_servers["browserbase"]
save_config(config) save_config(config)
@ -253,7 +253,7 @@ async def test_request_mcp_reload_reaches_runtime_control_without_restart(
): ):
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config() config = load_raw_config()
config.tools.mcp_servers["browserbase"] = MCPServerConfig( config.tools.mcp_servers["browserbase"] = MCPServerConfig(
type="stdio", type="stdio",
command="browserbase-mcp", command="browserbase-mcp",
@ -292,7 +292,7 @@ async def test_request_mcp_reload_reaches_runtime_control_without_restart(
assert result["requires_restart"] is False assert result["requires_restart"] is False
assert loop.tools.has("mcp_browserbase_navigate") assert loop.tools.has("mcp_browserbase_navigate")
config = load_config() config = load_raw_config()
del config.tools.mcp_servers["browserbase"] del config.tools.mcp_servers["browserbase"]
save_config(config) save_config(config)
@ -314,7 +314,7 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
): ):
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
config = load_config() config = load_raw_config()
config.tools.mcp_servers["browserbase"] = MCPServerConfig( config.tools.mcp_servers["browserbase"] = MCPServerConfig(
type="stdio", type="stdio",
command="browserbase-mcp", command="browserbase-mcp",

View File

@ -56,7 +56,7 @@ async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"]) monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {}) monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(registry, "discover_enabled", discover_enabled) monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs)) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda: next(configs))
manager = ChannelManager(disabled, MessageBus()) manager = ChannelManager(disabled, MessageBus())
manager._started = True manager._started = True
@ -95,7 +95,7 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f
"discover_enabled", "discover_enabled",
lambda enabled_names, **_kwargs: {"hot": _HotChannel}, lambda enabled_names, **_kwargs: {"hot": _HotChannel},
) )
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda: enabled)
manager = ChannelManager(enabled, MessageBus()) manager = ChannelManager(enabled, MessageBus())
old_channel = manager.channels["hot"] old_channel = manager.channels["hot"]

View File

@ -634,7 +634,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
seen["config"] = self.config seen["config"] = self.config
return True return True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config()) monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: Config())
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.channels.registry.discover_all", "nanobot.channels.registry.discover_all",
lambda: {"fakeplugin": _LoginPlugin}, lambda: {"fakeplugin": _LoginPlugin},
@ -660,7 +660,7 @@ def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
async def login(self, force: bool = False) -> bool: async def login(self, force: bool = False) -> bool:
return True return True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config()) monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: Config())
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.config.loader.set_config_path", "nanobot.config.loader.set_config_path",
lambda path: seen.__setitem__("config_path", path), lambda path: seen.__setitem__("config_path", path),
@ -686,7 +686,7 @@ def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
seen: dict[str, object] = {} seen: dict[str, object] = {}
config_path = tmp_path / "custom-config.json" config_path = tmp_path / "custom-config.json"
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config()) monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: Config())
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.config.loader.set_config_path", "nanobot.config.loader.set_config_path",
lambda path: seen.__setitem__("config_path", path), lambda path: seen.__setitem__("config_path", path),
@ -707,7 +707,7 @@ def test_plugins_list_shows_available_features(monkeypatch):
runner = CliRunner() runner = CliRunner()
config = Config.model_validate({"channels": {"weixin": {"enabled": True}}}) config = Config.model_validate({"channels": {"weixin": {"enabled": True}}})
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: config) monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: config)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"]) monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr( monkeypatch.setattr(

View File

@ -32,7 +32,7 @@ from nanobot.channels.websocket import (
_parse_inbound_payload, _parse_inbound_payload,
publish_runtime_model_update, publish_runtime_model_update,
) )
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_raw_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.session import webui_turns as wth from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
@ -2131,7 +2131,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
) )
assert bad_image.status_code == 400 assert bad_image.status_code == 400
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.agents.defaults.model == "atomic_chat/test" assert saved.agents.defaults.model == "atomic_chat/test"
assert saved.agents.defaults.provider == "atomic_chat" assert saved.agents.defaults.provider == "atomic_chat"
assert saved.agents.defaults.model_preset == "fast-writing" assert saved.agents.defaults.model_preset == "fast-writing"
@ -2320,7 +2320,7 @@ def test_update_provider_settings_ignores_api_type_for_non_openai(monkeypatch, t
}) })
assert body["providers"] assert body["providers"]
config = load_config(config_path) config = load_raw_config(config_path)
assert config.providers.custom.api_base == "https://example.test/v1" assert config.providers.custom.api_base == "https://example.test/v1"
assert config.providers.custom.api_type == "auto" assert config.providers.custom.api_type == "auto"

View File

@ -1124,7 +1124,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
) -> dict[str, Any]: ) -> dict[str, Any]:
assert action == "enable" assert action == "enable"
assert query == {"name": ["discord"]} assert query == {"name": ["discord"]}
cfg = loader.load_config() cfg = loader.load_raw_config()
section = dict(getattr(cfg.channels, "discord", {}) or {}) section = dict(getattr(cfg.channels, "discord", {}) or {})
section["enabled"] = True section["enabled"] = True
setattr(cfg.channels, "discord", section) setattr(cfg.channels, "discord", section)
@ -1151,7 +1151,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
async def channel_feature_action(action: str, name: str) -> dict[str, Any]: async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name)) calls.append((action, name))
cfg = loader.load_config() cfg = loader.load_raw_config()
assert getattr(cfg.channels, "discord")["token"] == "discord-token" assert getattr(cfg.channels, "discord")["token"] == "discord-token"
return { return {
"handled": True, "handled": True,

View File

@ -224,7 +224,7 @@ def mock_paths():
"""Mock config/workspace paths for test isolation.""" """Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \ with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \ patch("nanobot.config.loader.save_config") as mock_sc, \
patch("nanobot.config.loader.load_config") as mock_lc, \ patch("nanobot.config.loader.load_raw_config") as mock_lc, \
patch("nanobot.cli.commands.get_workspace_path") as mock_ws: patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data") base_dir = Path("./test_onboard_data")
if base_dir.exists(): if base_dir.exists():
@ -681,7 +681,7 @@ def test_provider_login_model_implies_set_main_provider(tmp_path):
def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch): def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
proxy = "http://127.0.0.1:23458" proxy = "http://127.0.0.1:23458"
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.config.loader.load_config", "nanobot.config.loader.load_effective_config",
lambda: Config.model_validate({"providers": {"openaiCodex": {"proxy": proxy}}}), lambda: Config.model_validate({"providers": {"openaiCodex": {"proxy": proxy}}}),
) )
@ -706,13 +706,12 @@ def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
assert captured["proxy"] == proxy assert captured["proxy"] == proxy
def test_provider_login_openai_codex_resolves_proxy_env_ref(monkeypatch): def test_provider_login_openai_codex_uses_resolved_proxy_from_effective_config(monkeypatch):
proxy = "http://127.0.0.1:23458" proxy = "http://127.0.0.1:23458"
monkeypatch.setenv("CODEX_PROXY_FOR_TEST", proxy)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.config.loader.load_config", "nanobot.config.loader.load_effective_config",
lambda: Config.model_validate( lambda: Config.model_validate(
{"providers": {"openaiCodex": {"proxy": "${CODEX_PROXY_FOR_TEST}"}}} {"providers": {"openaiCodex": {"proxy": proxy}}}
), ),
) )
@ -1278,8 +1277,9 @@ def mock_agent_runtime(tmp_path):
config = Config() config = Config()
config.agents.defaults.workspace = str(tmp_path / "default-workspace") config.agents.defaults.workspace = str(tmp_path / "default-workspace")
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ with patch(
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \ "nanobot.config.loader.load_effective_config", return_value=config
) as mock_load_effective_config, \
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \ patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
@ -1296,7 +1296,7 @@ def mock_agent_runtime(tmp_path):
yield { yield {
"config": config, "config": config,
"load_config": mock_load_config, "load_effective_config": mock_load_effective_config,
"sync_templates": mock_sync_templates, "sync_templates": mock_sync_templates,
"from_config": mock_from_config, "from_config": mock_from_config,
"agent_loop": agent_loop, "agent_loop": agent_loop,
@ -1319,7 +1319,7 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
result = runner.invoke(app, ["agent", "-m", "hello"]) result = runner.invoke(app, ["agent", "-m", "hello"])
assert result.exit_code == 0 assert result.exit_code == 0
assert mock_agent_runtime["load_config"].call_args.args == (None,) assert mock_agent_runtime["load_effective_config"].call_args.args == (None,)
assert mock_agent_runtime["sync_templates"].call_args.args == ( assert mock_agent_runtime["sync_templates"].call_args.args == (
mock_agent_runtime["config"].workspace_path, mock_agent_runtime["config"].workspace_path,
) )
@ -1338,7 +1338,9 @@ def test_agent_uses_explicit_config_path(mock_agent_runtime, tmp_path: Path):
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)]) result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
assert result.exit_code == 0 assert result.exit_code == 0
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),) assert mock_agent_runtime["load_effective_config"].call_args.args == (
config_path.resolve(),
)
def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
@ -1353,7 +1355,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
"nanobot.config.loader.set_config_path", "nanobot.config.loader.set_config_path",
lambda path: seen.__setitem__("config_path", path), lambda path: seen.__setitem__("config_path", path),
) )
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
@ -1391,7 +1393,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
seen: dict[str, Path] = {} seen: dict[str, Path] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
@ -1440,7 +1442,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
seen: dict[str, Path] = {} seen: dict[str, Path] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
@ -1496,7 +1498,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
seen: dict[str, Path] = {} seen: dict[str, Path] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider()) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
@ -1556,7 +1558,9 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),) assert mock_agent_runtime["load_effective_config"].call_args.args == (
config_path.resolve(),
)
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path) assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,) assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
passed_config = mock_agent_runtime["from_config"].call_args.args[0] passed_config = mock_agent_runtime["from_config"].call_args.args[0]
@ -1675,8 +1679,7 @@ def _patch_cli_command_runtime(
"nanobot.config.loader.set_config_path", "nanobot.config.loader.set_config_path",
set_config_path or (lambda _path: None), set_config_path or (lambda _path: None),
) )
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.config.loader.resolve_config_env_vars", lambda c: c)
monkeypatch.setattr( monkeypatch.setattr(
"nanobot.cli.commands.sync_workspace_templates", "nanobot.cli.commands.sync_workspace_templates",
sync_templates or (lambda _path: None), sync_templates or (lambda _path: None),
@ -2194,7 +2197,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
seen: dict[str, object] = {} seen: dict[str, object] = {}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch) _patch_gateway_ports_free(monkeypatch)
@ -2321,7 +2324,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
seen: dict[str, object] = {"run_records": []} seen: dict[str, object] = {"run_records": []}
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider) monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch) _patch_gateway_ports_free(monkeypatch)

View File

@ -2,12 +2,12 @@ import json
import pytest import pytest
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
from nanobot.config.schema import ApiConfig from nanobot.config.schema import ApiConfig
def test_load_config_missing_file_uses_defaults(tmp_path) -> None: def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
config = load_config(tmp_path / "missing.json") config = load_raw_config(tmp_path / "missing.json")
assert config.agents.defaults.model assert config.agents.defaults.model
@ -17,7 +17,7 @@ def test_load_config_invalid_json_fails_fast(tmp_path) -> None:
config_path.write_text("{broken json", encoding="utf-8") config_path.write_text("{broken json", encoding="utf-8")
with pytest.raises(ValueError, match="Failed to load config"): with pytest.raises(ValueError, match="Failed to load config"):
load_config(config_path) load_raw_config(config_path)
def test_load_config_rejects_non_object_root(tmp_path) -> None: def test_load_config_rejects_non_object_root(tmp_path) -> None:
@ -25,7 +25,7 @@ def test_load_config_rejects_non_object_root(tmp_path) -> None:
config_path.write_text("[]", encoding="utf-8") config_path.write_text("[]", encoding="utf-8")
with pytest.raises(ValueError, match="config root must be a JSON object"): with pytest.raises(ValueError, match="config root must be a JSON object"):
load_config(config_path) load_raw_config(config_path)
def test_load_config_invalid_schema_fails_fast(tmp_path) -> None: def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
@ -36,7 +36,7 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
) )
with pytest.raises(ValueError, match="Failed to load config"): with pytest.raises(ValueError, match="Failed to load config"):
load_config(config_path) load_raw_config(config_path)
@pytest.mark.parametrize("host", ["0.0.0.0", "::"]) @pytest.mark.parametrize("host", ["0.0.0.0", "::"])

View File

@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest import pytest
from nanobot.config.loader import apply_config_runtime_policies, load_config, save_config from nanobot.config.loader import apply_config_runtime_policies, load_raw_config, save_config
from nanobot.security.network import validate_url_target from nanobot.security.network import validate_url_target
@ -33,7 +33,7 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path)
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
assert config.agents.defaults.max_tokens == 1234 assert config.agents.defaults.max_tokens == 1234
assert config.agents.defaults.context_window_tokens == 200_000 assert config.agents.defaults.context_window_tokens == 200_000
@ -56,7 +56,7 @@ def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
save_config(config, config_path) save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
defaults = saved["agents"]["defaults"] defaults = saved["agents"]["defaults"]
@ -103,8 +103,8 @@ def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name)
encoding="utf-8", encoding="utf-8",
) )
with patch("nanobot.config.loader.logger.warning") as warning: with patch("nanobot.config.repository.logger.warning") as warning:
config = load_config(config_path) config = load_raw_config(config_path)
assert config.agents.defaults.max_tokens == 1234 assert config.agents.defaults.max_tokens == 1234
assert not hasattr(config.agents.defaults, "max_messages") assert not hasattr(config.agents.defaults, "max_messages")
@ -121,8 +121,8 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
with patch("nanobot.config.loader.logger.warning"): with patch("nanobot.config.repository.logger.warning"):
config = load_config(config_path) config = load_raw_config(config_path)
save_config(config, config_path) save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
@ -193,7 +193,7 @@ def test_load_config_migrates_legacy_my_tool_keys(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
assert config.tools.my.enable is False assert config.tools.my.enable is False
assert config.tools.my.allow_set is True assert config.tools.my.allow_set is True
@ -213,7 +213,7 @@ def test_save_config_rewrites_legacy_my_tool_keys(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
save_config(config, config_path) save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
@ -238,7 +238,7 @@ def test_new_my_tool_keys_take_precedence_over_legacy(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
assert config.tools.my.enable is True assert config.tools.my.enable is True
assert config.tools.my.allow_set is True assert config.tools.my.allow_set is True
@ -253,12 +253,12 @@ def test_runtime_policy_application_resets_ssrf_whitelist(tmp_path) -> None:
defaulted = tmp_path / "defaulted.json" defaulted = tmp_path / "defaulted.json"
defaulted.write_text(json.dumps({}), encoding="utf-8") defaulted.write_text(json.dumps({}), encoding="utf-8")
apply_config_runtime_policies(load_config(whitelisted)) apply_config_runtime_policies(load_raw_config(whitelisted))
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
ok, err = validate_url_target("http://ts.local/api") ok, err = validate_url_target("http://ts.local/api")
assert ok, err assert ok, err
apply_config_runtime_policies(load_config(defaulted)) apply_config_runtime_policies(load_raw_config(defaulted))
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
ok, _ = validate_url_target("http://ts.local/api") ok, _ = validate_url_target("http://ts.local/api")
assert not ok assert not ok
@ -268,7 +268,7 @@ def test_load_config_defaults_local_service_access_to_enabled(tmp_path) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8") config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
config = load_config(config_path) config = load_raw_config(config_path)
assert config.tools.webui_allow_local_service_access is True assert config.tools.webui_allow_local_service_access is True
@ -280,7 +280,7 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
assert config.tools.webui_allow_local_service_access is False assert config.tools.webui_allow_local_service_access is False
@ -289,7 +289,7 @@ def test_load_config_defaults_remote_package_install_to_disabled(tmp_path) -> No
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8") config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
config = load_config(config_path) config = load_raw_config(config_path)
assert config.tools.webui_allow_remote_package_install is False assert config.tools.webui_allow_remote_package_install is False
@ -306,5 +306,5 @@ def test_load_config_accepts_remote_package_install_aliases(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
assert load_config(camel_path).tools.webui_allow_remote_package_install is True assert load_raw_config(camel_path).tools.webui_allow_remote_package_install is True
assert load_config(snake_path).tools.webui_allow_remote_package_install is True assert load_raw_config(snake_path).tools.webui_allow_remote_package_install is True

View File

@ -2,54 +2,31 @@ import json
import pytest import pytest
from nanobot.config.loader import ( from nanobot.config.loader import load_raw_config, save_config
_resolve_env_vars, from nanobot.config.repository import resolve_config_env_vars
load_config,
resolve_config_env_vars,
save_config,
)
from nanobot.config.schema import Config from nanobot.config.schema import Config
class TestResolveEnvVars: class TestResolveConfig:
def test_replaces_string_value(self, monkeypatch): def test_resolves_multiple_refs_inside_a_value(self, monkeypatch):
monkeypatch.setenv("MY_SECRET", "hunter2") monkeypatch.setenv("TEST_USER", "alice")
assert _resolve_env_vars("${MY_SECRET}") == "hunter2" monkeypatch.setenv("TEST_PASSWORD", "secret")
config = Config.model_validate(
{"providers": {"groq": {"apiKey": "${TEST_USER}:${TEST_PASSWORD}"}}}
)
def test_partial_replacement(self, monkeypatch): resolved = resolve_config_env_vars(config)
monkeypatch.setenv("HOST", "example.com")
assert _resolve_env_vars("https://${HOST}/api") == "https://example.com/api"
def test_multiple_vars_in_one_string(self, monkeypatch): assert resolved.providers.groq.api_key == "alice:secret"
monkeypatch.setenv("USER", "alice")
monkeypatch.setenv("PASS", "secret")
assert _resolve_env_vars("${USER}:${PASS}") == "alice:secret"
def test_nested_dicts(self, monkeypatch):
monkeypatch.setenv("TOKEN", "abc123")
data = {"channels": {"telegram": {"token": "${TOKEN}"}}}
result = _resolve_env_vars(data)
assert result["channels"]["telegram"]["token"] == "abc123"
def test_lists(self, monkeypatch):
monkeypatch.setenv("VAL", "x")
assert _resolve_env_vars(["${VAL}", "plain"]) == ["x", "plain"]
def test_ignores_non_strings(self):
assert _resolve_env_vars(42) == 42
assert _resolve_env_vars(True) is True
assert _resolve_env_vars(None) is None
assert _resolve_env_vars(3.14) == 3.14
def test_plain_strings_unchanged(self):
assert _resolve_env_vars("no vars here") == "no vars here"
def test_missing_var_raises(self): def test_missing_var_raises(self):
config = Config.model_validate(
{"providers": {"groq": {"apiKey": "${DOES_NOT_EXIST}"}}}
)
with pytest.raises(ValueError, match="DOES_NOT_EXIST"): with pytest.raises(ValueError, match="DOES_NOT_EXIST"):
_resolve_env_vars("${DOES_NOT_EXIST}") resolve_config_env_vars(config)
class TestResolveConfig:
def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch): def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("TEST_API_KEY", "resolved-key") monkeypatch.setenv("TEST_API_KEY", "resolved-key")
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
@ -60,7 +37,7 @@ class TestResolveConfig:
encoding="utf-8", encoding="utf-8",
) )
raw = load_config(config_path) raw = load_raw_config(config_path)
assert raw.providers.groq.api_key == "${TEST_API_KEY}" assert raw.providers.groq.api_key == "${TEST_API_KEY}"
resolved = resolve_config_env_vars(raw) resolved = resolve_config_env_vars(raw)
@ -76,7 +53,7 @@ class TestResolveConfig:
encoding="utf-8", encoding="utf-8",
) )
raw = load_config(config_path) raw = load_raw_config(config_path)
save_config(raw, config_path) save_config(raw, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
@ -91,14 +68,14 @@ class TestResolveConfig:
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
config.agents.defaults.max_tokens = 1234 config.agents.defaults.max_tokens = 1234
save_config(config, config_path) save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
assert saved["agents"]["defaults"]["dream"]["cron"] == "0 */4 * * *" assert saved["agents"]["defaults"]["dream"]["cron"] == "0 */4 * * *"
reloaded = load_config(config_path) reloaded = load_raw_config(config_path)
schedule = reloaded.agents.defaults.dream.build_schedule("UTC") schedule = reloaded.agents.defaults.dream.build_schedule("UTC")
assert schedule.kind == "cron" assert schedule.kind == "cron"
assert schedule.expr == "0 */4 * * *" assert schedule.expr == "0 */4 * * *"
@ -119,7 +96,7 @@ class TestResolveConfig:
encoding="utf-8", encoding="utf-8",
) )
config = load_config(config_path) config = load_raw_config(config_path)
save_config(config, config_path) save_config(config, config_path)
saved = json.loads(config_path.read_text(encoding="utf-8")) saved = json.loads(config_path.read_text(encoding="utf-8"))
@ -149,7 +126,7 @@ class TestResolveConfig:
assert saved["providers"]["openaiCodex"] == {"proxy": proxy} assert saved["providers"]["openaiCodex"] == {"proxy": proxy}
assert saved["providers"]["groq"]["apiKey"] == "groq-secret" assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
reloaded = load_config(config_path) reloaded = load_raw_config(config_path)
assert reloaded.providers.openai_codex.proxy == proxy assert reloaded.providers.openai_codex.proxy == proxy
assert reloaded.providers.openai_codex.api_key is None assert reloaded.providers.openai_codex.api_key is None
@ -166,7 +143,7 @@ class TestResolveConfig:
encoding="utf-8", encoding="utf-8",
) )
raw = load_config(config_path) raw = load_raw_config(config_path)
assert raw.providers.openai_codex.api_key == "secret" assert raw.providers.openai_codex.api_key == "secret"
resolved = resolve_config_env_vars(raw) resolved = resolve_config_env_vars(raw)
@ -190,7 +167,7 @@ class TestResolveConfig:
encoding="utf-8", encoding="utf-8",
) )
raw = load_config(config_path) raw = load_raw_config(config_path)
resolved = resolve_config_env_vars(raw) resolved = resolve_config_env_vars(raw)
assert resolved.providers.groq.api_key == "resolved-key" assert resolved.providers.groq.api_key == "resolved-key"

View File

@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest import pytest
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
from nanobot.config.repository import ConfigConflictError, FileConfigRepository from nanobot.config.repository import ConfigConflictError, FileConfigRepository
from nanobot.security.network import configure_ssrf_whitelist, validate_url_target from nanobot.security.network import configure_ssrf_whitelist, validate_url_target
@ -26,6 +26,16 @@ def _fake_resolve(host: str, results: list[str]):
return _resolver return _resolver
def test_public_api_exposes_explicit_loaders_without_legacy_alias() -> None:
import nanobot.config as config_api
from nanobot.config import loader
assert config_api.load_raw_config is loader.load_raw_config
assert config_api.load_effective_config is loader.load_effective_config
assert not hasattr(config_api, "load_config")
assert not hasattr(loader, "load_config")
def test_raw_and_effective_snapshots_keep_secret_templates_separate( def test_raw_and_effective_snapshots_keep_secret_templates_separate(
tmp_path: Path, tmp_path: Path,
monkeypatch, monkeypatch,
@ -213,7 +223,7 @@ def test_loading_config_does_not_change_process_network_policy(tmp_path: Path) -
path.write_text(json.dumps({"tools": {"ssrfWhitelist": []}}), encoding="utf-8") path.write_text(json.dumps({"tools": {"ssrfWhitelist": []}}), encoding="utf-8")
configure_ssrf_whitelist(["100.64.0.0/10"]) configure_ssrf_whitelist(["100.64.0.0/10"])
try: try:
load_config(path) load_raw_config(path)
with patch( with patch(
"nanobot.security.network.socket.getaddrinfo", "nanobot.security.network.socket.getaddrinfo",

View File

@ -120,7 +120,7 @@ def test_from_config_rejects_multiple_model_selectors(tmp_path):
def test_from_config_default_path(): def test_from_config_default_path():
from nanobot.config.schema import Config from nanobot.config.schema import Config
with patch("nanobot.config.loader.load_config") as mock_load, \ with patch("nanobot.config.loader.load_effective_config") as mock_load, \
patch("nanobot.providers.factory.make_provider") as mock_prov: patch("nanobot.providers.factory.make_provider") as mock_prov:
mock_load.return_value = Config() mock_load.return_value = Config()
mock_prov.return_value = MagicMock() mock_prov.return_value = MagicMock()

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import httpx import httpx
import pytest import pytest
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_raw_config, save_config
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.webui import channel_validation from nanobot.webui import channel_validation
from nanobot.webui.channel_validation import validate_channel_config from nanobot.webui.channel_validation import validate_channel_config
@ -35,7 +35,7 @@ def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.Mo
) )
assert payload["status"] == "connected" assert payload["status"] == "connected"
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.channels.slack["appToken"] == "xapp-old" assert saved.channels.slack["appToken"] == "xapp-old"
assert saved.channels.slack["botToken"] == "xoxb-old" assert saved.channels.slack["botToken"] == "xoxb-old"
@ -101,7 +101,7 @@ def test_validate_email_presets_are_checked_without_saving(
assert payload["status"] == "connected" assert payload["status"] == "connected"
assert payload["can_enable"] is True assert payload["can_enable"] is True
assert not hasattr(load_config(config_path).channels, "email") assert not hasattr(load_raw_config(config_path).channels, "email")
def test_validate_email_blocks_private_targets_when_local_access_is_disabled( def test_validate_email_blocks_private_targets_when_local_access_is_disabled(

View File

@ -4,7 +4,7 @@ import asyncio
import pytest import pytest
from nanobot.config.loader import load_config from nanobot.config.loader import load_raw_config
from nanobot.webui.mcp_presets_api import ( from nanobot.webui.mcp_presets_api import (
McpPresetError, McpPresetError,
custom_mcp_action, custom_mcp_action,
@ -76,7 +76,7 @@ def test_enable_browserbase_writes_scrubbed_config_payload(
assert preset["installed"] is True assert preset["installed"] is True
assert preset["configured"] is True assert preset["configured"] is True
assert "bb_live_secret" not in str(payload) assert "bb_live_secret" not in str(payload)
config = load_config() config = load_raw_config()
assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
@ -107,7 +107,7 @@ def test_enable_context7_optional_api_key_appends_arg(
assert "ctx7_secret" not in str(payload) assert "ctx7_secret" not in str(payload)
row = next(item for item in payload["presets"] if item["name"] == "context7") row = next(item for item in payload["presets"] if item["name"] == "context7")
assert row["configured"] is True assert row["configured"] is True
config = load_config() config = load_raw_config()
assert config.tools.mcp_servers["context7"].args == [ assert config.tools.mcp_servers["context7"].args == [
"-y", "-y",
"@upstash/context7-mcp@latest", "@upstash/context7-mcp@latest",
@ -124,7 +124,7 @@ def test_enable_stdio_preset_uses_config_scoped_cwd(
mcp_presets_action("enable", {"name": ["playwright"]}) mcp_presets_action("enable", {"name": ["playwright"]})
config = load_config() config = load_raw_config()
cwd = config.tools.mcp_servers["playwright"].cwd cwd = config.tools.mcp_servers["playwright"].cwd
assert cwd == str(tmp_path / "mcp" / "playwright") assert cwd == str(tmp_path / "mcp" / "playwright")
assert (tmp_path / "mcp" / "playwright").is_dir() assert (tmp_path / "mcp" / "playwright").is_dir()
@ -137,7 +137,7 @@ def test_enable_no_auth_remote_presets_write_url(tmp_path, monkeypatch: pytest.M
mcp_presets_action("enable", {"name": ["exa"]}) mcp_presets_action("enable", {"name": ["exa"]})
mcp_presets_action("enable", {"name": ["firecrawl"]}) mcp_presets_action("enable", {"name": ["firecrawl"]})
config = load_config() config = load_raw_config()
assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp" assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp" assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp" assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp"
@ -154,7 +154,7 @@ def test_firecrawl_preset_is_keyless(tmp_path, monkeypatch: pytest.MonkeyPatch)
assert row["required_fields"] == [] assert row["required_fields"] == []
assert row["configured"] is True assert row["configured"] is True
assert "Keyless" in row["note"] assert "Keyless" in row["note"]
config = load_config() config = load_raw_config()
assert config.tools.mcp_servers["firecrawl"].type == "streamableHttp" assert config.tools.mcp_servers["firecrawl"].type == "streamableHttp"
assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp" assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp"
assert config.tools.mcp_servers["firecrawl"].env == {} assert config.tools.mcp_servers["firecrawl"].env == {}
@ -173,7 +173,7 @@ def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPa
assert payload["last_action"]["removed"] is True assert payload["last_action"]["removed"] is True
assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"] assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"]
assert not managed_cwd.exists() assert not managed_cwd.exists()
config = load_config() config = load_raw_config()
assert "playwright" not in config.tools.mcp_servers assert "playwright" not in config.tools.mcp_servers
@ -196,7 +196,7 @@ def test_remove_custom_mcp_server_preserves_user_cwd(tmp_path, monkeypatch: pyte
assert payload["last_action"]["ok"] is True assert payload["last_action"]["ok"] is True
assert user_cwd.exists() assert user_cwd.exists()
config = load_config() config = load_raw_config()
assert "internal-docs" not in config.tools.mcp_servers assert "internal-docs" not in config.tools.mcp_servers
@ -330,7 +330,7 @@ def test_custom_mcp_server_writes_config_and_catalog_row(
assert row["manifest"]["capabilities"][0]["command"] == "node" assert row["manifest"]["capabilities"][0]["command"] == "node"
assert "server.js" not in str(row["manifest"]) assert "server.js" not in str(row["manifest"])
assert "docs-secret-value" not in str(payload) assert "docs-secret-value" not in str(payload)
config = load_config() config = load_raw_config()
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"] assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value" assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
@ -356,7 +356,7 @@ def test_import_mcp_config_and_tool_allowlist(
) )
assert payload["last_action"]["message"] == "Imported 2 MCP server(s)." assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
config = load_config() config = load_raw_config()
assert config.tools.mcp_servers["docs"].command == "npx" assert config.tools.mcp_servers["docs"].command == "npx"
assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"] assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
assert config.tools.mcp_servers["remote-docs"].type == "sse" assert config.tools.mcp_servers["remote-docs"].type == "sse"
@ -374,7 +374,7 @@ def test_import_mcp_config_and_tool_allowlist(
row = next(item for item in payload["presets"] if item["name"] == "docs") row = next(item for item in payload["presets"] if item["name"] == "docs")
assert row["enabled_tools"] == ["mcp_docs_search"] assert row["enabled_tools"] == ["mcp_docs_search"]
assert load_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"] assert load_raw_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
payload = custom_mcp_action( payload = custom_mcp_action(
"tools", "tools",
@ -386,7 +386,7 @@ def test_import_mcp_config_and_tool_allowlist(
row = next(item for item in payload["presets"] if item["name"] == "docs") row = next(item for item in payload["presets"] if item["name"] == "docs")
assert row["enabled_tools"] == [] assert row["enabled_tools"] == []
assert load_config().tools.mcp_servers["docs"].enabled_tools == [] assert load_raw_config().tools.mcp_servers["docs"].enabled_tools == []
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server( def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(

View File

@ -7,7 +7,7 @@ from types import SimpleNamespace
import httpx import httpx
import pytest import pytest
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_raw_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
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 (
@ -95,7 +95,7 @@ def test_update_api_settings_requires_key_for_network_access(
"port": ["9900"], "port": ["9900"],
"api_key": ["secret-token"], "api_key": ["secret-token"],
}) })
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.api.host == "0.0.0.0" assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900 assert saved.api.port == 9900
assert saved.api.api_key == "secret-token" assert saved.api.api_key == "secret-token"
@ -124,7 +124,7 @@ def test_update_api_settings_allows_alternate_loopback_without_key(
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]}) update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
assert load_config(config_path).api.host == "127.0.0.2" assert load_raw_config(config_path).api.host == "127.0.0.2"
def _dynamic_provider_config( def _dynamic_provider_config(
@ -174,7 +174,7 @@ def test_create_model_configuration_writes_label_and_selects(
rows = {row["name"]: row for row in payload["model_presets"]} rows = {row["name"]: row for row in payload["model_presets"]}
assert rows["fast-writing"]["label"] == "Fast writing" assert rows["fast-writing"]["label"] == "Fast writing"
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.agents.defaults.model_preset == "fast-writing" assert saved.agents.defaults.model_preset == "fast-writing"
assert saved.model_presets["fast-writing"].label == "Fast writing" assert saved.model_presets["fast-writing"].label == "Fast writing"
assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini" assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini"
@ -209,7 +209,7 @@ def test_create_model_configuration_accepts_dynamic_custom_provider(
assert payload["agent"]["model_preset"] == "tenant-model" assert payload["agent"]["model_preset"] == "tenant-model"
assert payload["agent"]["provider"] == DYNAMIC_PROVIDER_NAME assert payload["agent"]["provider"] == DYNAMIC_PROVIDER_NAME
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.model_presets["tenant-model"].provider == DYNAMIC_PROVIDER_NAME assert saved.model_presets["tenant-model"].provider == DYNAMIC_PROVIDER_NAME
assert saved.model_presets["tenant-model"].model == "gpt-4o-mini" assert saved.model_presets["tenant-model"].model == "gpt-4o-mini"
@ -292,7 +292,7 @@ def test_update_model_configuration_edits_named_preset_and_selects(
assert payload["agent"]["model_preset"] == "codex" assert payload["agent"]["model_preset"] == "codex"
assert payload["agent"]["model"] == "openai-codex/gpt-5.5" assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.agents.defaults.model_preset == "codex" assert saved.agents.defaults.model_preset == "codex"
assert saved.model_presets["codex"].label == "Codex" assert saved.model_presets["codex"].label == "Codex"
assert saved.model_presets["codex"].provider == "openai_codex" assert saved.model_presets["codex"].provider == "openai_codex"
@ -318,7 +318,7 @@ def test_update_provider_settings_updates_dynamic_custom_provider(
providers = {row["name"]: row for row in payload["providers"]} providers = {row["name"]: row for row in payload["providers"]}
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == "https://new.example/v1" assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == "https://new.example/v1"
assert providers[DYNAMIC_PROVIDER_NAME]["api_key_hint"] == "••••" assert providers[DYNAMIC_PROVIDER_NAME]["api_key_hint"] == "••••"
saved = load_config(config_path) saved = load_raw_config(config_path)
dynamic_provider = saved.providers.model_extra[DYNAMIC_PROVIDER_NAME] dynamic_provider = saved.providers.model_extra[DYNAMIC_PROVIDER_NAME]
assert dynamic_provider.api_base == "https://new.example/v1" assert dynamic_provider.api_base == "https://new.example/v1"
assert dynamic_provider.api_key == "sk-test" assert dynamic_provider.api_key == "sk-test"
@ -336,7 +336,7 @@ def test_update_agent_settings_accepts_context_window_options(
payload = update_agent_settings({"context_window_tokens": ["200000"]}) payload = update_agent_settings({"context_window_tokens": ["200000"]})
assert payload["agent"]["context_window_tokens"] == 200000 assert payload["agent"]["context_window_tokens"] == 200000
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.agents.defaults.context_window_tokens == 200000 assert saved.agents.defaults.context_window_tokens == 200000
@ -362,7 +362,7 @@ def test_update_model_configuration_accepts_context_window_options(
) )
assert payload["agent"]["context_window_tokens"] == 262144 assert payload["agent"]["context_window_tokens"] == 262144
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.model_presets["codex"].context_window_tokens == 262144 assert saved.model_presets["codex"].context_window_tokens == 262144
@ -555,7 +555,7 @@ def test_update_web_search_settings_accepts_keenable_without_api_key(
payload = update_web_search_settings({"provider": ["keenable"]}) payload = update_web_search_settings({"provider": ["keenable"]})
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.tools.web.search.provider == "keenable" assert saved.tools.web.search.provider == "keenable"
assert saved.tools.web.search.api_key == "" assert saved.tools.web.search.api_key == ""
option = next(item for item in payload["web_search"]["providers"] if item["name"] == "keenable") option = next(item for item in payload["web_search"]["providers"] if item["name"] == "keenable")
@ -575,7 +575,7 @@ def test_update_web_search_settings_can_clear_optional_api_key(
update_web_search_settings({"provider": ["keenable"], "api_key": [""]}) update_web_search_settings({"provider": ["keenable"], "api_key": [""]})
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.tools.web.search.provider == "keenable" assert saved.tools.web.search.provider == "keenable"
assert saved.tools.web.search.api_key == "" assert saved.tools.web.search.api_key == ""
@ -720,7 +720,7 @@ def test_update_transcription_settings_writes_top_level_only(
} }
) )
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.channels.transcription_provider == "openai" assert saved.channels.transcription_provider == "openai"
assert saved.channels.transcription_language == "en" assert saved.channels.transcription_language == "en"
assert saved.transcription.enabled is True assert saved.transcription.enabled is True
@ -750,7 +750,7 @@ def test_update_transcription_settings_accepts_openrouter(
} }
) )
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.transcription.provider == "openrouter" assert saved.transcription.provider == "openrouter"
assert saved.transcription.model == "nvidia/parakeet-tdt-0.6b-v3" assert saved.transcription.model == "nvidia/parakeet-tdt-0.6b-v3"
assert payload["transcription"]["provider"] == "openrouter" assert payload["transcription"]["provider"] == "openrouter"
@ -775,7 +775,7 @@ def test_update_transcription_settings_accepts_xiaomi_mimo(
} }
) )
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.transcription.provider == "xiaomi_mimo" assert saved.transcription.provider == "xiaomi_mimo"
assert saved.transcription.model == "mimo-v2.5-asr" assert saved.transcription.model == "mimo-v2.5-asr"
assert saved.transcription.language == "zh" assert saved.transcription.language == "zh"
@ -800,7 +800,7 @@ def test_update_transcription_settings_accepts_assemblyai(
} }
) )
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.transcription.provider == "assemblyai" assert saved.transcription.provider == "assemblyai"
assert saved.transcription.model == "universal-3-pro" assert saved.transcription.model == "universal-3-pro"
assert payload["transcription"]["provider"] == "assemblyai" assert payload["transcription"]["provider"] == "assemblyai"
@ -881,7 +881,7 @@ def test_update_network_safety_settings_writes_local_service_flag(
} }
) )
saved = load_config(config_path) saved = load_raw_config(config_path)
saved_raw = json.loads(config_path.read_text(encoding="utf-8")) saved_raw = json.loads(config_path.read_text(encoding="utf-8"))
assert saved.tools.webui_allow_local_service_access is False assert saved.tools.webui_allow_local_service_access is False
assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False
@ -917,7 +917,7 @@ def test_update_network_safety_settings_default_access_is_webui_only(
payload = update_network_safety_settings({"webui_default_access_mode": ["full"]}) payload = update_network_safety_settings({"webui_default_access_mode": ["full"]})
saved = load_config(config_path) saved = load_raw_config(config_path)
assert config_path.read_text(encoding="utf-8") == before assert config_path.read_text(encoding="utf-8") == before
assert saved.tools.restrict_to_workspace is False assert saved.tools.restrict_to_workspace is False
assert payload["advanced"]["webui_default_access_mode"] == "full" assert payload["advanced"]["webui_default_access_mode"] == "full"
@ -1225,7 +1225,7 @@ def test_create_model_configuration_accepts_configured_oauth_provider(
) )
assert payload["agent"]["model_preset"] == "codex" assert payload["agent"]["model_preset"] == "codex"
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.model_presets["codex"].provider == "openai_codex" assert saved.model_presets["codex"].provider == "openai_codex"
@ -1312,7 +1312,7 @@ def test_create_model_configuration_accepts_azure_openai_aad_mode(
) )
assert payload["agent"]["model_preset"] == "azure-aad" assert payload["agent"]["model_preset"] == "azure-aad"
saved = load_config(config_path) saved = load_raw_config(config_path)
assert saved.model_presets["azure-aad"].provider == "azure_openai" assert saved.model_presets["azure-aad"].provider == "azure_openai"
assert saved.model_presets["azure-aad"].model == "my-deployment" assert saved.model_presets["azure-aad"].model == "my-deployment"