diff --git a/.agent/design.md b/.agent/design.md index 207fcd97a..9ff2408cd 100644 --- a/.agent/design.md +++ b/.agent/design.md @@ -30,6 +30,6 @@ Configuration must be declared explicitly in `config/schema.py` Pydantic models. ## 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. diff --git a/.agent/gotchas.md b/.agent/gotchas.md index b78dd9e48..5dad6d693 100644 --- a/.agent/gotchas.md +++ b/.agent/gotchas.md @@ -6,7 +6,7 @@ ## 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: ```json diff --git a/docs/configuration.md b/docs/configuration.md index b347e8e2e..e1c345b97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,6 +13,46 @@ For setup and runtime failures, follow the diagnosis order in [`troubleshooting. > [!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. +## 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 This page is the complete configuration reference. For task-oriented setup, use diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 7919b9adf..8f72e5754 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -1162,9 +1162,9 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]: "requires_restart": True, } 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) except Exception as exc: logger.warning("MCP hot reload could not read config: {}", exc) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index bcae4bbdc..afe4afcce 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -315,8 +315,9 @@ class WebSearchTool(Tool): config_loader = None if ctx.provider_snapshot_loader is not None: def config_loader(): - from nanobot.config.loader import load_config, resolve_config_env_vars - return resolve_config_env_vars(load_config()).tools.web.search + from nanobot.config.loader import load_effective_config + + return load_effective_config().tools.web.search return cls( config=ctx.config.web.search, proxy=ctx.config.web.proxy, diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index ce0fe5708..240ed79b8 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -52,9 +52,9 @@ class BaseChannel(ABC): resolve_transcription_config, 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: self.logger.exception("Audio transcription failed") return "" diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4f24cd781..26990b92e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -740,9 +740,9 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool: if not FEISHU_AVAILABLE: 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) defaults = FeishuChannel.default_config() specs = feishu_instance_specs(feishu_cfg, defaults) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 304101c80..7c9ed40ea 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -364,9 +364,9 @@ class ChannelManager: "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) if action == "disable": runtime_names = [name if not instance_id else f"{name}.{instance_id}"] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4ae739282..ad18831d1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -640,7 +640,7 @@ def onboard( non_interactive_refresh: bool = typer.Option(False, "--refresh", help="Refresh config, preserving existing settings without prompting"), ): """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 if config: @@ -658,7 +658,7 @@ def onboard( # Create or update config if config_path.exists(): if wizard: - config = _apply_workspace_override(load_config(config_path)) + config = _apply_workspace_override(load_raw_config(config_path)) else: should_refresh = non_interactive_refresh if not non_interactive_refresh: @@ -677,7 +677,7 @@ def onboard( should_refresh = True 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) console.print( f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)" @@ -861,7 +861,7 @@ def _load_inspection_config( workspace: str | None = None, ) -> tuple[Path, Config]: """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 if config: @@ -871,7 +871,7 @@ def _load_inspection_config( display_path = config_path or get_config_path() try: - loaded = load_config(config_path) + loaded = load_raw_config(config_path) except ValueError as exc: console.print(f"[red]Error: {exc}[/red]") 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: """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: - return load_config(config_path) + return load_raw_config(config_path) except ValueError as e: console.print(f"[red]Error: {e}[/red]") 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: """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 try: @@ -2520,7 +2520,7 @@ def plugins_list( ): """List optional nanobot features.""" 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 if resolved_config_path is not None: @@ -2530,7 +2530,7 @@ def plugins_list( feature_support.optional_dependency_groups(), set(discover_channel_names()), discover_plugins(), - load_config(resolved_config_path), + load_raw_config(resolved_config_path), ) @@ -2767,11 +2767,11 @@ def _login_openai_codex() -> None: try: 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 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: console.print(f"[red]{e}[/red]") raise typer.Exit(1) from e diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 53431471d..17d98caf9 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -22,7 +22,7 @@ from nanobot.cli.models import ( get_model_context_limit, 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 console = Console() @@ -1940,7 +1940,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult: else: config_path = get_config_path() if config_path.exists(): - base_config = load_config() + base_config = load_raw_config() else: base_config = Config() diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index c8406fc11..65e7afbc3 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -4,8 +4,8 @@ from nanobot.config.loader import ( apply_config_runtime_policies, get_config_path, get_config_repository, - load_config, load_effective_config, + load_raw_config, update_config, ) from nanobot.config.paths import ( @@ -37,8 +37,8 @@ __all__ = [ "FileConfigRepository", "PersistedConfigSnapshot", "apply_config_runtime_policies", - "load_config", "load_effective_config", + "load_raw_config", "update_config", "get_config_path", "get_config_repository", diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 529bf0e83..565e72a33 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -1,4 +1,4 @@ -"""Compatibility helpers for configuration loading and persistence.""" +"""Configuration loading and persistence entry points.""" from __future__ import annotations @@ -6,28 +6,18 @@ from collections.abc import Callable from pathlib import Path from typing import Any -from loguru import logger as logger # compatibility: callers patch loader.logger - from nanobot.config.repository import ( ConfigCommit, 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 -# Legacy default-instance path. Runtime code should prefer an explicitly scoped -# FileConfigRepository; these helpers remain for CLI and plugin compatibility. +# Default path for process entry points that do not receive an explicit path. _current_config_path: Path | None = 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 _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()) -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.""" return get_config_repository(config_path).load_raw().config def load_effective_config(config_path: Path | None = None) -> Config: - """Load a fresh runtime config with environment references resolved. - - 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)) + """Load a fresh runtime config with environment references resolved.""" + return get_config_repository(config_path).load_effective().config 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__ = [ - "FileConfigRepository", "apply_config_runtime_policies", "get_config_path", "get_config_repository", - "load_config", + "load_raw_config", "load_effective_config", "merge_missing_defaults", - "resolve_config_env_vars", "save_config", "set_config_path", "update_config", diff --git a/nanobot/config/repository.py b/nanobot/config/repository.py index bbaa5e7bd..ac7382dd6 100644 --- a/nanobot/config/repository.py +++ b/nanobot/config/repository.py @@ -253,17 +253,6 @@ def _resolve_in_place(obj: Any) -> Any: 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: name = match.group(1) value = os.environ.get(name) diff --git a/nanobot/optional_features.py b/nanobot/optional_features.py index 54fb46bba..79c70061e 100644 --- a/nanobot/optional_features.py +++ b/nanobot/optional_features.py @@ -440,16 +440,16 @@ def optional_features_payload( last_action: dict[str, Any] | None = None, ) -> dict[str, Any]: 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 = config or load_config() + config = config or load_raw_config() if not config_provided: with suppress(Exception): from nanobot.channels.feishu import refresh_saved_feishu_identities if refresh_saved_feishu_identities(config): - config = load_config() + config = load_raw_config() extras = optional_dependency_groups() builtin_channels = set(discover_channel_names()) plugin_channels = discover_plugins() diff --git a/nanobot/providers/factory.py b/nanobot/providers/factory.py index 8e2543d9f..6c2c829ca 100644 --- a/nanobot/providers/factory.py +++ b/nanobot/providers/factory.py @@ -278,9 +278,9 @@ def load_provider_snapshot( *, preset_name: str | None = None, ) -> ProviderSnapshot: - from nanobot.config.loader import load_config, resolve_config_env_vars + from nanobot.config.loader import load_effective_config return build_provider_snapshot( - resolve_config_env_vars(load_config(config_path)), + load_effective_config(config_path), preset_name=preset_name, ) diff --git a/nanobot/webui/channel_connect.py b/nanobot/webui/channel_connect.py index 10c3eea35..e725a04f4 100644 --- a/nanobot/webui/channel_connect.py +++ b/nanobot/webui/channel_connect.py @@ -13,7 +13,7 @@ import httpx from nanobot.channels import feishu 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): @@ -394,7 +394,7 @@ class WeixinConnectStore: from nanobot.bus.queue import MessageBus 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"): config = section.model_dump(mode="json", by_alias=True) elif isinstance(section, dict): diff --git a/nanobot/webui/channel_validation.py b/nanobot/webui/channel_validation.py index 0358d0677..d36e2b17b 100644 --- a/nanobot/webui/channel_validation.py +++ b/nanobot/webui/channel_validation.py @@ -16,7 +16,7 @@ from typing import Any import httpx 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 CheckStatus = str @@ -42,7 +42,7 @@ def validate_channel_config( if not channel: return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")]) - config = load_config() + config = load_raw_config() section = getattr(config.channels, channel, None) values = _channel_config(channel, section, instance_id=instance_id) values = _merge_form_values(channel, values, raw_values or {}) diff --git a/nanobot/webui/cli_apps_api.py b/nanobot/webui/cli_apps_api.py index d9eeb8cc0..317511d24 100644 --- a/nanobot/webui/cli_apps_api.py +++ b/nanobot/webui/cli_apps_api.py @@ -8,7 +8,7 @@ import time from typing import Any 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]] @@ -85,7 +85,7 @@ def _query_first(query: QueryParams, key: str) -> str | None: def _manager() -> CliAppManager: - config = load_config() + config = load_raw_config() cli_cfg = config.tools.cli_apps return CliAppManager( workspace=config.workspace_path, diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 608fd8781..998b33e13 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -18,7 +18,7 @@ from typing import Any, Literal, Mapping from nanobot.agent.tools.registry import ToolRegistry 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.schema import Config, MCPServerConfig from nanobot.utils.helpers import ensure_dir @@ -416,7 +416,7 @@ def _known_preset_names() -> set[str]: def _known_mcp_names() -> set[str]: names = _known_preset_names() with suppress(Exception): - names.update(load_config().tools.mcp_servers) + names.update(load_raw_config().tools.mcp_servers) return names @@ -822,7 +822,7 @@ def mcp_presets_payload( last_action: dict[str, Any] | None = None, tool_preview: Mapping[str, list[str]] | None = None, ) -> dict[str, Any]: - config = load_config() + config = load_raw_config() known = _known_preset_names() preset_rows = [ _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) try: - config = resolve_config_env_vars(load_config()) + config = load_effective_config() except ValueError as exc: return mcp_presets_payload(last_action={ "ok": False, diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 594adf0d7..6f96bbd4b 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -24,8 +24,8 @@ from nanobot.audio.transcription_registry import ( ) from nanobot.config.loader import ( get_config_path, - load_config, - resolve_config_env_vars, + load_effective_config, + load_raw_config, update_config, ) 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: raise WebUISettingsError("provider is required") - config = load_config() + config = load_raw_config() resolved_provider = _resolve_settings_provider(config, provider_name) if resolved_provider is None: raise WebUISettingsError("unknown provider") @@ -754,7 +754,7 @@ def settings_payload( restart_required_sections: list[str] | None = None, apply_state: dict[str, Any] | None = None, ) -> dict[str, Any]: - config = load_config() + config = load_raw_config() defaults = config.agents.defaults active_preset_name = defaults.model_preset or "default" try: @@ -961,7 +961,7 @@ def settings_payload( def settings_usage_payload() -> dict[str, Any]: """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) @@ -1188,7 +1188,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: ) from None 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: raise WebUISettingsError(str(e), status=400) from e token = None diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 3f6dfe8cf..46da6f3e3 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -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.bus.queue import MessageBus 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 ( OptionalFeatureError, extra_installed, @@ -397,7 +397,7 @@ class WebUISettingsRouter: allow_install=self._allow_feature_package_install(connection, request), ) update_api_settings(self._parse_api_service_settings_query(request)) - config = load_config() + config = load_raw_config() runtime = self._api_runtime() options = ApiStartOptions( host=config.api.host, @@ -465,7 +465,7 @@ class WebUISettingsRouter: return ApiRuntime(paths=api_runtime_paths(config_path)) 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() extras = optional_dependency_groups() 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): return True try: - return bool(load_config().tools.webui_allow_remote_package_install) + return bool(load_raw_config().tools.webui_allow_remote_package_install) except Exception: self.logger.exception("failed to load remote package install policy") return False diff --git a/nanobot/webui/transcription_ws.py b/nanobot/webui/transcription_ws.py index 8404206e1..8e0f6010f 100644 --- a/nanobot/webui/transcription_ws.py +++ b/nanobot/webui/transcription_ws.py @@ -13,7 +13,7 @@ from nanobot.audio.transcription import ( resolve_transcription_config, transcribe_audio_data_url, ) -from nanobot.config.loader import load_config +from nanobot.config.loader import load_raw_config _MAX_REQUEST_ID_LENGTH = 80 @@ -38,7 +38,7 @@ async def webui_transcription_event(envelope: dict[str, Any]) -> tuple[str, dict try: text = await transcribe_audio_data_url( envelope.get("data_url"), - resolve_transcription_config(load_config()), + resolve_transcription_config(load_raw_config()), duration_ms=envelope.get("duration_ms"), ) except TranscriptionIngressError as exc: diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 664108a22..f253aad77 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -107,8 +107,8 @@ def _decode_api_key(raw_key: str) -> str | None: def _default_model_name_from_config() -> str | None: try: - from nanobot.config.loader import load_config - model = load_config().resolve_preset().model.strip() + from nanobot.config.loader import load_raw_config + model = load_raw_config().resolve_preset().model.strip() return model or None except Exception as e: logger.debug("bootstrap model_name could not load from config: {}", e) diff --git a/tests/agent/test_mcp_connection.py b/tests/agent/test_mcp_connection.py index f5402e849..85c1b865d 100644 --- a/tests/agent/test_mcp_connection.py +++ b/tests/agent/test_mcp_connection.py @@ -20,7 +20,7 @@ from nanobot.agent.tools import mcp as mcp_runtime from nanobot.agent.tools.base import Tool from nanobot.agent.tools.mcp import MCPResourceWrapper, MCPToolWrapper 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 @@ -201,7 +201,7 @@ async def test_reload_mcp_servers_adds_and_removes_tools_without_restart( ): config_path = tmp_path / "config.json" monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - config = load_config() + config = load_raw_config() config.tools.mcp_servers["browserbase"] = MCPServerConfig( type="stdio", 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 "browserbase" in loop._mcp_stacks - config = load_config() + config = load_raw_config() del config.tools.mcp_servers["browserbase"] save_config(config) @@ -253,7 +253,7 @@ async def test_request_mcp_reload_reaches_runtime_control_without_restart( ): config_path = tmp_path / "config.json" monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - config = load_config() + config = load_raw_config() config.tools.mcp_servers["browserbase"] = MCPServerConfig( type="stdio", 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 loop.tools.has("mcp_browserbase_navigate") - config = load_config() + config = load_raw_config() del config.tools.mcp_servers["browserbase"] 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" monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - config = load_config() + config = load_raw_config() config.tools.mcp_servers["browserbase"] = MCPServerConfig( type="stdio", command="browserbase-mcp", diff --git a/tests/channels/test_channel_manager_hot_reload.py b/tests/channels/test_channel_manager_hot_reload.py index 946ce9970..804cf3c70 100644 --- a/tests/channels/test_channel_manager_hot_reload.py +++ b/tests/channels/test_channel_manager_hot_reload.py @@ -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_plugins", lambda enabled_names=None: {}) 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._started = True @@ -95,7 +95,7 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f "discover_enabled", 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()) old_channel = manager.channels["hot"] diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index f2d646661..15febb105 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -634,7 +634,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch): seen["config"] = self.config 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( "nanobot.channels.registry.discover_all", 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: 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( "nanobot.config.loader.set_config_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] = {} 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( "nanobot.config.loader.set_config_path", lambda path: seen.__setitem__("config_path", path), @@ -707,7 +707,7 @@ def test_plugins_list_shows_available_features(monkeypatch): runner = CliRunner() 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_plugins", lambda: {}) monkeypatch.setattr( diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index c5ec0a841..4a7cddcff 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -32,7 +32,7 @@ from nanobot.channels.websocket import ( _parse_inbound_payload, 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.session import webui_turns as wth 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 - saved = load_config(config_path) + saved = load_raw_config(config_path) assert saved.agents.defaults.model == "atomic_chat/test" assert saved.agents.defaults.provider == "atomic_chat" 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"] - 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_type == "auto" diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index dc00af5f6..4d450bd42 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -1124,7 +1124,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads( ) -> dict[str, Any]: assert action == "enable" assert query == {"name": ["discord"]} - cfg = loader.load_config() + cfg = loader.load_raw_config() section = dict(getattr(cfg.channels, "discord", {}) or {}) section["enabled"] = True 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]: calls.append((action, name)) - cfg = loader.load_config() + cfg = loader.load_raw_config() assert getattr(cfg.channels, "discord")["token"] == "discord-token" return { "handled": True, diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 0ec0b5824..912509055 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -224,7 +224,7 @@ def mock_paths(): """Mock config/workspace paths for test isolation.""" with patch("nanobot.config.loader.get_config_path") as mock_cp, \ 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: base_dir = Path("./test_onboard_data") 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): proxy = "http://127.0.0.1:23458" monkeypatch.setattr( - "nanobot.config.loader.load_config", + "nanobot.config.loader.load_effective_config", 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 -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" - monkeypatch.setenv("CODEX_PROXY_FOR_TEST", proxy) monkeypatch.setattr( - "nanobot.config.loader.load_config", + "nanobot.config.loader.load_effective_config", 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.agents.defaults.workspace = str(tmp_path / "default-workspace") - with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ - patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \ + with patch( + "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.providers.factory.make_provider", return_value=_fake_provider()), \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ @@ -1296,7 +1296,7 @@ def mock_agent_runtime(tmp_path): yield { "config": config, - "load_config": mock_load_config, + "load_effective_config": mock_load_effective_config, "sync_templates": mock_sync_templates, "from_config": mock_from_config, "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"]) 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 == ( 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)]) 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: @@ -1353,7 +1355,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: "nanobot.config.loader.set_config_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.providers.factory.make_provider", lambda _config: _fake_provider()) 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] = {} 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.providers.factory.make_provider", lambda _config: _fake_provider()) 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] = {} 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.providers.factory.make_provider", lambda _config: _fake_provider()) 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] = {} 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.providers.factory.make_provider", lambda _config: _fake_provider()) 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 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["sync_templates"].call_args.args == (workspace_path,) 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", set_config_path or (lambda _path: None), ) - monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.config.loader.resolve_config_env_vars", lambda c: c) + monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config) monkeypatch.setattr( "nanobot.cli.commands.sync_workspace_templates", sync_templates or (lambda _path: None), @@ -2194,7 +2197,7 @@ def test_gateway_unbound_agent_cron_is_skipped( seen: dict[str, object] = {} 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.providers.factory.make_provider", lambda _config: provider) _patch_gateway_ports_free(monkeypatch) @@ -2321,7 +2324,7 @@ def test_gateway_bound_cron_runs_as_session_turn( seen: dict[str, object] = {"run_records": []} 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.providers.factory.make_provider", lambda _config: provider) _patch_gateway_ports_free(monkeypatch) diff --git a/tests/config/test_config_load_errors.py b/tests/config/test_config_load_errors.py index 73719e7bc..a32240f10 100644 --- a/tests/config/test_config_load_errors.py +++ b/tests/config/test_config_load_errors.py @@ -2,12 +2,12 @@ import json import pytest -from nanobot.config.loader import load_config +from nanobot.config.loader import load_raw_config from nanobot.config.schema import ApiConfig 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 @@ -17,7 +17,7 @@ def test_load_config_invalid_json_fails_fast(tmp_path) -> None: config_path.write_text("{broken json", encoding="utf-8") 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: @@ -25,7 +25,7 @@ def test_load_config_rejects_non_object_root(tmp_path) -> None: config_path.write_text("[]", encoding="utf-8") 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: @@ -36,7 +36,7 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None: ) 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", "::"]) diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index a5b7b7066..4c2ff501c 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -4,7 +4,7 @@ from unittest.mock import patch 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 @@ -33,7 +33,7 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path) 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.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", ) - config = load_config(config_path) + config = load_raw_config(config_path) save_config(config, config_path) saved = json.loads(config_path.read_text(encoding="utf-8")) 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", ) - with patch("nanobot.config.loader.logger.warning") as warning: - config = load_config(config_path) + with patch("nanobot.config.repository.logger.warning") as warning: + config = load_raw_config(config_path) assert config.agents.defaults.max_tokens == 1234 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", ) - with patch("nanobot.config.loader.logger.warning"): - config = load_config(config_path) + with patch("nanobot.config.repository.logger.warning"): + config = load_raw_config(config_path) save_config(config, config_path) 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", ) - config = load_config(config_path) + config = load_raw_config(config_path) assert config.tools.my.enable is False 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", ) - config = load_config(config_path) + config = load_raw_config(config_path) save_config(config, config_path) 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", ) - config = load_config(config_path) + config = load_raw_config(config_path) assert config.tools.my.enable 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.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"])): ok, err = validate_url_target("http://ts.local/api") 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"])): ok, _ = validate_url_target("http://ts.local/api") 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.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 @@ -280,7 +280,7 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None: encoding="utf-8", ) - config = load_config(config_path) + config = load_raw_config(config_path) 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.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 @@ -306,5 +306,5 @@ def test_load_config_accepts_remote_package_install_aliases(tmp_path) -> None: encoding="utf-8", ) - assert load_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(camel_path).tools.webui_allow_remote_package_install is True + assert load_raw_config(snake_path).tools.webui_allow_remote_package_install is True diff --git a/tests/config/test_env_interpolation.py b/tests/config/test_env_interpolation.py index 329010cc1..9c026474a 100644 --- a/tests/config/test_env_interpolation.py +++ b/tests/config/test_env_interpolation.py @@ -2,54 +2,31 @@ import json import pytest -from nanobot.config.loader import ( - _resolve_env_vars, - load_config, - resolve_config_env_vars, - save_config, -) +from nanobot.config.loader import load_raw_config, save_config +from nanobot.config.repository import resolve_config_env_vars from nanobot.config.schema import Config -class TestResolveEnvVars: - def test_replaces_string_value(self, monkeypatch): - monkeypatch.setenv("MY_SECRET", "hunter2") - assert _resolve_env_vars("${MY_SECRET}") == "hunter2" +class TestResolveConfig: + def test_resolves_multiple_refs_inside_a_value(self, monkeypatch): + monkeypatch.setenv("TEST_USER", "alice") + monkeypatch.setenv("TEST_PASSWORD", "secret") + config = Config.model_validate( + {"providers": {"groq": {"apiKey": "${TEST_USER}:${TEST_PASSWORD}"}}} + ) - def test_partial_replacement(self, monkeypatch): - monkeypatch.setenv("HOST", "example.com") - assert _resolve_env_vars("https://${HOST}/api") == "https://example.com/api" + resolved = resolve_config_env_vars(config) - def test_multiple_vars_in_one_string(self, monkeypatch): - 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" + assert resolved.providers.groq.api_key == "alice:secret" def test_missing_var_raises(self): + config = Config.model_validate( + {"providers": {"groq": {"apiKey": "${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): monkeypatch.setenv("TEST_API_KEY", "resolved-key") config_path = tmp_path / "config.json" @@ -60,7 +37,7 @@ class TestResolveConfig: encoding="utf-8", ) - raw = load_config(config_path) + raw = load_raw_config(config_path) assert raw.providers.groq.api_key == "${TEST_API_KEY}" resolved = resolve_config_env_vars(raw) @@ -76,7 +53,7 @@ class TestResolveConfig: encoding="utf-8", ) - raw = load_config(config_path) + raw = load_raw_config(config_path) save_config(raw, config_path) saved = json.loads(config_path.read_text(encoding="utf-8")) @@ -91,14 +68,14 @@ class TestResolveConfig: encoding="utf-8", ) - config = load_config(config_path) + config = load_raw_config(config_path) config.agents.defaults.max_tokens = 1234 save_config(config, config_path) saved = json.loads(config_path.read_text(encoding="utf-8")) 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") assert schedule.kind == "cron" assert schedule.expr == "0 */4 * * *" @@ -119,7 +96,7 @@ class TestResolveConfig: encoding="utf-8", ) - config = load_config(config_path) + config = load_raw_config(config_path) save_config(config, config_path) 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"]["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.api_key is None @@ -166,7 +143,7 @@ class TestResolveConfig: encoding="utf-8", ) - raw = load_config(config_path) + raw = load_raw_config(config_path) assert raw.providers.openai_codex.api_key == "secret" resolved = resolve_config_env_vars(raw) @@ -190,7 +167,7 @@ class TestResolveConfig: encoding="utf-8", ) - raw = load_config(config_path) + raw = load_raw_config(config_path) resolved = resolve_config_env_vars(raw) assert resolved.providers.groq.api_key == "resolved-key" diff --git a/tests/config/test_repository.py b/tests/config/test_repository.py index 47f94c1c3..d38fdce12 100644 --- a/tests/config/test_repository.py +++ b/tests/config/test_repository.py @@ -9,7 +9,7 @@ from unittest.mock import patch 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.security.network import configure_ssrf_whitelist, validate_url_target @@ -26,6 +26,16 @@ def _fake_resolve(host: str, results: list[str]): 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( tmp_path: Path, 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") configure_ssrf_whitelist(["100.64.0.0/10"]) try: - load_config(path) + load_raw_config(path) with patch( "nanobot.security.network.socket.getaddrinfo", diff --git a/tests/test_nanobot_facade.py b/tests/test_nanobot_facade.py index b1462a393..43eef4ab1 100644 --- a/tests/test_nanobot_facade.py +++ b/tests/test_nanobot_facade.py @@ -120,7 +120,7 @@ def test_from_config_rejects_multiple_model_selectors(tmp_path): def test_from_config_default_path(): 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: mock_load.return_value = Config() mock_prov.return_value = MagicMock() diff --git a/tests/webui/test_channel_validation.py b/tests/webui/test_channel_validation.py index 09773c445..7d7bee020 100644 --- a/tests/webui/test_channel_validation.py +++ b/tests/webui/test_channel_validation.py @@ -3,7 +3,7 @@ from __future__ import annotations import httpx 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.webui import channel_validation 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" - saved = load_config(config_path) + saved = load_raw_config(config_path) assert saved.channels.slack["appToken"] == "xapp-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["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( diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index f1bd12e0a..1d83a19ea 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -4,7 +4,7 @@ import asyncio import pytest -from nanobot.config.loader import load_config +from nanobot.config.loader import load_raw_config from nanobot.webui.mcp_presets_api import ( McpPresetError, custom_mcp_action, @@ -76,7 +76,7 @@ def test_enable_browserbase_writes_scrubbed_config_payload( assert preset["installed"] is True assert preset["configured"] is True 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 @@ -107,7 +107,7 @@ def test_enable_context7_optional_api_key_appends_arg( assert "ctx7_secret" not in str(payload) row = next(item for item in payload["presets"] if item["name"] == "context7") assert row["configured"] is True - config = load_config() + config = load_raw_config() assert config.tools.mcp_servers["context7"].args == [ "-y", "@upstash/context7-mcp@latest", @@ -124,7 +124,7 @@ def test_enable_stdio_preset_uses_config_scoped_cwd( mcp_presets_action("enable", {"name": ["playwright"]}) - config = load_config() + config = load_raw_config() cwd = config.tools.mcp_servers["playwright"].cwd assert cwd == str(tmp_path / "mcp" / "playwright") 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": ["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["exa"].url == "https://mcp.exa.ai/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["configured"] is True 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"].url == "https://mcp.firecrawl.dev/v2/mcp" 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"]["managed_paths_removed"] == ["runtime:mcp/playwright"] assert not managed_cwd.exists() - config = load_config() + config = load_raw_config() 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 user_cwd.exists() - config = load_config() + config = load_raw_config() 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 "server.js" not in str(row["manifest"]) 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"].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)." - config = load_config() + config = load_raw_config() assert config.tools.mcp_servers["docs"].command == "npx" assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"] 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") 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( "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") 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( diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 299b4af21..9179c2cbd 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -7,7 +7,7 @@ from types import SimpleNamespace import httpx 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.providers.registry import find_by_name from nanobot.webui.settings_api import ( @@ -95,7 +95,7 @@ def test_update_api_settings_requires_key_for_network_access( "port": ["9900"], "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.port == 9900 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"]}) - 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( @@ -174,7 +174,7 @@ def test_create_model_configuration_writes_label_and_selects( rows = {row["name"]: row for row in payload["model_presets"]} 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.model_presets["fast-writing"].label == "Fast writing" 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"]["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"].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"] == "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.model_presets["codex"].label == "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"]} assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == "https://new.example/v1" 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] assert dynamic_provider.api_base == "https://new.example/v1" 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"]}) 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 @@ -362,7 +362,7 @@ def test_update_model_configuration_accepts_context_window_options( ) 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 @@ -555,7 +555,7 @@ def test_update_web_search_settings_accepts_keenable_without_api_key( 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.api_key == "" 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": [""]}) - saved = load_config(config_path) + saved = load_raw_config(config_path) assert saved.tools.web.search.provider == "keenable" 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_language == "en" 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.model == "nvidia/parakeet-tdt-0.6b-v3" 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.model == "mimo-v2.5-asr" 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.model == "universal-3-pro" 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")) assert saved.tools.webui_allow_local_service_access 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"]}) - saved = load_config(config_path) + saved = load_raw_config(config_path) assert config_path.read_text(encoding="utf-8") == before assert saved.tools.restrict_to_workspace is False 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" - saved = load_config(config_path) + saved = load_raw_config(config_path) 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" - 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"].model == "my-deployment"