mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-13 15:49:16 +03:00
390 lines
13 KiB
Python
390 lines
13 KiB
Python
"""Configuration loading utilities."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, cast, overload
|
|
|
|
from pydantic import BaseModel, ValidationError
|
|
from pydantic_settings import SettingsError
|
|
|
|
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
|
from nanobot.config.schema import (
|
|
Config,
|
|
_resolve_tool_config_refs, # pyright: ignore[reportPrivateUsage]
|
|
)
|
|
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
|
|
|
|
# Global variable to store current config path (for multi-instance support)
|
|
_current_config_path: Path | None = None
|
|
_schema_refs_ready = False
|
|
|
|
|
|
def _as_config_object(value: object) -> dict[str, Any] | None:
|
|
"""Narrow an untrusted JSON configuration value to an object."""
|
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
|
|
|
|
|
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()
|
|
|
|
if not path.exists():
|
|
try:
|
|
config = Config()
|
|
except SettingsError as exc:
|
|
raise ConfigLoadError(
|
|
path,
|
|
kind="invalid_schema",
|
|
summary=(
|
|
"Environment-based configuration could not be parsed. "
|
|
"Check that complex NANOBOT_* values use valid JSON."
|
|
),
|
|
) from exc
|
|
except ValidationError as exc:
|
|
raise ConfigLoadError(
|
|
path,
|
|
kind="invalid_schema",
|
|
summary="Environment-based configuration is invalid.",
|
|
issues=validation_issues(exc),
|
|
) from exc
|
|
config.bind_source_path(path)
|
|
_apply_ssrf_whitelist(config)
|
|
return config
|
|
|
|
try:
|
|
with path.open(encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
except json.JSONDecodeError as exc:
|
|
raise ConfigLoadError(
|
|
path,
|
|
kind="invalid_json",
|
|
summary=(
|
|
f"JSON syntax error at line {exc.lineno}, column {exc.colno}: "
|
|
f"{_sentence(exc.msg)}"
|
|
),
|
|
) from exc
|
|
except UnicodeDecodeError as exc:
|
|
raise ConfigLoadError(
|
|
path,
|
|
kind="io_error",
|
|
summary="The file is not valid UTF-8.",
|
|
) from exc
|
|
except OSError as exc:
|
|
detail = exc.strerror or type(exc).__name__
|
|
raise ConfigLoadError(
|
|
path,
|
|
kind="io_error",
|
|
summary=f"Unable to read the file: {_sentence(detail)}",
|
|
) from exc
|
|
|
|
if not isinstance(data, dict):
|
|
root_type = type(data).__name__
|
|
raise ConfigLoadError(
|
|
path,
|
|
kind="invalid_root",
|
|
summary="The top level of config.json must be a JSON object.",
|
|
issues=(
|
|
ConfigIssue(
|
|
path=(),
|
|
message=f"Expected an object, but found {root_type}.",
|
|
),
|
|
),
|
|
)
|
|
|
|
data = _migrate_config(cast(dict[str, Any], data))
|
|
try:
|
|
config = Config.model_validate(data)
|
|
except ValidationError as exc:
|
|
issues = validation_issues(exc)
|
|
raise ConfigLoadError(
|
|
path,
|
|
kind="invalid_schema",
|
|
summary=f"Found {len(issues)} invalid setting(s).",
|
|
issues=issues,
|
|
) from exc
|
|
|
|
config.bind_source_path(path)
|
|
_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: object, defaults: object) -> object:
|
|
"""Recursively add missing defaults without replacing configured values."""
|
|
if not isinstance(existing, dict) or not isinstance(defaults, dict):
|
|
return cast(object, existing)
|
|
|
|
existing_dict = cast(dict[str, object], existing)
|
|
defaults_dict = cast(dict[str, object], defaults)
|
|
merged = dict(existing_dict)
|
|
for key, value in defaults_dict.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_path: Path | None = None,
|
|
) -> 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 ``ConfigLoadError`` if a referenced variable is not set.
|
|
"""
|
|
missing = tuple(_missing_env_issues(config))
|
|
if missing:
|
|
raise ConfigLoadError(
|
|
config_path or get_config_path(),
|
|
kind="missing_env",
|
|
summary=f"Found {len(missing)} missing environment variable reference(s).",
|
|
issues=missing,
|
|
)
|
|
return _resolve_in_place(config)
|
|
|
|
|
|
@overload
|
|
def resolve_env_refs(value: str) -> str: ...
|
|
|
|
|
|
@overload
|
|
def resolve_env_refs(value: object) -> object: ...
|
|
|
|
|
|
def resolve_env_refs(value: object) -> object:
|
|
"""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):
|
|
object_dict = cast(dict[str, Any], obj)
|
|
resolved = {key: _resolve_in_place(value) for key, value in object_dict.items()}
|
|
return (
|
|
resolved
|
|
if any(resolved[key] is not object_dict[key] for key in object_dict)
|
|
else cast(object, obj)
|
|
)
|
|
if isinstance(obj, list):
|
|
object_list = cast(list[Any], obj)
|
|
resolved = [_resolve_in_place(value) for value in object_list]
|
|
return (
|
|
resolved
|
|
if any(new is not old for new, old in zip(resolved, object_list))
|
|
else cast(object, obj)
|
|
)
|
|
return obj
|
|
|
|
|
|
def _missing_env_issues(
|
|
obj: Any,
|
|
path: tuple[str | int, ...] = (),
|
|
) -> list[ConfigIssue]:
|
|
if isinstance(obj, str):
|
|
return [
|
|
ConfigIssue(
|
|
path=path,
|
|
message=f"Environment variable '{name}' is not set.",
|
|
)
|
|
for name in dict.fromkeys(_ENV_REF_PATTERN.findall(obj))
|
|
if name not in os.environ
|
|
]
|
|
if isinstance(obj, BaseModel):
|
|
issues: list[ConfigIssue] = []
|
|
for name, field in type(obj).model_fields.items():
|
|
alias = field.serialization_alias or field.alias or name
|
|
part = alias
|
|
issues.extend(_missing_env_issues(getattr(obj, name), (*path, part)))
|
|
for name, value in (obj.__pydantic_extra__ or {}).items():
|
|
issues.extend(_missing_env_issues(value, (*path, name)))
|
|
return issues
|
|
if isinstance(obj, dict):
|
|
object_dict = cast(dict[str | int, Any], obj)
|
|
issues = []
|
|
for name, value in object_dict.items():
|
|
part = name
|
|
issues.extend(_missing_env_issues(value, (*path, part)))
|
|
return issues
|
|
if isinstance(obj, list):
|
|
issues = []
|
|
for index, value in enumerate(cast(list[Any], obj)):
|
|
issues.extend(_missing_env_issues(value, (*path, index)))
|
|
return issues
|
|
return []
|
|
|
|
|
|
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 cast(dict[str, object], obj).items()
|
|
}
|
|
if isinstance(obj, list):
|
|
return [_resolve_env_vars(value) for value in cast(list[object], 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[str, Any]) -> dict[str, Any]:
|
|
"""Migrate old config formats to current."""
|
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
|
tools_value = data.get("tools", {})
|
|
if not isinstance(tools_value, dict):
|
|
return data
|
|
tools = cast(dict[str, Any], tools_value)
|
|
exec_cfg = _as_config_object(tools.get("exec", {}))
|
|
if (
|
|
exec_cfg is not None
|
|
and "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.get("my")
|
|
if my_cfg is None:
|
|
my_cfg = {}
|
|
tools["my"] = my_cfg
|
|
if not isinstance(my_cfg, dict):
|
|
return data
|
|
my_cfg = cast(dict[str, Any], my_cfg)
|
|
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
|
|
|
|
|
|
def _sentence(message: str) -> str:
|
|
message = message.strip()
|
|
if message and message[-1] not in ".!?":
|
|
message += "."
|
|
return message
|