"""Config loader for per-sandbox Beaver runtime settings.""" from __future__ import annotations import json import os import sys from pathlib import Path from typing import Any from .schema import ( AgentDefaultsConfig, AuthzConfig, BackendIdentityConfig, BeaverConfig, EmbeddingConfig, MCPServerConfig, ProviderConfig, ToolsConfig, ) LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = { "local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"}, "local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"}, "local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"}, "local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"}, "local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"}, "local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"}, "local_web_mcp": {"category": "web", "display_name": "本地联网工具"}, } def default_config_path(*, workspace: str | Path | None = None) -> Path: """Resolve the default config path for a single-user sandbox instance. Priority: 1. `BEAVER_CONFIG_PATH` 2. `BEAVER_HOME/config.json` 3. `/.beaver/config.json` 4. `./.beaver/config.json` """ explicit = os.getenv("BEAVER_CONFIG_PATH") if explicit: return Path(explicit).expanduser() beaver_home = os.getenv("BEAVER_HOME") if beaver_home: return Path(beaver_home).expanduser() / "config.json" root = Path(workspace).expanduser() if workspace is not None else Path.cwd() return root / ".beaver" / "config.json" def load_config( *, workspace: str | Path | None = None, config_path: str | Path | None = None, ) -> BeaverConfig: """Load backend config; missing config is treated as an empty config.""" path = Path(config_path).expanduser() if config_path is not None else default_config_path(workspace=workspace) if not path.exists(): return BeaverConfig(config_path=path) data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ValueError(f"Beaver config must be a JSON object: {path}") return BeaverConfig( agents_defaults=_parse_agent_defaults(data), providers=_parse_providers(data.get("providers")), embedding=_parse_embedding(data), tools=_parse_tools(data.get("tools")), authz=_parse_authz(data.get("authz")), backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")), config_path=path, ) def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig: agents = _as_dict(data.get("agents")) defaults = _as_dict(agents.get("defaults")) return AgentDefaultsConfig( workspace=_string(defaults.get("workspace") or data.get("workspace")), model=_string(defaults.get("model") or data.get("model")), provider=_string(defaults.get("provider") or data.get("provider")), embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")), max_context_messages=_int( defaults.get("maxContextMessages") or defaults.get("max_context_messages") or data.get("maxContextMessages") or data.get("max_context_messages") ), max_tool_iterations=_int( defaults.get("maxToolIterations") or defaults.get("max_tool_iterations") or data.get("maxToolIterations") or data.get("max_tool_iterations") ), ) def _parse_providers(raw: Any) -> dict[str, ProviderConfig]: providers: dict[str, ProviderConfig] = {} for name, payload in _as_dict(raw).items(): if not isinstance(payload, dict): continue providers[str(name)] = ProviderConfig( api_key=_string(payload.get("apiKey") or payload.get("api_key")), api_base=_string(payload.get("apiBase") or payload.get("api_base") or payload.get("baseUrl") or payload.get("base_url")), extra_headers=_string_dict(payload.get("extraHeaders") or payload.get("extra_headers") or payload.get("headers")), request_timeout_seconds=_float( payload.get("requestTimeoutSeconds") or payload.get("request_timeout_seconds") or payload.get("timeout") ), ) return providers def _parse_embedding(data: dict[str, Any]) -> EmbeddingConfig: raw = _as_dict(data.get("embedding") or data.get("embeddings")) return EmbeddingConfig( provider=_string(raw.get("provider") or raw.get("provider_name")), model=_string(raw.get("model") or data.get("embeddingModel") or data.get("embedding_model")), api_key=_string(raw.get("apiKey") or raw.get("api_key")), api_base=_string(raw.get("apiBase") or raw.get("api_base") or raw.get("baseUrl") or raw.get("base_url")), extra_headers=_string_dict(raw.get("extraHeaders") or raw.get("extra_headers") or raw.get("headers")), request_timeout_seconds=_float( raw.get("requestTimeoutSeconds") or raw.get("request_timeout_seconds") or raw.get("timeout") ), ) def _parse_tools(raw: Any) -> ToolsConfig: data = _as_dict(raw) mcp_servers: dict[str, MCPServerConfig] = {} for server_id, payload in _as_dict(data.get("mcpServers") or data.get("mcp_servers")).items(): if not isinstance(payload, dict): continue mcp_servers[str(server_id)] = MCPServerConfig( command=_string(payload.get("command")) or "", args=_string_list(payload.get("args")), env=_string_dict(payload.get("env")), url=_string(payload.get("url")) or "", headers=_string_dict(payload.get("headers")), auth_mode=(_string(payload.get("authMode") or payload.get("auth_mode")) or "none").lower(), auth_audience=_string(payload.get("authAudience") or payload.get("auth_audience")) or "", auth_scopes=_string_list(payload.get("authScopes") or payload.get("auth_scopes")), tool_timeout=int(_float(payload.get("toolTimeout") or payload.get("tool_timeout")) or 30), sensitive=_bool(payload.get("sensitive"), default=False), kind=(_string(payload.get("kind")) or ("local" if payload.get("command") else "online")).lower(), category=_string(payload.get("category")) or ("local" if payload.get("command") else "online"), managed=_bool(payload.get("managed"), default=False), display_name=_string(payload.get("displayName") or payload.get("display_name")) or "", source=_string(payload.get("source")) or "config", ) for server_id, meta in LOCAL_MCP_CATEGORIES.items(): if server_id in mcp_servers: continue mcp_servers[server_id] = MCPServerConfig( command=sys.executable or "python", args=["-m", "beaver.interfaces.mcp.tools_server", "--category", meta["category"]], env={}, kind="local", category=meta["category"], managed=True, display_name=meta["display_name"], source="beaver-default", tool_timeout=60, ) return ToolsConfig( restrict_to_workspace=_bool( data.get("restrictToWorkspace") if "restrictToWorkspace" in data else data.get("restrict_to_workspace"), default=True, ), mcp_servers=mcp_servers, ) def _parse_authz(raw: Any) -> AuthzConfig: data = _as_dict(raw) return AuthzConfig( enabled=_bool(data.get("enabled"), default=False), base_url=_string(data.get("baseUrl") or data.get("base_url")) or "", request_timeout_seconds=int(_float(data.get("requestTimeoutSeconds") or data.get("request_timeout_seconds")) or 10), outlook_mcp_url=_string(data.get("outlookMcpUrl") or data.get("outlook_mcp_url")) or "", ) def _parse_backend_identity(raw: Any) -> BackendIdentityConfig: data = _as_dict(raw) return BackendIdentityConfig( backend_id=_string(data.get("backendId") or data.get("backend_id")) or "", client_id=_string(data.get("clientId") or data.get("client_id")) or "", client_secret=_string(data.get("clientSecret") or data.get("client_secret")) or "", name=_string(data.get("name")) or "", public_base_url=_string(data.get("publicBaseUrl") or data.get("public_base_url")) or "", ) def _as_dict(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} def _string(value: Any) -> str | None: if value is None: return None value = str(value).strip() return value or None def _string_dict(value: Any) -> dict[str, str]: if not isinstance(value, dict): return {} return {str(key): str(item) for key, item in value.items() if item is not None} def _string_list(value: Any) -> list[str]: if not isinstance(value, list): return [] return [str(item) for item in value if str(item).strip()] def _float(value: Any) -> float | None: if value in (None, ""): return None return float(value) def _int(value: Any) -> int | None: parsed = _float(value) if parsed is None: return None return int(parsed) def _bool(value: Any, *, default: bool) -> bool: if isinstance(value, bool): return value if value in (None, ""): return default if isinstance(value, str): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value)