refactor(config): centralize file persistence

This commit is contained in:
chengyongru 2026-07-14 11:28:48 +08:00
parent 6e462e62a6
commit f19efcd990
16 changed files with 1149 additions and 655 deletions

View File

@ -27,3 +27,9 @@ A bugfix should make the protected invariant clear, change the smallest surface
## Explicit over magical ## Explicit over magical
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class. Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
## 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.
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.

View File

@ -6,7 +6,7 @@
## Config `${VAR}` References ## Config `${VAR}` References
`config/loader.py` resolves `${VAR}` patterns in `config.json` at load time. This is **not** a shell-like default-value syntax. If the environment variable is missing, `load_config` raises `ValueError` and the agent falls back to default configuration. `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`.
Example valid usage: Example valid usage:
```json ```json

View File

@ -16,7 +16,7 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`). All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
The only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time. The only escape hatch is `configure_ssrf_whitelist(cidrs)`. Runtime entry points explicitly apply `config.tools.ssrf_whitelist` after loading the effective config; ordinary config reads must not mutate this process-wide policy.
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path. HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.

View File

@ -626,38 +626,44 @@ def sync_saved_feishu_identity_boundary(
if not current_identity_key: if not current_identity_key:
return False return False
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import update_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config() defaults = FeishuChannel.default_config()
previous_identity_key = "" access_cleared = False
for spec in feishu_instance_specs(feishu_cfg, defaults):
if spec.instance_id == instance_id:
previous_identity_key = str(
spec.config.get("identityKey") or spec.config.get("identity_key") or ""
)
break
access_cleared = bool(previous_identity_key and previous_identity_key != current_identity_key) def mutate(full_config: Any) -> None:
values: dict[str, Any] = {"identityKey": current_identity_key} nonlocal access_cleared
if access_cleared: feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
values["allowFrom"] = [] if not isinstance(feishu_cfg, dict):
values["allow_from"] = [] feishu_cfg = {}
clear_channel(runtime_channel_name("feishu", instance_id))
if not previous_identity_key or access_cleared: previous_identity_key = ""
feishu_cfg = update_feishu_instance_preserving_shape( for spec in feishu_instance_specs(feishu_cfg, defaults):
feishu_cfg, if spec.instance_id == instance_id:
defaults, previous_identity_key = str(
instance_id, spec.config.get("identityKey") or spec.config.get("identity_key") or ""
values, )
break
access_cleared = bool(
previous_identity_key and previous_identity_key != current_identity_key
) )
setattr(full_config.channels, "feishu", feishu_cfg) values: dict[str, Any] = {"identityKey": current_identity_key}
save_config(full_config) if access_cleared:
values["allowFrom"] = []
values["allow_from"] = []
if not previous_identity_key or access_cleared:
full_config.channels.feishu = update_feishu_instance_preserving_shape(
feishu_cfg,
defaults,
instance_id,
values,
)
update_config(mutate)
if access_cleared:
clear_channel(runtime_channel_name("feishu", instance_id))
return access_cleared return access_cleared
@ -669,19 +675,13 @@ def save_registration_result(
name: str | None = None, name: str | None = None,
) -> None: ) -> None:
"""Persist a successful Feishu/Lark registration result to config.json.""" """Persist a successful Feishu/Lark registration result to config.json."""
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import update_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config() defaults = FeishuChannel.default_config()
app_id = str(result["app_id"]).strip() app_id = str(result["app_id"]).strip()
domain = str(result.get("domain", "feishu") or "feishu").strip().lower() domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
domain = "lark" if domain == "lark" else "feishu" domain = "lark" if domain == "lark" else "feishu"
previous_identity_key = _saved_feishu_instance_identity_key(feishu_cfg, defaults, instance_id)
next_identity_key = _feishu_app_identity_key(app_id, domain) next_identity_key = _feishu_app_identity_key(app_id, domain)
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
identity: dict[str, str] = {} identity: dict[str, str] = {}
with suppress(Exception): with suppress(Exception):
identity = fetch_feishu_app_identity( identity = fetch_feishu_app_identity(
@ -698,18 +698,35 @@ def save_registration_result(
"enabled": True, "enabled": True,
**identity, **identity,
} }
identity_changed = False
def mutate(full_config: Any) -> None:
nonlocal identity_changed
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
previous_identity_key = _saved_feishu_instance_identity_key(
feishu_cfg,
defaults,
instance_id,
)
identity_changed = bool(
previous_identity_key and previous_identity_key != next_identity_key
)
next_values = dict(values)
if identity_changed:
next_values["allowFrom"] = []
next_values["allow_from"] = []
full_config.channels.feishu = upsert_feishu_instance(
feishu_cfg,
defaults,
instance_id,
next_values,
)
update_config(mutate)
if identity_changed: if identity_changed:
values["allowFrom"] = []
values["allow_from"] = []
clear_channel(runtime_channel_name("feishu", instance_id)) clear_channel(runtime_channel_name("feishu", instance_id))
feishu_cfg = upsert_feishu_instance(
feishu_cfg,
defaults,
instance_id,
values,
)
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
def refresh_saved_feishu_identities(config: Any | None = None) -> bool: def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
@ -723,13 +740,13 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
if not FEISHU_AVAILABLE: if not FEISHU_AVAILABLE:
return False return False
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, update_config
full_config = config or load_config() source_config = config or load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) feishu_cfg = getattr(source_config.channels, "feishu", None)
defaults = FeishuChannel.default_config() defaults = FeishuChannel.default_config()
specs = feishu_instance_specs(feishu_cfg, defaults) specs = feishu_instance_specs(feishu_cfg, defaults)
updated = False fetched: dict[str, tuple[tuple[str, str, str], dict[str, str]]] = {}
for spec in specs: for spec in specs:
instance = spec.config instance = spec.config
@ -753,20 +770,50 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
if not identity: if not identity:
identity = {"identityFetchedAt": _identity_timestamp()} identity = {"identityFetchedAt": _identity_timestamp()}
feishu_cfg = update_feishu_instance_preserving_shape( fetched[spec.instance_id] = (
feishu_cfg, (app_id, app_secret, str(instance.get("domain") or "feishu")),
defaults,
spec.instance_id,
identity, identity,
) )
updated = True
if not updated: if not fetched:
return False return False
setattr(full_config.channels, "feishu", feishu_cfg) updated = False
save_config(full_config)
return True def mutate(full_config: Any) -> None:
nonlocal updated
current_cfg = getattr(full_config.channels, "feishu", None)
for spec in feishu_instance_specs(current_cfg, defaults):
fetched_entry = fetched.get(spec.instance_id)
if fetched_entry is None:
continue
expected_credentials, identity = fetched_entry
instance = spec.config
current_credentials = (
str(instance.get("appId") or instance.get("app_id") or "").strip(),
str(instance.get("appSecret") or instance.get("app_secret") or "").strip(),
str(instance.get("domain") or "feishu"),
)
if current_credentials != expected_credentials:
continue
if (
instance.get("displayName")
or instance.get("avatarUrl")
or instance.get("identityFetchedAt")
):
continue
current_cfg = update_feishu_instance_preserving_shape(
current_cfg,
defaults,
spec.instance_id,
identity,
)
updated = True
if updated:
full_config.channels.feishu = current_cfg
update_config(mutate)
return updated
def qr_register( def qr_register(

View File

@ -738,27 +738,25 @@ def onboard(
def _onboard_plugins(config_path: Path) -> None: def _onboard_plugins(config_path: Path) -> None:
"""Inject default config for all discovered channels (built-in + plugins).""" """Inject default config for all discovered channels (built-in + plugins)."""
import json
from nanobot.channels.registry import discover_all from nanobot.channels.registry import discover_all
from nanobot.config.loader import merge_missing_defaults from nanobot.config.loader import get_config_repository, merge_missing_defaults
all_channels = discover_all() all_channels = discover_all()
if not all_channels: if not all_channels:
return return
with open(config_path, encoding="utf-8") as f: def mutate(config: Config) -> None:
data = json.load(f) for name, cls in all_channels.items():
existing = getattr(config.channels, name, None)
if not isinstance(existing, dict):
existing = {}
setattr(
config.channels,
name,
merge_missing_defaults(existing, cls.default_config()),
)
channels = data.setdefault("channels", {}) get_config_repository(config_path).update(mutate)
for name, cls in all_channels.items():
if name not in channels:
channels[name] = cls.default_config()
else:
channels[name] = merge_missing_defaults(channels[name], cls.default_config())
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def _print_enable_options( def _print_enable_options(
@ -798,7 +796,11 @@ def _model_display(config: Config) -> tuple[str, str]:
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config: def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
"""Load config and optionally override the active workspace.""" """Load config and optionally override the active workspace."""
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path from nanobot.config.loader import (
apply_config_runtime_policies,
load_effective_config,
set_config_path,
)
config_path = None config_path = None
if config: if config:
@ -810,13 +812,14 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
console.print(f"[dim]Using config: {config_path}[/dim]") console.print(f"[dim]Using config: {config_path}[/dim]")
try: try:
loaded = resolve_config_env_vars(load_config(config_path)) loaded = load_effective_config(config_path)
except ValueError as e: except ValueError as e:
console.print(f"[red]Error: {e}[/red]") console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1) raise typer.Exit(1)
_warn_deprecated_config_keys(config_path) _warn_deprecated_config_keys(config_path)
if workspace: if workspace:
loaded.agents.defaults.workspace = workspace loaded.agents.defaults.workspace = workspace
apply_config_runtime_policies(loaded)
return loaded return loaded
@ -2688,19 +2691,21 @@ def _set_oauth_provider_as_main(
config_path: str | None = None, config_path: str | None = None,
) -> None: ) -> None:
"""Persist an OAuth provider as the active agent provider.""" """Persist an OAuth provider as the active agent provider."""
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path from nanobot.config.loader import get_config_path, set_config_path, update_config
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
if resolved_config_path is not None: if resolved_config_path is not None:
set_config_path(resolved_config_path) set_config_path(resolved_config_path)
console.print(f"[dim]Using config: {resolved_config_path}[/dim]") console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
config = load_config(resolved_config_path)
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name] selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
config.agents.defaults.model_preset = None
config.agents.defaults.provider = provider_name def mutate(config: Config) -> None:
config.agents.defaults.model = selected_model config.agents.defaults.model_preset = None
save_config(config, resolved_config_path) config.agents.defaults.provider = provider_name
config.agents.defaults.model = selected_model
update_config(mutate, resolved_config_path)
saved_path = resolved_config_path or get_config_path() saved_path = resolved_config_path or get_config_path()
console.print( console.print(

View File

@ -1,6 +1,13 @@
"""Configuration module for nanobot.""" """Configuration module for nanobot."""
from nanobot.config.loader import get_config_path, load_config from nanobot.config.loader import (
apply_config_runtime_policies,
get_config_path,
get_config_repository,
load_config,
load_effective_config,
update_config,
)
from nanobot.config.paths import ( from nanobot.config.paths import (
get_cli_history_path, get_cli_history_path,
get_cron_dir, get_cron_dir,
@ -13,12 +20,28 @@ from nanobot.config.paths import (
get_workspace_path, get_workspace_path,
is_default_workspace, is_default_workspace,
) )
from nanobot.config.repository import (
ConfigCommit,
ConfigConflictError,
EffectiveConfigSnapshot,
FileConfigRepository,
PersistedConfigSnapshot,
)
from nanobot.config.schema import Config from nanobot.config.schema import Config
__all__ = [ __all__ = [
"Config", "Config",
"ConfigCommit",
"ConfigConflictError",
"EffectiveConfigSnapshot",
"FileConfigRepository",
"PersistedConfigSnapshot",
"apply_config_runtime_policies",
"load_config", "load_config",
"load_effective_config",
"update_config",
"get_config_path", "get_config_path",
"get_config_repository",
"get_data_dir", "get_data_dir",
"get_runtime_subdir", "get_runtime_subdir",
"get_media_dir", "get_media_dir",

View File

@ -1,92 +1,90 @@
"""Configuration loading utilities.""" """Compatibility helpers for configuration loading and persistence."""
import json from __future__ import annotations
import os
import re from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pydantic from loguru import logger as logger # compatibility: callers patch loader.logger
from loguru import logger
from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs 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
# Global variable to store current config path (for multi-instance support) # Legacy default-instance path. Runtime code should prefer an explicitly scoped
# FileConfigRepository; these helpers remain for CLI and plugin compatibility.
_current_config_path: Path | None = None _current_config_path: Path | None = None
_schema_refs_ready = False
def set_config_path(path: Path) -> None: def set_config_path(path: Path) -> None:
"""Set the current config path (used to derive data directory).""" """Set the default config path used by compatibility helpers."""
global _current_config_path global _current_config_path
_current_config_path = path _current_config_path = path
def get_config_path() -> Path: def get_config_path() -> Path:
"""Get the configuration file path.""" """Get the default configuration file path."""
if _current_config_path: if _current_config_path:
return _current_config_path return _current_config_path
return Path.home() / ".nanobot" / "config.json" return Path.home() / ".nanobot" / "config.json"
def get_config_repository(config_path: Path | None = None) -> FileConfigRepository:
"""Return an instance-scoped repository for *config_path*."""
return FileConfigRepository(config_path or get_config_path())
def load_config(config_path: Path | None = None) -> Config: def load_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.
""" """
Load configuration from file or create default. return resolve_config_env_vars(load_config(config_path))
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: def save_config(config: Config, config_path: Path | None = None) -> None:
"""Atomically save a complete config.
New read-modify-write flows should use :func:`update_config` so concurrent
writers for the same file cannot silently overwrite one another.
""" """
Save configuration to file. get_config_repository(config_path).save(config)
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) def update_config(
if config.providers.openai_codex.proxy is not None: mutator: Callable[[Config], None],
data.setdefault("providers", {})["openaiCodex"] = { config_path: Path | None = None,
"proxy": config.providers.openai_codex.proxy, *,
} expected_revision: str | None = None,
) -> ConfigCommit:
"""Atomically mutate the latest raw config and return the resulting commit."""
return get_config_repository(config_path).update(
mutator,
expected_revision=expected_revision,
)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False) def apply_config_runtime_policies(config: Config) -> None:
"""Apply process-level policies when starting or reconfiguring a runtime."""
from nanobot.security.network import configure_ssrf_whitelist
configure_ssrf_whitelist(config.tools.ssrf_whitelist)
def merge_missing_defaults(existing: Any, defaults: Any) -> Any: def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
@ -103,109 +101,16 @@ def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
return merged return merged
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") __all__ = [
"FileConfigRepository",
"apply_config_runtime_policies",
def resolve_config_env_vars(config: Config) -> Config: "get_config_path",
"""Return *config* with ``${VAR}`` env-var references resolved. "get_config_repository",
"load_config",
Walks in place so fields declared with ``exclude=True`` survive; "load_effective_config",
returns the same instance when no references are present. "merge_missing_defaults",
Raises ``ValueError`` if a referenced variable is not set. "resolve_config_env_vars",
""" "save_config",
return _resolve_in_place(config) "set_config_path",
"update_config",
]
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(next version): Remove this legacy cleanup branch; the schema
# will silently ignore this field once the warning grace period ends.
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

View File

@ -0,0 +1,319 @@
"""Instance-scoped configuration persistence and resolution."""
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
import threading
import uuid
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loguru import logger
from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs
ConfigMutator = Callable[[Config], None]
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
_schema_refs_ready = False
_schema_refs_lock = threading.Lock()
_path_locks_guard = threading.Lock()
_path_locks: dict[Path, threading.RLock] = {}
class ConfigConflictError(RuntimeError):
"""Raised when a caller tries to update a stale configuration snapshot."""
@dataclass(frozen=True)
class PersistedConfigSnapshot:
"""Validated configuration as represented by the persisted source."""
config: Config
path: Path
revision: str
@dataclass(frozen=True)
class EffectiveConfigSnapshot:
"""Runtime configuration with environment references resolved."""
config: Config
path: Path
revision: str
@dataclass(frozen=True)
class ConfigCommit:
"""One atomic configuration update and its semantic field changes."""
before: PersistedConfigSnapshot
after: PersistedConfigSnapshot
changed_paths: frozenset[str]
def _ensure_schema_refs() -> None:
global _schema_refs_ready
if _schema_refs_ready:
return
with _schema_refs_lock:
if not _schema_refs_ready:
_resolve_tool_config_refs()
_schema_refs_ready = True
def _lock_for_path(path: Path) -> threading.RLock:
key = path.expanduser().resolve(strict=False)
with _path_locks_guard:
return _path_locks.setdefault(key, threading.RLock())
def _revision_for_bytes(raw: bytes | None) -> str:
if raw is None:
return "missing"
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
def _config_data(config: Config) -> dict[str, Any]:
data = config.model_dump(mode="json", by_alias=True)
if config.providers.openai_codex.proxy is not None:
data.setdefault("providers", {})["openaiCodex"] = {
"proxy": config.providers.openai_codex.proxy,
}
return data
def _validate_config_data(data: dict[str, Any], path: Path) -> Config:
_ensure_schema_refs()
try:
return Config.model_validate(data)
except ValueError as exc:
raise ValueError(f"Failed to load config from {path}: {exc}") from exc
def _read_snapshot(path: Path) -> PersistedConfigSnapshot:
_ensure_schema_refs()
if not path.exists():
return PersistedConfigSnapshot(Config(), path, "missing")
try:
raw = path.read_bytes()
data = json.loads(raw.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("config root must be a JSON object")
data = _migrate_config(data)
config = Config.model_validate(data)
except (UnicodeDecodeError, ValueError) as exc:
raise ValueError(f"Failed to load config from {path}: {exc}") from exc
return PersistedConfigSnapshot(config, path, _revision_for_bytes(raw))
def _write_config_atomic(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
content = json.dumps(data, indent=2, ensure_ascii=False)
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
existing_mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
try:
with open(tmp, "w", encoding="utf-8") as handle:
handle.write(content)
if existing_mode is not None:
with suppress(OSError):
os.chmod(tmp, existing_mode)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
with suppress(OSError, NotImplementedError):
directory_fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
tmp.unlink(missing_ok=True)
def _changed_paths(before: Any, after: Any, prefix: str = "") -> set[str]:
if isinstance(before, dict) and isinstance(after, dict):
changed: set[str] = set()
for key in before.keys() | after.keys():
path = f"{prefix}.{key}" if prefix else str(key)
if key not in before or key not in after:
changed.add(path)
else:
changed.update(_changed_paths(before[key], after[key], path))
return changed
if before != after:
return {prefix} if prefix else {"<root>"}
return set()
class FileConfigRepository:
"""Read and atomically update one configuration file.
The repository does not cache. Every read returns a new validated snapshot,
while updates for the same path are serialized within this process.
"""
def __init__(self, path: str | Path):
self.path = Path(path).expanduser().resolve(strict=False)
self._lock = _lock_for_path(self.path)
def load_raw(self) -> PersistedConfigSnapshot:
"""Load the persisted form without resolving secret references."""
with self._lock:
return _read_snapshot(self.path)
def load_effective(self) -> EffectiveConfigSnapshot:
"""Load an isolated runtime snapshot with ``${VAR}`` references resolved."""
raw = self.load_raw()
effective = resolve_config_env_vars(raw.config.model_copy(deep=True))
return EffectiveConfigSnapshot(effective, raw.path, raw.revision)
def save(
self,
config: Config,
*,
expected_revision: str | None = None,
) -> PersistedConfigSnapshot:
"""Atomically save a complete config, optionally rejecting stale writes."""
with self._lock:
current = _read_snapshot(self.path)
if expected_revision is not None and current.revision != expected_revision:
raise ConfigConflictError(
f"Config changed since revision {expected_revision}; "
f"current revision is {current.revision}"
)
data = _config_data(config)
_validate_config_data(data, self.path)
_write_config_atomic(self.path, data)
return _read_snapshot(self.path)
def update(
self,
mutator: ConfigMutator,
*,
expected_revision: str | None = None,
) -> ConfigCommit:
"""Atomically apply a mutation to the latest persisted config."""
with self._lock:
before = _read_snapshot(self.path)
if expected_revision is not None and before.revision != expected_revision:
raise ConfigConflictError(
f"Config changed since revision {expected_revision}; "
f"current revision is {before.revision}"
)
before_data = _config_data(before.config)
draft = before.config.model_copy(deep=True)
mutator(draft)
after_data = _config_data(draft)
changed = frozenset(_changed_paths(before_data, after_data))
if not changed:
return ConfigCommit(before, before, changed)
_validate_config_data(after_data, self.path)
_write_config_atomic(self.path, after_data)
after = _read_snapshot(self.path)
return ConfigCommit(before, after, changed)
def resolve_config_env_vars(config: Config) -> Config:
"""Return *config* with ``${VAR}`` environment references resolved."""
return _resolve_in_place(config)
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 = {key: _resolve_in_place(value) for key, value in extras.items()}
if any(resolved[key] is not extras[key] for key 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 = {key: _resolve_in_place(value) for key, value in obj.items()}
return resolved if any(resolved[key] is not obj[key] for key in obj) else obj
if isinstance(obj, list):
resolved = [_resolve_in_place(value) for value in obj]
return resolved if any(new is not old for new, old 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 {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)
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 the current schema."""
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:
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."
)
tools = data.get("tools", {})
if not isinstance(tools, dict):
return data
exec_cfg = tools.get("exec", {})
if isinstance(exec_cfg, dict) and "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
if "myEnabled" in tools or "mySet" in tools:
my_cfg = tools.setdefault("my", {})
if not isinstance(my_cfg, dict):
return data
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

View File

@ -95,7 +95,10 @@ class Nanobot:
model: Override the instance default model. model: Override the instance default model.
model_preset: Override the instance default model preset. model_preset: Override the instance default model preset.
""" """
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import (
apply_config_runtime_policies,
load_effective_config,
)
ensure_single_model_selector(model=model, model_preset=model_preset) ensure_single_model_selector(model=model, model_preset=model_preset)
resolved: Path | None = None resolved: Path | None = None
@ -104,7 +107,7 @@ class Nanobot:
if not resolved.exists(): if not resolved.exists():
raise FileNotFoundError(f"Config not found: {resolved}") raise FileNotFoundError(f"Config not found: {resolved}")
config: Config = resolve_config_env_vars(load_config(resolved)) config: Config = load_effective_config(resolved)
if workspace is not None: if workspace is not None:
config.agents.defaults.workspace = str( config.agents.defaults.workspace = str(
Path(workspace).expanduser().resolve() Path(workspace).expanduser().resolve()
@ -116,6 +119,8 @@ class Nanobot:
elif model_preset is not None: elif model_preset is not None:
config.agents.defaults.model_preset = model_preset config.agents.defaults.model_preset = model_preset
apply_config_runtime_policies(config)
loop = AgentLoop.from_config( loop = AgentLoop.from_config(
config, config,
image_generation_provider_configs=image_gen_provider_configs(config), image_generation_provider_configs=image_gen_provider_configs(config),

View File

@ -26,7 +26,7 @@ from nanobot.channels._setup import (
stringify_channel_value, stringify_channel_value,
) )
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.loader import merge_missing_defaults from nanobot.config.loader import get_config_repository, merge_missing_defaults
from nanobot.config.schema import Config from nanobot.config.schema import Config
@ -266,29 +266,16 @@ def install_extra(
return InstallResult(False, label, pip_cmd, failed_cmd=failed_cmd, output=output) return InstallResult(False, label, pip_cmd, failed_cmd=failed_cmd, output=output)
def read_config_data(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
with open(path, encoding="utf-8") as f:
return json.load(f)
def write_config_data(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None: def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
data = read_config_data(config_path) def mutate(config: Config) -> None:
channels = data.setdefault("channels", {}) existing = getattr(config.channels, channel_name, {})
existing = channels.get(channel_name, {}) if not isinstance(existing, dict):
if not isinstance(existing, dict): existing = {}
existing = {} merged = merge_missing_defaults(existing, defaults)
merged = merge_missing_defaults(existing, defaults) merged["enabled"] = True
merged["enabled"] = True setattr(config.channels, channel_name, merged)
channels[channel_name] = merged
write_config_data(config_path, data) get_config_repository(config_path).update(mutate)
def enable_feishu_instance_config( def enable_feishu_instance_config(
@ -297,24 +284,29 @@ def enable_feishu_instance_config(
*, *,
instance_id: str = DEFAULT_INSTANCE_ID, instance_id: str = DEFAULT_INSTANCE_ID,
) -> None: ) -> None:
data = read_config_data(config_path) def mutate(config: Config) -> None:
channels = data.setdefault("channels", {}) existing = getattr(config.channels, "feishu", {})
existing = channels.get("feishu", {}) if not isinstance(existing, dict):
if not isinstance(existing, dict): existing = {}
existing = {} config.channels.feishu = set_feishu_instance_enabled(
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, True) existing,
write_config_data(config_path, data) defaults,
instance_id,
True,
)
get_config_repository(config_path).update(mutate)
def disable_channel_config(config_path: Path, channel_name: str) -> None: def disable_channel_config(config_path: Path, channel_name: str) -> None:
data = read_config_data(config_path) def mutate(config: Config) -> None:
channels = data.setdefault("channels", {}) existing = getattr(config.channels, channel_name, {})
existing = channels.get(channel_name, {}) if not isinstance(existing, dict):
if not isinstance(existing, dict): existing = {}
existing = {} existing["enabled"] = False
existing["enabled"] = False setattr(config.channels, channel_name, existing)
channels[channel_name] = existing
write_config_data(config_path, data) get_config_repository(config_path).update(mutate)
def disable_feishu_instance_config( def disable_feishu_instance_config(
@ -323,13 +315,18 @@ def disable_feishu_instance_config(
*, *,
instance_id: str = DEFAULT_INSTANCE_ID, instance_id: str = DEFAULT_INSTANCE_ID,
) -> None: ) -> None:
data = read_config_data(config_path) def mutate(config: Config) -> None:
channels = data.setdefault("channels", {}) existing = getattr(config.channels, "feishu", {})
existing = channels.get("feishu", {}) if not isinstance(existing, dict):
if not isinstance(existing, dict): existing = {}
existing = {} config.channels.feishu = set_feishu_instance_enabled(
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, False) existing,
write_config_data(config_path, data) defaults,
instance_id,
False,
)
get_config_repository(config_path).update(mutate)
def channel_enabled(config: Config, name: str) -> bool: def channel_enabled(config: Config, name: str) -> bool:

View File

@ -18,9 +18,9 @@ from typing import Any, Literal, Mapping
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.apps.protocol import app_manifest, compact_dict
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config from nanobot.config.loader import load_config, resolve_config_env_vars, update_config
from nanobot.config.paths import get_runtime_subdir from nanobot.config.paths import get_runtime_subdir
from nanobot.config.schema import MCPServerConfig from nanobot.config.schema import Config, MCPServerConfig
from nanobot.utils.helpers import ensure_dir from nanobot.utils.helpers import ensure_dir
QueryParams = dict[str, list[str]] QueryParams = dict[str, list[str]]
@ -1180,19 +1180,16 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]: def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
config = load_config()
if action == "custom": if action == "custom":
name, cfg = _custom_server_from_query(query) name, cfg = _custom_server_from_query(query)
config.tools.mcp_servers[name] = cfg update_config(lambda config: config.tools.mcp_servers.__setitem__(name, cfg))
save_config(config)
payload = mcp_presets_payload(last_action=_server_action_message(action, name)) payload = mcp_presets_payload(last_action=_server_action_message(action, name))
payload["requires_restart"] = True payload["requires_restart"] = True
return payload return payload
if action in {"import", "import-cursor"}: if action in {"import", "import-cursor"}:
servers = _import_mcp_servers(_query_first(query, "config")) servers = _import_mcp_servers(_query_first(query, "config"))
config.tools.mcp_servers.update(servers) update_config(lambda config: config.tools.mcp_servers.update(servers))
save_config(config)
payload = mcp_presets_payload(last_action={ payload = mcp_presets_payload(last_action={
"ok": True, "ok": True,
"message": f"Imported {len(servers)} MCP server(s).", "message": f"Imported {len(servers)} MCP server(s).",
@ -1202,12 +1199,15 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
if action == "tools": if action == "tools":
name = _validated_server_name((_query_first(query, "name") or "").strip()) name = _validated_server_name((_query_first(query, "name") or "").strip())
cfg = config.tools.mcp_servers.get(name)
if cfg is None: def mutate(config: Config) -> None:
raise McpPresetError("unknown MCP server", status=404) cfg = config.tools.mcp_servers.get(name)
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools")) if cfg is None:
config.tools.mcp_servers[name] = cfg raise McpPresetError("unknown MCP server", status=404)
save_config(config) cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
config.tools.mcp_servers[name] = cfg
update_config(mutate)
payload = mcp_presets_payload(last_action=_server_action_message(action, name)) payload = mcp_presets_payload(last_action=_server_action_message(action, name))
payload["requires_restart"] = True payload["requires_restart"] = True
return payload return payload
@ -1221,31 +1221,42 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
raise McpPresetError("missing MCP preset name") raise McpPresetError("missing MCP preset name")
preset = _preset_by_name_optional(name) preset = _preset_by_name_optional(name)
config = load_config()
existing = config.tools.mcp_servers.get(name)
if action == "enable": if action == "enable":
if preset is None: if preset is None:
raise McpPresetError("unknown MCP preset", status=404) raise McpPresetError("unknown MCP preset", status=404)
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
save_config(config) def mutate(config: Config) -> None:
existing = config.tools.mcp_servers.get(name)
config.tools.mcp_servers[preset.name] = _materialize_server(
preset,
query,
existing,
)
update_config(mutate)
payload = mcp_presets_payload(last_action=_action_message(action, preset)) payload = mcp_presets_payload(last_action=_action_message(action, preset))
payload["requires_restart"] = True payload["requires_restart"] = True
return payload return payload
if action == "remove": if action == "remove":
if preset is None and name not in config.tools.mcp_servers:
raise McpPresetError("unknown MCP server", status=404)
removed_runtime_files = False removed_runtime_files = False
cleanup_error = "" cleanup_error = ""
if name in config.tools.mcp_servers: removed: dict[str, MCPServerConfig] = {}
existing_cfg = config.tools.mcp_servers[name]
def mutate(config: Config) -> None:
existing_cfg = config.tools.mcp_servers.get(name)
if preset is None and existing_cfg is None:
raise McpPresetError("unknown MCP server", status=404)
if existing_cfg is not None:
removed[name] = existing_cfg
del config.tools.mcp_servers[name]
update_config(mutate)
if existing_cfg := removed.get(name):
try: try:
removed_runtime_files = _remove_managed_stdio_cwd(name, existing_cfg) removed_runtime_files = _remove_managed_stdio_cwd(name, existing_cfg)
except OSError as exc: except OSError as exc:
cleanup_error = str(exc) cleanup_error = str(exc)
del config.tools.mcp_servers[name]
save_config(config)
last_action = ( last_action = (
_action_message(action, preset) _action_message(action, preset)
if preset is not None if preset is not None

View File

@ -22,8 +22,13 @@ from nanobot.audio.transcription_registry import (
resolve_transcription_provider, resolve_transcription_provider,
transcription_provider_names, transcription_provider_names,
) )
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config from nanobot.config.loader import (
from nanobot.config.schema import ModelPresetConfig, ProviderConfig get_config_path,
load_config,
resolve_config_env_vars,
update_config,
)
from nanobot.config.schema import Config, ModelPresetConfig, ProviderConfig
from nanobot.providers.image_generation import ( from nanobot.providers.image_generation import (
get_image_gen_provider, get_image_gen_provider,
image_gen_provider_names, image_gen_provider_names,
@ -961,100 +966,86 @@ def settings_usage_payload() -> dict[str, Any]:
def update_agent_settings(query: QueryParams) -> dict[str, Any]: def update_agent_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
defaults = config.agents.defaults
changed = False
restart_required = False restart_required = False
if "model_preset" in query or "modelPreset" in query: def mutate(config: Config) -> None:
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip() nonlocal restart_required
preset_value = None if not preset or preset == "default" else preset defaults = config.agents.defaults
if preset_value is not None and preset_value not in config.model_presets:
raise WebUISettingsError("unknown model preset") if "model_preset" in query or "modelPreset" in query:
if defaults.model_preset != preset_value: preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
preset_value = None if not preset or preset == "default" else preset
if preset_value is not None and preset_value not in config.model_presets:
raise WebUISettingsError("unknown model preset")
defaults.model_preset = preset_value defaults.model_preset = preset_value
changed = True
model = _query_first(query, "model") model = _query_first(query, "model")
if model is not None: if model is not None:
model = model.strip() model = model.strip()
if not model: if not model:
raise WebUISettingsError("model is required") raise WebUISettingsError("model is required")
if defaults.model != model:
defaults.model = model defaults.model = model
changed = True
provider = _query_first(query, "provider") provider = _query_first(query, "provider")
if provider is not None: if provider is not None:
provider = provider.strip() provider = provider.strip()
if not provider: if not provider:
raise WebUISettingsError("provider is required") raise WebUISettingsError("provider is required")
_validate_configured_provider(config, provider) _validate_configured_provider(config, provider)
if defaults.provider != provider:
defaults.provider = provider defaults.provider = provider
changed = True
context_window_tokens = _parse_context_window_tokens( context_window_tokens = _parse_context_window_tokens(
_query_first_alias(query, "context_window_tokens", "contextWindowTokens") _query_first_alias(query, "context_window_tokens", "contextWindowTokens")
) )
if ( if context_window_tokens is not None:
context_window_tokens is not None defaults.context_window_tokens = context_window_tokens
and defaults.context_window_tokens != context_window_tokens
):
defaults.context_window_tokens = context_window_tokens
changed = True
timezone = _query_first(query, "timezone") timezone = _query_first(query, "timezone")
if timezone is not None: if timezone is not None:
timezone = timezone.strip() timezone = timezone.strip()
if not timezone: if not timezone:
raise WebUISettingsError("timezone is required") raise WebUISettingsError("timezone is required")
try: try:
ZoneInfo(timezone) ZoneInfo(timezone)
except Exception: except Exception:
raise WebUISettingsError("invalid timezone") from None raise WebUISettingsError("invalid timezone") from None
if defaults.timezone != timezone: if defaults.timezone != timezone:
defaults.timezone = timezone defaults.timezone = timezone
changed = True restart_required = True
restart_required = True
bot_name = _query_first_alias(query, "bot_name", "botName") bot_name = _query_first_alias(query, "bot_name", "botName")
if bot_name is not None: if bot_name is not None:
bot_name = bot_name.strip() bot_name = bot_name.strip()
if not bot_name: if not bot_name:
raise WebUISettingsError("bot_name is required") raise WebUISettingsError("bot_name is required")
if defaults.bot_name != bot_name: if defaults.bot_name != bot_name:
defaults.bot_name = bot_name defaults.bot_name = bot_name
changed = True restart_required = True
restart_required = True
bot_icon = _query_first_alias(query, "bot_icon", "botIcon") bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
if bot_icon is not None: if bot_icon is not None:
bot_icon = bot_icon.strip() bot_icon = bot_icon.strip()
if defaults.bot_icon != bot_icon: if defaults.bot_icon != bot_icon:
defaults.bot_icon = bot_icon defaults.bot_icon = bot_icon
changed = True restart_required = True
restart_required = True
tool_hint_max_length = _query_first_alias( tool_hint_max_length = _query_first_alias(
query, query,
"tool_hint_max_length", "tool_hint_max_length",
"toolHintMaxLength", "toolHintMaxLength",
) )
if tool_hint_max_length is not None: if tool_hint_max_length is not None:
try: try:
parsed = int(tool_hint_max_length) parsed = int(tool_hint_max_length)
except ValueError: except ValueError:
raise WebUISettingsError("tool_hint_max_length must be an integer") from None raise WebUISettingsError("tool_hint_max_length must be an integer") from None
if parsed < 20 or parsed > 500: if parsed < 20 or parsed > 500:
raise WebUISettingsError("tool_hint_max_length must be between 20 and 500") raise WebUISettingsError("tool_hint_max_length must be between 20 and 500")
if defaults.tool_hint_max_length != parsed: if defaults.tool_hint_max_length != parsed:
defaults.tool_hint_max_length = parsed defaults.tool_hint_max_length = parsed
changed = True restart_required = True
restart_required = True
if changed: update_config(mutate)
save_config(config)
return settings_payload(requires_restart=restart_required) return settings_payload(requires_restart=restart_required)
@ -1072,23 +1063,23 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
raise WebUISettingsError("provider is required") raise WebUISettingsError("provider is required")
name = _model_configuration_slug(raw_name or label) name = _model_configuration_slug(raw_name or label)
config = load_config() def mutate(config: Config) -> None:
if name in config.model_presets: if name in config.model_presets:
raise WebUISettingsError("configuration already exists", status=409) raise WebUISettingsError("configuration already exists", status=409)
_validate_configured_provider(config, provider) _validate_configured_provider(config, provider)
base = config.resolve_default_preset()
config.model_presets[name] = ModelPresetConfig(
label=label,
model=model,
provider=provider,
max_tokens=base.max_tokens,
context_window_tokens=base.context_window_tokens,
temperature=base.temperature,
reasoning_effort=base.reasoning_effort,
)
config.agents.defaults.model_preset = name
base = config.resolve_default_preset() update_config(mutate)
config.model_presets[name] = ModelPresetConfig(
label=label,
model=model,
provider=provider,
max_tokens=base.max_tokens,
context_window_tokens=base.context_window_tokens,
temperature=base.temperature,
reasoning_effort=base.reasoning_effort,
)
config.agents.defaults.model_preset = name
save_config(config)
return settings_payload() return settings_payload()
@ -1097,56 +1088,41 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
if not name or name == "default": if not name or name == "default":
raise WebUISettingsError("model configuration is required") raise WebUISettingsError("model configuration is required")
config = load_config() def mutate(config: Config) -> None:
preset = config.model_presets.get(name) preset = config.model_presets.get(name)
if preset is None: if preset is None:
raise WebUISettingsError("unknown model configuration") raise WebUISettingsError("unknown model configuration")
changed = False label = _query_first_alias(query, "label", "displayName")
label = _query_first_alias(query, "label", "displayName") if label is not None:
if label is not None: label = label.strip()
label = label.strip() if not label:
if not label: raise WebUISettingsError("label is required")
raise WebUISettingsError("label is required")
if preset.label != label:
preset.label = label preset.label = label
changed = True
model = _query_first(query, "model") model = _query_first(query, "model")
if model is not None: if model is not None:
model = model.strip() model = model.strip()
if not model: if not model:
raise WebUISettingsError("model is required") raise WebUISettingsError("model is required")
if preset.model != model:
preset.model = model preset.model = model
changed = True
provider = _query_first(query, "provider") provider = _query_first(query, "provider")
if provider is not None: if provider is not None:
provider = provider.strip() provider = provider.strip()
if not provider: if not provider:
raise WebUISettingsError("provider is required") raise WebUISettingsError("provider is required")
_validate_configured_provider(config, provider) _validate_configured_provider(config, provider)
if preset.provider != provider:
preset.provider = provider preset.provider = provider
changed = True
context_window_tokens = _parse_context_window_tokens( context_window_tokens = _parse_context_window_tokens(
_query_first_alias(query, "context_window_tokens", "contextWindowTokens") _query_first_alias(query, "context_window_tokens", "contextWindowTokens")
) )
if ( if context_window_tokens is not None:
context_window_tokens is not None preset.context_window_tokens = context_window_tokens
and preset.context_window_tokens != context_window_tokens
):
preset.context_window_tokens = context_window_tokens
changed = True
if config.agents.defaults.model_preset != name:
config.agents.defaults.model_preset = name config.agents.defaults.model_preset = name
changed = True
if changed: update_config(mutate)
save_config(config)
return settings_payload() return settings_payload()
@ -1155,43 +1131,37 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
if not provider_name: if not provider_name:
raise WebUISettingsError("provider is required") raise WebUISettingsError("provider is required")
config = load_config() provider_key = ""
resolved_provider = _resolve_settings_provider(config, provider_name)
if resolved_provider is None:
raise WebUISettingsError("unknown provider")
spec, provider_key, provider_config = resolved_provider
if spec.is_oauth:
raise WebUISettingsError("unknown provider")
changed = False def mutate(config: Config) -> None:
if "api_key" in query or "apiKey" in query: nonlocal provider_key
api_key = _query_first_alias(query, "api_key", "apiKey") resolved_provider = _resolve_settings_provider(config, provider_name)
api_key = (api_key or "").strip() or None if resolved_provider is None:
if provider_config.api_key != api_key: raise WebUISettingsError("unknown provider")
provider_config.api_key = api_key spec, provider_key, provider_config = resolved_provider
changed = True if spec.is_oauth:
raise WebUISettingsError("unknown provider")
if "api_base" in query or "apiBase" in query: if "api_key" in query or "apiKey" in query:
api_base = _query_first_alias(query, "api_base", "apiBase") api_key = _query_first_alias(query, "api_key", "apiKey")
api_base = (api_base or "").strip() or None provider_config.api_key = (api_key or "").strip() or None
if provider_config.api_base != api_base:
provider_config.api_base = api_base
changed = True
if "api_type" in query: if "api_base" in query or "apiBase" in query:
if spec.name == "openai": api_base = _query_first_alias(query, "api_base", "apiBase")
provider_config.api_base = (api_base or "").strip() or None
if "api_type" in query and spec.name == "openai":
api_type = (_query_first(query, "api_type") or "").strip() api_type = (_query_first(query, "api_type") or "").strip()
try: try:
parsed_api_type = type(provider_config)(api_type=api_type).api_type provider_config.api_type = type(provider_config)(api_type=api_type).api_type
except Exception: except Exception:
raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None raise WebUISettingsError(
if provider_config.api_type != parsed_api_type: "api_type must be auto, chat_completions, or responses"
provider_config.api_type = parsed_api_type ) from None
changed = True
if changed: commit = update_config(mutate)
save_config(config) changed = bool(commit.changed_paths)
image_config = config.tools.image_generation image_config = commit.after.config.tools.image_generation
restart_required = ( restart_required = (
changed changed
and image_config.enabled and image_config.enabled
@ -1299,16 +1269,18 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
if raw_allow is None and raw_default_access_mode is None: if raw_allow is None and raw_default_access_mode is None:
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required") raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
config = load_config()
changed = False
if raw_allow is not None: if raw_allow is not None:
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access") webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
if config.tools.webui_allow_local_service_access != webui_allow_local_service_access: commit = update_config(
config.tools.webui_allow_local_service_access = webui_allow_local_service_access lambda config: setattr(
changed = True config.tools,
"webui_allow_local_service_access",
if changed: webui_allow_local_service_access,
save_config(config) )
)
changed = bool(commit.changed_paths)
else:
changed = False
if raw_default_access_mode is not None: if raw_default_access_mode is not None:
default_access_mode = raw_default_access_mode.strip().lower() default_access_mode = raw_default_access_mode.strip().lower()
if default_access_mode == "restricted": if default_access_mode == "restricted":
@ -1328,134 +1300,120 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
if provider_option is None: if provider_option is None:
raise WebUISettingsError("unknown web search provider") raise WebUISettingsError("unknown web search provider")
config = load_config()
search_config = config.tools.web.search
web_config = config.tools.web
previous_provider = search_config.provider
changed = False
restart_required = False restart_required = False
def set_search_value(attr: str, value: object) -> None: def mutate(config: Config) -> None:
nonlocal changed nonlocal restart_required
if getattr(search_config, attr) != value: search_config = config.tools.web.search
setattr(search_config, attr, value) web_config = config.tools.web
changed = True previous_provider = search_config.provider
def set_fetch_value(attr: str, value: object) -> None:
nonlocal changed
if getattr(web_config.fetch, attr) != value:
setattr(web_config.fetch, attr, value)
changed = True
if search_config.provider != provider_name:
search_config.provider = provider_name search_config.provider = provider_name
changed = True
credential = provider_option["credential"] credential = provider_option["credential"]
if credential == "none": if credential == "none":
set_search_value("api_key", "") search_config.api_key = ""
set_search_value("base_url", "") search_config.base_url = ""
elif credential == "base_url": elif credential == "base_url":
base_url = _query_first_alias(query, "base_url", "baseUrl") base_url = _query_first_alias(query, "base_url", "baseUrl")
base_url = base_url.strip() if base_url is not None else None base_url = base_url.strip() if base_url is not None else None
if not base_url and previous_provider == provider_name and search_config.base_url: if not base_url and previous_provider == provider_name and search_config.base_url:
base_url = search_config.base_url base_url = search_config.base_url
if not base_url: if not base_url:
raise WebUISettingsError("base_url is required") raise WebUISettingsError("base_url is required")
set_search_value("base_url", base_url) search_config.base_url = base_url
set_search_value("api_key", "") search_config.api_key = ""
elif credential in {"api_key", "optional_api_key"}: elif credential in {"api_key", "optional_api_key"}:
raw_api_key = _query_first_alias(query, "api_key", "apiKey") raw_api_key = _query_first_alias(query, "api_key", "apiKey")
api_key = raw_api_key.strip() if raw_api_key is not None else None api_key = raw_api_key.strip() if raw_api_key is not None else None
if api_key is None and previous_provider == provider_name and search_config.api_key: if api_key is None and previous_provider == provider_name and search_config.api_key:
api_key = search_config.api_key api_key = search_config.api_key
if credential == "api_key" and not api_key: if credential == "api_key" and not api_key:
raise WebUISettingsError("api_key is required") raise WebUISettingsError("api_key is required")
set_search_value("api_key", api_key or "") search_config.api_key = api_key or ""
set_search_value("base_url", "") search_config.base_url = ""
else: else:
raise WebUISettingsError("unknown web search credential type") raise WebUISettingsError("unknown web search credential type")
max_results = _query_first_alias(query, "max_results", "maxResults") max_results = _query_first_alias(query, "max_results", "maxResults")
if max_results is not None: if max_results is not None:
try: try:
parsed = int(max_results) parsed = int(max_results)
except ValueError: except ValueError:
raise WebUISettingsError("max_results must be an integer") from None raise WebUISettingsError("max_results must be an integer") from None
if parsed < 1 or parsed > 10: if parsed < 1 or parsed > 10:
raise WebUISettingsError("max_results must be between 1 and 10") raise WebUISettingsError("max_results must be between 1 and 10")
set_search_value("max_results", parsed) search_config.max_results = parsed
timeout = _query_first(query, "timeout") timeout = _query_first(query, "timeout")
if timeout is not None: if timeout is not None:
try: try:
parsed_timeout = int(timeout) parsed_timeout = int(timeout)
except ValueError: except ValueError:
raise WebUISettingsError("timeout must be an integer") from None raise WebUISettingsError("timeout must be an integer") from None
if parsed_timeout < 1 or parsed_timeout > 120: if parsed_timeout < 1 or parsed_timeout > 120:
raise WebUISettingsError("timeout must be between 1 and 120") raise WebUISettingsError("timeout must be between 1 and 120")
set_search_value("timeout", parsed_timeout) search_config.timeout = parsed_timeout
use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader") use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader")
if use_jina_reader is not None: if use_jina_reader is not None:
normalized = use_jina_reader.strip().lower() normalized = use_jina_reader.strip().lower()
if normalized not in {"1", "0", "true", "false", "yes", "no"}: if normalized not in {"1", "0", "true", "false", "yes", "no"}:
raise WebUISettingsError("use_jina_reader must be boolean") raise WebUISettingsError("use_jina_reader must be boolean")
previous_jina_reader = web_config.fetch.use_jina_reader enabled = normalized in {"1", "true", "yes"}
set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"}) if web_config.fetch.use_jina_reader != enabled:
if web_config.fetch.use_jina_reader != previous_jina_reader: web_config.fetch.use_jina_reader = enabled
restart_required = True restart_required = True
if changed: update_config(mutate)
save_config(config)
return settings_payload(requires_restart=restart_required) return settings_payload(requires_restart=restart_required)
def update_api_settings(query: QueryParams) -> dict[str, Any]: def update_api_settings(query: QueryParams) -> dict[str, Any]:
"""Update the managed OpenAI-compatible API configuration.""" """Update the managed OpenAI-compatible API configuration."""
config = load_config() def mutate(config: Config) -> None:
api = config.api api = config.api
host = _query_first(query, "host") host = _query_first(query, "host")
if host is not None: if host is not None:
host = host.strip() host = host.strip()
if not host: if not host:
raise WebUISettingsError("host is required") raise WebUISettingsError("host is required")
api.host = host api.host = host
port = _query_first(query, "port") port = _query_first(query, "port")
if port is not None: if port is not None:
try: try:
parsed_port = int(port) parsed_port = int(port)
except ValueError: except ValueError:
raise WebUISettingsError("port must be an integer") from None raise WebUISettingsError("port must be an integer") from None
if parsed_port < 1 or parsed_port > 65535: if parsed_port < 1 or parsed_port > 65535:
raise WebUISettingsError("port must be between 1 and 65535") raise WebUISettingsError("port must be between 1 and 65535")
api.port = parsed_port api.port = parsed_port
timeout = _query_first(query, "timeout") timeout = _query_first(query, "timeout")
if timeout is not None: if timeout is not None:
try: try:
parsed_timeout = float(timeout) parsed_timeout = float(timeout)
except ValueError: except ValueError:
raise WebUISettingsError("timeout must be a number") from None raise WebUISettingsError("timeout must be a number") from None
if parsed_timeout < 1 or parsed_timeout > 3600: if parsed_timeout < 1 or parsed_timeout > 3600:
raise WebUISettingsError("timeout must be between 1 and 3600") raise WebUISettingsError("timeout must be between 1 and 3600")
api.timeout = parsed_timeout api.timeout = parsed_timeout
api_key = _query_first_alias(query, "api_key", "apiKey") api_key = _query_first_alias(query, "api_key", "apiKey")
if api_key is not None: if api_key is not None:
api.api_key = api_key.strip() api.api_key = api_key.strip()
if not is_loopback_host(api.host) and not api.api_key.strip(): if not is_loopback_host(api.host) and not api.api_key.strip():
raise WebUISettingsError("an API key is required when the API is available on the network") raise WebUISettingsError(
"an API key is required when the API is available on the network"
)
save_config(config) update_config(mutate)
return settings_payload() return settings_payload()
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]: def _apply_image_generation_settings(config: Config, query: QueryParams) -> bool:
config = load_config()
image_config = config.tools.image_generation image_config = config.tools.image_generation
changed = False changed = False
@ -1547,13 +1505,21 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
if not selected_provider or not selected_provider["configured"]: if not selected_provider or not selected_provider["configured"]:
raise WebUISettingsError("image generation provider is not configured") raise WebUISettingsError("image generation provider is not configured")
if changed: return changed
save_config(config)
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
changed = False
def mutate(config: Config) -> None:
nonlocal changed
changed = _apply_image_generation_settings(config, query)
update_config(mutate)
return settings_payload(requires_restart=changed) return settings_payload(requires_restart=changed)
def update_transcription_settings(query: QueryParams) -> dict[str, Any]: def _apply_transcription_settings(config: Config, query: QueryParams) -> bool:
config = load_config()
transcription = config.transcription transcription = config.transcription
changed = False changed = False
@ -1617,6 +1583,12 @@ def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
transcription.max_upload_mb = parsed_upload transcription.max_upload_mb = parsed_upload
changed = True changed = True
if changed: return changed
save_config(config)
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
def mutate(config: Config) -> None:
_apply_transcription_settings(config, query)
update_config(mutate)
return settings_payload() return settings_payload()

View File

@ -21,7 +21,7 @@ from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels._setup import channel_setup_spec from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.loader import get_config_path, load_config, update_config
from nanobot.optional_features import ( from nanobot.optional_features import (
OptionalFeatureError, OptionalFeatureError,
extra_installed, extra_installed,
@ -773,51 +773,56 @@ class WebUISettingsRouter:
if not raw_values: if not raw_values:
return [] return []
config = load_config()
section = getattr(config.channels, name, None)
if name == "feishu":
from nanobot.channels._feishu_instances import feishu_instance_specs
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(section, FeishuChannel.default_config())
selected = next((spec for spec in specs if spec.instance_id == instance_id), None)
channel_config = dict(selected.config) if selected is not None else {}
elif hasattr(section, "model_dump"):
channel_config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
channel_config = dict(section)
else:
channel_config = {}
saved: list[str] = [] saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key:
raise WebUISettingsError("channel settings payload contains an invalid key")
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = self._coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
self._assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
if name == "feishu": def mutate(config: Any) -> None:
from nanobot.channels._feishu_instances import upsert_feishu_instance section = getattr(config.channels, name, None)
from nanobot.channels.feishu import FeishuChannel if name == "feishu":
from nanobot.channels._feishu_instances import feishu_instance_specs
from nanobot.channels.feishu import FeishuChannel
existing = getattr(config.channels, name, None) specs = feishu_instance_specs(section, FeishuChannel.default_config())
channel_config = upsert_feishu_instance( selected = next(
existing if isinstance(existing, dict) else {}, (spec for spec in specs if spec.instance_id == instance_id),
FeishuChannel.default_config(), None,
instance_id, )
channel_config, channel_config = dict(selected.config) if selected is not None else {}
) elif hasattr(section, "model_dump"):
channel_config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
channel_config = dict(section)
else:
channel_config = {}
setattr(config.channels, name, channel_config) prefix = f"channels.{name}."
save_config(config) for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key:
raise WebUISettingsError("channel settings payload contains an invalid key")
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = self._coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
self._assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
if name == "feishu":
from nanobot.channels._feishu_instances import upsert_feishu_instance
from nanobot.channels.feishu import FeishuChannel
existing = getattr(config.channels, name, None)
channel_config = upsert_feishu_instance(
existing if isinstance(existing, dict) else {},
FeishuChannel.default_config(),
instance_id,
channel_config,
)
setattr(config.channels, name, channel_config)
update_config(mutate)
return saved return saved
@staticmethod @staticmethod

View File

@ -20,6 +20,14 @@ def test_load_config_invalid_json_fails_fast(tmp_path) -> None:
load_config(config_path) load_config(config_path)
def test_load_config_rejects_non_object_root(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text("[]", encoding="utf-8")
with pytest.raises(ValueError, match="config root must be a JSON object"):
load_config(config_path)
def test_load_config_invalid_schema_fails_fast(tmp_path) -> None: def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
config_path = tmp_path / "config.json" config_path = tmp_path / "config.json"
config_path.write_text( config_path.write_text(

View File

@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest import pytest
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import apply_config_runtime_policies, load_config, save_config
from nanobot.security.network import validate_url_target from nanobot.security.network import validate_url_target
@ -244,7 +244,7 @@ def test_new_my_tool_keys_take_precedence_over_legacy(tmp_path) -> None:
assert config.tools.my.allow_set is True assert config.tools.my.allow_set is True
def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -> None: def test_runtime_policy_application_resets_ssrf_whitelist(tmp_path) -> None:
whitelisted = tmp_path / "whitelisted.json" whitelisted = tmp_path / "whitelisted.json"
whitelisted.write_text( whitelisted.write_text(
json.dumps({"tools": {"ssrfWhitelist": ["100.64.0.0/10"]}}), json.dumps({"tools": {"ssrfWhitelist": ["100.64.0.0/10"]}}),
@ -253,12 +253,12 @@ def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -
defaulted = tmp_path / "defaulted.json" defaulted = tmp_path / "defaulted.json"
defaulted.write_text(json.dumps({}), encoding="utf-8") defaulted.write_text(json.dumps({}), encoding="utf-8")
load_config(whitelisted) apply_config_runtime_policies(load_config(whitelisted))
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
ok, err = validate_url_target("http://ts.local/api") ok, err = validate_url_target("http://ts.local/api")
assert ok, err assert ok, err
load_config(defaulted) apply_config_runtime_policies(load_config(defaulted))
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
ok, _ = validate_url_target("http://ts.local/api") ok, _ = validate_url_target("http://ts.local/api")
assert not ok assert not ok

View File

@ -0,0 +1,191 @@
import json
import os
import socket
import stat
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
from nanobot.config.loader import load_config
from nanobot.config.repository import ConfigConflictError, FileConfigRepository
from nanobot.security.network import configure_ssrf_whitelist, validate_url_target
def _fake_resolve(host: str, results: list[str]):
def _resolver(hostname, port, family=0, type_=0):
if hostname == host:
return [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0))
for ip in results
]
raise socket.gaierror(f"cannot resolve {hostname}")
return _resolver
def test_raw_and_effective_snapshots_keep_secret_templates_separate(
tmp_path: Path,
monkeypatch,
) -> None:
path = tmp_path / "config.json"
path.write_text(
json.dumps({"providers": {"groq": {"apiKey": "${GROQ_TOKEN}"}}}),
encoding="utf-8",
)
monkeypatch.setenv("GROQ_TOKEN", "resolved-secret")
repository = FileConfigRepository(path)
raw = repository.load_raw()
effective = repository.load_effective()
assert raw.config.providers.groq.api_key == "${GROQ_TOKEN}"
assert effective.config.providers.groq.api_key == "resolved-secret"
assert effective.config is not raw.config
assert effective.revision == raw.revision
def test_update_uses_latest_raw_config_and_reports_changed_paths(
tmp_path: Path,
monkeypatch,
) -> None:
path = tmp_path / "config.json"
path.write_text(
json.dumps({"providers": {"groq": {"apiKey": "${GROQ_TOKEN}"}}}),
encoding="utf-8",
)
monkeypatch.setenv("GROQ_TOKEN", "resolved-secret")
repository = FileConfigRepository(path)
commit = repository.update(
lambda config: setattr(config.agents.defaults, "timezone", "Asia/Shanghai")
)
saved = json.loads(path.read_text(encoding="utf-8"))
assert saved["providers"]["groq"]["apiKey"] == "${GROQ_TOKEN}"
assert saved["agents"]["defaults"]["timezone"] == "Asia/Shanghai"
assert commit.before.revision != commit.after.revision
assert commit.changed_paths == frozenset({"agents.defaults.timezone"})
def test_update_rejects_stale_expected_revision(tmp_path: Path) -> None:
path = tmp_path / "config.json"
repository = FileConfigRepository(path)
initial = repository.load_raw()
repository.update(lambda config: setattr(config.api, "port", 9001))
with pytest.raises(ConfigConflictError, match="Config changed"):
repository.update(
lambda config: setattr(config.api, "port", 9002),
expected_revision=initial.revision,
)
assert repository.load_raw().config.api.port == 9001
def test_repositories_for_same_path_serialize_updates(tmp_path: Path) -> None:
path = tmp_path / "config.json"
first = FileConfigRepository(path)
second = FileConfigRepository(path)
first_mutator_entered = threading.Event()
release_first = threading.Event()
second_mutator_entered = threading.Event()
def update_first() -> None:
def mutate(config):
first_mutator_entered.set()
assert release_first.wait(timeout=2)
config.api.port = 9001
first.update(mutate)
def update_second() -> None:
def mutate(config):
second_mutator_entered.set()
config.agents.defaults.timezone = "Asia/Shanghai"
second.update(mutate)
first_thread = threading.Thread(target=update_first)
second_thread = threading.Thread(target=update_second)
first_thread.start()
assert first_mutator_entered.wait(timeout=2)
second_thread.start()
assert not second_mutator_entered.wait(timeout=0.1)
release_first.set()
first_thread.join(timeout=2)
second_thread.join(timeout=2)
config = first.load_raw().config
assert config.api.port == 9001
assert config.agents.defaults.timezone == "Asia/Shanghai"
def test_repositories_for_different_paths_are_isolated(tmp_path: Path) -> None:
first_path = tmp_path / "first.json"
second_path = tmp_path / "second.json"
first = FileConfigRepository(first_path)
second = FileConfigRepository(second_path)
first.update(lambda config: setattr(config.api, "port", 9001))
second.update(lambda config: setattr(config.api, "port", 9002))
assert first.load_raw().config.api.port == 9001
assert second.load_raw().config.api.port == 9002
assert json.loads(first_path.read_text(encoding="utf-8"))["api"]["port"] == 9001
assert json.loads(second_path.read_text(encoding="utf-8"))["api"]["port"] == 9002
def test_noop_update_keeps_revision_and_does_not_rewrite(tmp_path: Path) -> None:
path = tmp_path / "config.json"
repository = FileConfigRepository(path)
repository.update(lambda config: setattr(config.api, "port", 9001))
before = repository.load_raw()
commit = repository.update(lambda config: setattr(config.api, "port", 9001))
assert commit.changed_paths == frozenset()
assert commit.before.revision == before.revision
assert commit.after.revision == before.revision
def test_atomic_save_keeps_previous_file_when_replace_fails(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text('{"api": {"port": 9000}}', encoding="utf-8")
repository = FileConfigRepository(path)
with patch("nanobot.config.repository.os.replace", side_effect=OSError("replace failed")):
with pytest.raises(OSError, match="replace failed"):
repository.update(lambda config: setattr(config.api, "port", 9001))
assert json.loads(path.read_text(encoding="utf-8"))["api"]["port"] == 9000
assert list(tmp_path.glob(".config.json.*.tmp")) == []
@pytest.mark.skipif(os.name == "nt", reason="Windows does not expose POSIX file modes")
def test_atomic_save_preserves_existing_file_mode(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text('{"api": {"port": 9000}}', encoding="utf-8")
path.chmod(0o600)
FileConfigRepository(path).update(lambda config: setattr(config.api, "port", 9001))
assert stat.S_IMODE(path.stat().st_mode) == 0o600
def test_loading_config_does_not_change_process_network_policy(tmp_path: Path) -> None:
path = tmp_path / "config.json"
path.write_text(json.dumps({"tools": {"ssrfWhitelist": []}}), encoding="utf-8")
configure_ssrf_whitelist(["100.64.0.0/10"])
try:
load_config(path)
with patch(
"nanobot.security.network.socket.getaddrinfo",
_fake_resolve("ts.local", ["100.100.1.1"]),
):
ok, error = validate_url_target("http://ts.local/api")
assert ok, error
finally:
configure_ssrf_whitelist([])