"""Configuration loading utilities.""" import json import os import re from pathlib import Path from typing import Any import pydantic from loguru import logger from pydantic import BaseModel from nanobot.config.schema import Config, _resolve_tool_config_refs from nanobot.utils.helpers import _write_text_atomic # Global variable to store current config path (for multi-instance support) _current_config_path: Path | None = None _schema_refs_ready = False def set_config_path(path: Path) -> None: """Set the current config path (used to derive data directory).""" global _current_config_path _current_config_path = path def get_config_path() -> Path: """Get the configuration file path.""" if _current_config_path: return _current_config_path return Path.home() / ".nanobot" / "config.json" def load_config(config_path: Path | None = None) -> Config: """ Load configuration from file or create default. Args: config_path: Optional path to config file. Uses default if not provided. Returns: Loaded configuration object. """ global _schema_refs_ready if not _schema_refs_ready: _resolve_tool_config_refs() _schema_refs_ready = True path = config_path or get_config_path() config = Config() if path.exists(): try: with open(path, encoding="utf-8") as f: data = json.load(f) data = _migrate_config(data) config = Config.model_validate(data) except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e: raise ValueError(f"Failed to load config from {path}: {e}") from e _apply_ssrf_whitelist(config) return config def _apply_ssrf_whitelist(config: Config) -> None: """Apply SSRF whitelist from config to the network security module.""" from nanobot.security.network import configure_ssrf_whitelist configure_ssrf_whitelist(config.tools.ssrf_whitelist) def save_config(config: Config, config_path: Path | None = None) -> None: """ Save configuration to file. Args: config: Configuration to save. config_path: Optional path to save to. Uses default if not provided. """ path = config_path or get_config_path() path.parent.mkdir(parents=True, exist_ok=True) data = config.model_dump(mode="json", by_alias=True) # OAuth credentials live in dedicated token stores. Persist only the # non-credential request settings consumed by these provider backends. for alias, provider in ( ("openaiCodex", config.providers.openai_codex), ("xaiGrok", config.providers.xai_grok), ): settings = provider.model_dump( mode="json", by_alias=True, include={"proxy", "extra_body"}, exclude_none=True, ) if settings: data.setdefault("providers", {})[alias] = settings # Temp + replace so a crash mid-write cannot leave a truncated config.json. _write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False)) def merge_missing_defaults(existing: Any, defaults: Any) -> Any: """Recursively add missing defaults without replacing configured values.""" if not isinstance(existing, dict) or not isinstance(defaults, dict): return existing merged = dict(existing) for key, value in defaults.items(): if key not in merged: merged[key] = value else: merged[key] = merge_missing_defaults(merged[key], value) return merged _ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") def resolve_config_env_vars(config: Config) -> Config: """Return *config* with ``${VAR}`` env-var references resolved. Walks in place so fields declared with ``exclude=True`` survive; returns the same instance when no references are present. Raises ``ValueError`` if a referenced variable is not set. """ return _resolve_in_place(config) def resolve_env_refs(value: str) -> str: """Resolve ``${VAR}`` references in a single string, leniently. Unlike :func:`resolve_config_env_vars` (which walks a whole ``Config`` and raises on a missing variable), this resolves one value and returns an empty string if any reference is unset. It is meant for individual, lazily consumed fields — e.g. a transcription provider's ``api_key`` or ``api_base`` — so a missing variable degrades to "not configured" instead of producing a partial value. Non-string input is returned unchanged. """ if not isinstance(value, str): return value names = _ENV_REF_PATTERN.findall(value) if any(name not in os.environ for name in names): return "" return _ENV_REF_PATTERN.sub(lambda m: os.environ[m.group(1)], value) def _resolve_in_place(obj: Any) -> Any: if isinstance(obj, str): new = _ENV_REF_PATTERN.sub(_env_replace, obj) return new if new != obj else obj if isinstance(obj, BaseModel): updates: dict[str, Any] = {} for name in type(obj).model_fields: old = getattr(obj, name) new = _resolve_in_place(old) if new is not old: updates[name] = new extras = obj.__pydantic_extra__ new_extras: dict[str, Any] | None = None if extras: resolved = {k: _resolve_in_place(v) for k, v in extras.items()} if any(resolved[k] is not extras[k] for k in extras): new_extras = resolved if not updates and new_extras is None: return obj copy = obj.model_copy(update=updates) if updates else obj.model_copy() if new_extras is not None: copy.__pydantic_extra__ = new_extras return copy if isinstance(obj, dict): resolved = {k: _resolve_in_place(v) for k, v in obj.items()} return resolved if any(resolved[k] is not obj[k] for k in obj) else obj if isinstance(obj, list): resolved = [_resolve_in_place(v) for v in obj] return resolved if any(nv is not ov for nv, ov in zip(resolved, obj)) else obj return obj def _resolve_env_vars(obj: object) -> object: """Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists.""" if isinstance(obj, str): return _ENV_REF_PATTERN.sub(_env_replace, obj) if isinstance(obj, dict): return {k: _resolve_env_vars(v) for k, v in obj.items()} if isinstance(obj, list): return [_resolve_env_vars(v) for v in obj] return obj def _env_replace(match: re.Match[str]) -> str: name = match.group(1) value = os.environ.get(name) if value is None: raise ValueError( f"Environment variable '{name}' referenced in config is not set" ) return value def _migrate_config(data: dict) -> dict: """Migrate old config formats to current.""" agents = data.get("agents", {}) defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {} if isinstance(defaults, dict): had_legacy_max_messages = ( "maxMessages" in defaults or "max_messages" in defaults ) defaults.pop("maxMessages", None) defaults.pop("max_messages", None) if had_legacy_max_messages: # TODO(v0.3.1): Remove this legacy cleanup branch. v0.3.0 is the # final release that warns before the schema silently ignores the field. logger.warning( "agents.defaults.maxMessages/max_messages is legacy and ignored; " "replay max messages is now an internal safety cap. Remove it from " "config. This compatibility warning will be removed in the next version." ) # Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace tools = data.get("tools", {}) exec_cfg = tools.get("exec", {}) if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") # Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}. # The old flat keys shipped in the initial MyTool landing; wrapping them in a # sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow. if "myEnabled" in tools or "mySet" in tools: my_cfg = tools.setdefault("my", {}) if "myEnabled" in tools and "enable" not in my_cfg: my_cfg["enable"] = tools.pop("myEnabled") else: tools.pop("myEnabled", None) if "mySet" in tools and "allowSet" not in my_cfg: my_cfg["allowSet"] = tools.pop("mySet") else: tools.pop("mySet", None) return data