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
`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
`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

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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 ""

View File

@ -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)

View File

@ -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}"]

View File

@ -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

View File

@ -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()

View File

@ -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",

View File

@ -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",

View File

@ -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)

View File

@ -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()

View File

@ -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,
)

View File

@ -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):

View File

@ -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 {})

View File

@ -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,

View File

@ -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,

View File

@ -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

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.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

View File

@ -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:

View File

@ -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)

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.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",

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_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"]

View File

@ -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(

View File

@ -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"

View File

@ -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,

View File

@ -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)

View File

@ -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", "::"])

View File

@ -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

View File

@ -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"

View File

@ -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",

View File

@ -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()

View File

@ -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(

View File

@ -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(

View File

@ -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"