Files
ComfyUI-OpenClaw/services/runtime_config.py
T

627 lines
21 KiB
Python

"""
Runtime Config Service (R21/S13/R70).
Manages non-secret LLM configuration with precedence, validation, and persistence.
R70: Strict settings registration + schema-coerced writes.
"""
import json
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("ComfyUI-OpenClaw.services.runtime_config")
# R70: Settings schema registry (type coercion + unknown-key rejection)
try:
from .settings_schema import coerce_dict as _schema_coerce
from .settings_schema import get_schema_map
from .settings_schema import is_registered as _schema_registered
except ImportError:
try:
from services.settings_schema import (
coerce_dict as _schema_coerce, # type: ignore
)
from services.settings_schema import get_schema_map
from services.settings_schema import is_registered as _schema_registered
except ImportError:
# Fail-open: no schema enforcement if module missing
def _schema_coerce(updates): # type: ignore
return updates, []
def get_schema_map(): # type: ignore
return {}
def _schema_registered(key): # type: ignore
return True
# Config file location (under state dir)
try:
# Prefer package-relative imports when running as a ComfyUI custom node pack.
from .state_dir import get_state_dir
CONFIG_FILE = os.path.join(get_state_dir(), "config.json")
from .providers.catalog import PROVIDER_CATALOG
from .safe_io import SSRFError, is_private_ip, validate_outbound_url
except ImportError:
try:
# Fallback for direct sys.path imports (unit tests / scripts)
from services.state_dir import get_state_dir # type: ignore
CONFIG_FILE = os.path.join(get_state_dir(), "config.json")
from services.providers.catalog import PROVIDER_CATALOG # type: ignore
from services.safe_io import is_private_ip # type: ignore
from services.safe_io import SSRFError, validate_outbound_url
except ImportError:
CONFIG_FILE = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "data", "config.json"
)
# Fallback to empty if missing
PROVIDER_CATALOG = {}
# Mock for validation if missing (Fail Closed)
class SSRFError(ValueError):
pass
def validate_outbound_url(url, **kwargs):
raise SSRFError(
"Security dependencies missing: Cannot validate URL safety."
)
def is_private_ip(ip):
return True # Assume unsafe if missing
# Allowed config keys (whitelist)
ALLOWED_LLM_KEYS = {
"provider",
"model",
"base_url",
"timeout_sec",
"max_retries",
# R14: Failover config
"fallback_models",
"fallback_providers",
"max_failover_candidates",
}
ALLOWED_SCHEDULER_KEYS = {
"startup_jitter_sec",
"max_runs_per_tick",
"skip_missed_intervals",
}
# Default values
DEFAULTS = {
"llm": {
"provider": "openai",
"model": "gpt-4o-mini",
"base_url": "",
"timeout_sec": 120,
"max_retries": 3,
# R14: Failover defaults (empty = disabled)
"fallback_models": [],
"fallback_providers": [],
"max_failover_candidates": 3,
},
"scheduler": {
"startup_jitter_sec": 30,
"max_runs_per_tick": 5,
"skip_missed_intervals": False,
},
}
# Value constraints
CONSTRAINTS = {
"timeout_sec": (5, 300),
"max_retries": (0, 10),
"max_failover_candidates": (1, 5), # R14: Limit total candidates
}
SCHEDULER_CONSTRAINTS = {
"startup_jitter_sec": (0, 300),
"max_runs_per_tick": (1, 100),
}
# Environment variable mappings (new, legacy)
ENV_MAPPINGS = {
"provider": ("OPENCLAW_LLM_PROVIDER", "MOLTBOT_LLM_PROVIDER"),
"model": ("OPENCLAW_LLM_MODEL", "MOLTBOT_LLM_MODEL"),
"base_url": ("OPENCLAW_LLM_BASE_URL", "MOLTBOT_LLM_BASE_URL"),
"timeout_sec": ("OPENCLAW_LLM_TIMEOUT", "MOLTBOT_LLM_TIMEOUT"),
"max_retries": ("OPENCLAW_LLM_MAX_RETRIES", "MOLTBOT_LLM_MAX_RETRIES"),
# R14: Failover env vars
"fallback_models": ("OPENCLAW_FALLBACK_MODELS", "MOLTBOT_FALLBACK_MODELS"),
"fallback_providers": ("OPENCLAW_FALLBACK_PROVIDERS", "MOLTBOT_FALLBACK_PROVIDERS"),
"max_failover_candidates": (
"OPENCLAW_MAX_FAILOVER_CANDIDATES",
"MOLTBOT_MAX_FAILOVER_CANDIDATES",
),
}
SCHEDULER_ENV_MAPPINGS = {
"startup_jitter_sec": ("OPENCLAW_SCHEDULER_STARTUP_JITTER_SEC", ""),
"max_runs_per_tick": ("OPENCLAW_SCHEDULER_MAX_RUNS_PER_TICK", ""),
"skip_missed_intervals": ("OPENCLAW_SCHEDULER_SKIP_MISSED", ""),
}
# IMPORTANT:
# Keep effective-config merge order deterministic.
# Using a set iteration here makes legacy warning assertions flaky because the
# first env key read can vary per process/hash seed.
LLM_KEY_ORDER = tuple(ENV_MAPPINGS.keys())
def _clamp(value: int, min_val: int, max_val: int) -> int:
"""Clamp an integer to a range."""
return max(min_val, min(max_val, value))
def _load_file_config() -> Dict[str, Any]:
"""Load config from file if exists."""
if os.path.exists(CONFIG_FILE):
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Failed to load config file: {e}")
return {}
def _save_file_config(config: Dict[str, Any]) -> bool:
"""Save config to file."""
try:
os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
logger.info(f"Saved config to {CONFIG_FILE}")
return True
except OSError as e:
logger.error(f"Failed to save config file: {e}")
return False
def _get_env_value(key: str) -> Optional[str]:
"""
Get environment variable value for a config key (prefers new names, falls back to legacy).
Logs a warning exactly once per key if legacy variable is used.
"""
env_vars = ENV_MAPPINGS.get(key)
if not env_vars:
return None
primary, legacy = env_vars
# Respect explicit empty-string overrides: treat "present in env" as an override.
if primary in os.environ:
return os.environ.get(primary)
if legacy in os.environ:
# Check if we've already warned for this key to avoid spam
if not getattr(_get_env_value, "_warned_legacy", None):
_get_env_value._warned_legacy = set()
if legacy not in _get_env_value._warned_legacy:
logger.warning(
f"Config: Using legacy environment variable {legacy}. "
f"Please update to {primary} in future versions."
)
_get_env_value._warned_legacy.add(legacy)
return os.environ.get(legacy)
return None
def _env_flag(primary: str, legacy: str, default: bool = False) -> bool:
"""
Boolean env helper with new/legacy names.
Accepts: 1/true/yes/on (case-insensitive) as True.
"""
if primary in os.environ:
v = os.environ.get(primary, "")
elif legacy in os.environ:
v = os.environ.get(legacy, "")
else:
return default
return str(v).strip().lower() in ("1", "true", "yes", "on")
def get_scheduler_config() -> Dict[str, Any]:
"""
Get effective Scheduler config (Env > Defaults).
Note: Scheduler config is currently not persisted to file (Env only).
"""
effective = {}
defaults = DEFAULTS["scheduler"]
for key in ALLOWED_SCHEDULER_KEYS:
# Check ENV
env_vars = SCHEDULER_ENV_MAPPINGS.get(key)
if env_vars:
primary, _ = env_vars
val = os.environ.get(primary)
if val is not None:
# Parse
if key == "skip_missed_intervals":
effective[key] = str(val).strip().lower() in (
"1",
"true",
"yes",
"on",
)
elif key in SCHEDULER_CONSTRAINTS:
try:
val_int = int(val)
effective[key] = _clamp(val_int, *SCHEDULER_CONSTRAINTS[key])
except ValueError:
effective[key] = defaults[key]
else:
effective[key] = val
continue
# Use default
effective[key] = defaults.get(key)
return effective
def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]:
"""
Get effective LLM config with precedence: ENV > file > defaults.
Returns:
Tuple of (effective_config, sources) where sources maps each key to its origin.
"""
file_config = _load_file_config().get("llm", {})
effective = {}
sources = {}
ordered_keys = list(LLM_KEY_ORDER) + [
k for k in sorted(ALLOWED_LLM_KEYS) if k not in ENV_MAPPINGS
]
for key in ordered_keys:
# 1. Check ENV override
env_val = _get_env_value(key)
if env_val is not None:
# R14: Parse list env vars (comma-separated)
if key in ("fallback_models", "fallback_providers"):
env_val = [item.strip() for item in env_val.split(",") if item.strip()]
# Parse numeric env vars
if key in CONSTRAINTS:
try:
env_val = int(env_val)
env_val = _clamp(env_val, *CONSTRAINTS[key])
except ValueError:
env_val = DEFAULTS["llm"].get(key)
effective[key] = env_val
sources[key] = "env"
continue
# 2. Check file config
if key in file_config:
val = file_config[key]
if key in CONSTRAINTS and isinstance(val, (int, float)):
val = _clamp(int(val), *CONSTRAINTS[key])
effective[key] = val
sources[key] = "file"
continue
# 3. Use default
effective[key] = DEFAULTS["llm"].get(key, "")
sources[key] = "default"
return effective, sources
def get_settings_schema() -> dict:
"""R70: Return the full settings schema map for frontend consumption."""
return get_schema_map()
def validate_config_update(updates: Dict[str, Any]) -> Tuple[Dict[str, Any], list]:
"""
Validate and sanitize config updates.
R70: Schema-coerced writes — types are coerced before any domain validation.
Returns:
Tuple of (sanitized_updates, errors)
"""
sanitized = {}
errors = []
# R70: Phase 1 — Schema coercion (unknown keys rejected here)
coerced, coercion_errors = _schema_coerce(updates)
if coercion_errors:
errors.extend(coercion_errors)
for key, val in coerced.items():
# Belt-and-suspenders: also check legacy whitelist
if key not in ALLOWED_LLM_KEYS:
errors.append(f"Unknown key: {key}")
continue
# Validate types and constraints (post-coercion, values should already be typed)
if key in CONSTRAINTS:
if not isinstance(val, (int, float)):
errors.append(f"{key} must be a number")
continue
val = _clamp(int(val), *CONSTRAINTS[key])
elif key == "provider":
if not isinstance(val, str):
errors.append("provider must be a string")
continue
# R16: Validate against known providers from catalog
# R73: Normalize provider aliases before validation
try:
from .providers.catalog import list_providers, normalize_provider_id
val = normalize_provider_id(val)
valid_providers = set(list_providers())
except ImportError:
try:
from services.providers.catalog import ( # type: ignore
list_providers,
normalize_provider_id,
)
val = normalize_provider_id(val)
valid_providers = set(list_providers())
except ImportError:
valid_providers = {
"openai",
"anthropic",
"openrouter",
"gemini",
"groq",
"deepseek",
"xai",
"ollama",
"lmstudio",
"custom",
}
if val not in valid_providers:
errors.append(f"Unknown provider: {val}")
continue
elif key == "base_url":
if not isinstance(val, str):
errors.append("base_url must be a string")
continue
# NOTE: Allow empty base_url (use provider default).
# Without this, UI saves can fail with "Invalid scheme" on blank base_url.
if val.strip() == "":
sanitized[key] = ""
continue
# S16: Base URL policy
# 1. Allow if it matches the *default* base_url for the selected provider
# R73 FIX: Use the already-normalized provider from sanitized (post
# normalize_provider_id), so alias providers like "local" → "lmstudio"
# hit the correct local-provider branch.
provider_key = sanitized.get(
"provider",
coerced.get("provider", updates.get("provider", "custom")),
)
if isinstance(provider_key, str):
provider_key = provider_key.lower()
else:
provider_key = "custom"
known_provider = PROVIDER_CATALOG.get(provider_key)
if known_provider and val == known_provider.base_url:
# Matches known good default
pass
elif known_provider and known_provider.name.lower().endswith("(local)"):
# Loopback only for local providers
if not (
val.startswith("http://localhost")
or val.startswith("http://127.0.0.1")
):
errors.append(
f"Local provider {provider_key} must use localhost URL"
)
continue
else:
# Custom URL (either custom provider OR overriding default URL)
# Check opt-in for custom URLs
if provider_key == "custom" and not _env_flag(
"OPENCLAW_ALLOW_CUSTOM_BASE_URL",
"MOLTBOT_ALLOW_CUSTOM_BASE_URL",
default=False,
):
errors.append(
"Custom Base URL requires OPENCLAW_ALLOW_CUSTOM_BASE_URL=1 (or legacy MOLTBOT_ALLOW_CUSTOM_BASE_URL=1)"
)
continue
# S16.1: Strict Host Allowlist (Exact Match)
# Deny by default unless host is explicitly allowed.
# NOTE: built-in provider public hosts are allowlisted by default.
allowed_hosts_str = os.environ.get(
"OPENCLAW_LLM_ALLOWED_HOSTS"
) or os.environ.get("MOLTBOT_LLM_ALLOWED_HOSTS", "")
allowed_hosts_env = set(
h.lower().strip() for h in allowed_hosts_str.split(",") if h.strip()
)
try:
from .providers.catalog import get_default_public_llm_hosts
allowed_hosts = (
set(get_default_public_llm_hosts()) | allowed_hosts_env
)
except Exception:
allowed_hosts = allowed_hosts_env
# Check opt-in for "Any Public Host" (risky, for flexibility)
allow_any = _env_flag(
"OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST",
"MOLTBOT_ALLOW_ANY_PUBLIC_LLM_HOST",
default=False,
)
try:
validate_outbound_url(
val,
allow_hosts=allowed_hosts if not allow_any else None,
allow_any_public_host=allow_any,
)
except SSRFError as e:
# Allow override via insecure flag (legacy/risk acceptance)
if not _env_flag(
"OPENCLAW_ALLOW_INSECURE_BASE_URL",
"MOLTBOT_ALLOW_INSECURE_BASE_URL",
default=False,
):
errors.append(
f"Unsafe Base URL blocked (SSRF): {e}. Set OPENCLAW_LLM_ALLOWED_HOSTS (or legacy MOLTBOT_LLM_ALLOWED_HOSTS) to allow."
)
continue
elif key == "model":
if not isinstance(val, str):
errors.append("model must be a string")
continue
sanitized[key] = val
return sanitized, errors
def get_apply_semantics(updated_keys: list) -> Dict[str, list]:
"""
R53: Determine apply semantics for updated keys.
Returns:
{
"applied_now": [keys applied immediately],
"restart_required": [keys requiring restart],
"notes": [explanatory notes]
}
"""
applied_now = []
restart_required = []
notes = []
for key in updated_keys:
if key in ALLOWED_LLM_KEYS:
# LLM keys are read from file on every request (via get_effective_config),
# so they are effectively "applied now".
applied_now.append(key)
elif key in ALLOWED_SCHEDULER_KEYS:
# Scheduler config is env-only (not file-based) in current implementation,
# but if it were updateable via API, it might require restart or re-init.
# For now, this path is unused by config_put_handler which targets LLM config.
restart_required.append(key)
notes.append(f"{key} requires service restart to take effect.")
else:
# Unknown keys? Assume restart needed for safety if they slipped through validation
restart_required.append(key)
return {
"applied_now": sorted(applied_now),
"restart_required": sorted(restart_required),
"notes": notes,
}
def update_config(updates: Dict[str, Any]) -> Tuple[bool, list]:
"""
Update LLM config, persisting to file.
Returns:
Tuple of (success, errors)
"""
sanitized, errors = validate_config_update(updates)
if errors:
return False, errors
if not sanitized:
return True, [] # Nothing to update
# Merge with existing file config
file_config = _load_file_config()
if "llm" not in file_config:
file_config["llm"] = {}
file_config["llm"].update(sanitized)
if _save_file_config(file_config):
logger.info(f"Updated config: {list(sanitized.keys())}")
return True, []
else:
return False, ["Failed to save config file"]
def is_config_write_enabled() -> bool:
"""
Backwards-compat shim.
Config writes are no longer gated by a separate "enable" flag; admin access policy controls writes.
"""
return True
def validate_admin_token(token: str) -> bool:
"""Validate admin token for config writes (S13)."""
expected = os.environ.get("OPENCLAW_ADMIN_TOKEN") or os.environ.get(
"MOLTBOT_ADMIN_TOKEN", ""
)
if not expected:
return True # No token configured = convenience mode; caller must still enforce loopback-only.
return token == expected
def get_admin_token() -> str:
"""
Returns the configured admin token (preferred OPENCLAW, legacy MOLTBOT) or "" if not configured.
This is for internal policy decisions only (e.g., "is a token configured?").
Never return this value to UI callers and never log it.
"""
return (
os.environ.get("OPENCLAW_ADMIN_TOKEN")
or os.environ.get("MOLTBOT_ADMIN_TOKEN")
or ""
)
def is_loopback_client(remote_addr: str) -> bool:
"""Check if client is from loopback address."""
return remote_addr in ("127.0.0.1", "::1", "localhost")
class RuntimeConfig:
"""
Typed configuration snapshot.
Aggregates effective settings from Env and File.
"""
def __init__(self):
# LLM Settings
self.llm, _ = get_effective_config()
# Feature Flags
self.bridge_enabled = _env_flag(
"OPENCLAW_BRIDGE_ENABLED", "MOLTBOT_BRIDGE_ENABLED", False
)
# Security Flags (S41)
self.allow_any_public_llm_host = _env_flag(
"OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST",
"MOLTBOT_ALLOW_ANY_PUBLIC_LLM_HOST",
False,
)
self.allow_insecure_base_url = _env_flag(
"OPENCLAW_ALLOW_INSECURE_BASE_URL", "MOLTBOT_ALLOW_INSECURE_BASE_URL", False
)
self.webhook_auth_mode = os.environ.get("OPENCLAW_WEBHOOK_AUTH_MODE", "")
self.admin_token_configured = bool(get_admin_token())
def get_config() -> RuntimeConfig:
"""Factory to get current config snapshot."""
return RuntimeConfig()