"""Config/integration admin agent tools (TOOL_HANDLERS). Moved verbatim from tool_implementations.py as part of the tool-registry migration (#3629, the `admin_tools.py` bullet): manage_endpoints / manage_mcp / manage_webhooks / manage_tokens / manage_settings, plus manage_mcp's command-allowlist guard. Each impl keeps its `do_*(content, owner)` shape; ADMIN_TOOL_HANDLERS wraps them into registry `execute(content, ctx)` adapters via one factory. """ import json import os import re import logging from typing import Optional, Dict from src.tool_utils import get_mcp_manager, _parse_tool_args from src.tool_security import BUILTIN_EMAIL_TOOLS logger = logging.getLogger(__name__) async def do_manage_endpoints(content: str, owner: Optional[str] = None) -> Dict: """Manage model endpoints: list, add, delete, enable, disable.""" from core.database import SessionLocal, ModelEndpoint try: args = _parse_tool_args(content) except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} action = args.get("action", "list") db = SessionLocal() try: if action == "list": eps = db.query(ModelEndpoint).all() items = [{"id": e.id, "name": e.name, "base_url": e.base_url, "is_enabled": e.is_enabled} for e in eps] return {"response": f"{len(items)} endpoints", "endpoints": items, "exit_code": 0} elif action == "add": import uuid as _uuid name = args.get("name", "") base_url = args.get("base_url", "") api_key = args.get("api_key", "") if not base_url: return {"error": "base_url is required", "exit_code": 1} eid = str(_uuid.uuid4())[:8] from datetime import datetime ep = ModelEndpoint(id=eid, name=name or base_url, base_url=base_url, api_key=api_key, is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow()) db.add(ep) db.commit() return {"response": f"Added endpoint '{name or base_url}' (id: {eid})", "exit_code": 0} elif action == "delete": eid = args.get("endpoint_id", "") ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first() if not ep: return {"error": f"Endpoint {eid} not found", "exit_code": 1} name = ep.name db.delete(ep) db.commit() return {"response": f"Deleted endpoint '{name}'", "exit_code": 0} elif action in ("enable", "disable"): eid = args.get("endpoint_id", "") ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == eid).first() if not ep: return {"error": f"Endpoint {eid} not found", "exit_code": 1} ep.is_enabled = (action == "enable") db.commit() return {"response": f"Endpoint '{ep.name}' {action}d", "exit_code": 0} else: return {"error": f"Unknown action: {action}", "exit_code": 1} except Exception as e: logger.error(f"manage_endpoints error: {e}") return {"error": str(e), "exit_code": 1} finally: db.close() # --------------------------------------------------------------------------- # MCP server management tool # --------------------------------------------------------------------------- # Parallel to routes/cookbook_helpers._validate_serve_cmd but deliberately the # opposite policy: that gate guards an admin-only serve command and allows # interpreters (python3/etc) because model-serving needs them, whereas this is # the model/prompt-injection-reachable manage_mcp path, so interpreters and # runners are denied here. # # Commands that can execute arbitrary code regardless of their arguments. These # are NEVER accepted on the manage_mcp agent path, even if an operator lists one # in ODYSSEUS_MCP_ALLOWED_COMMANDS -- a stdio server that genuinely needs an # interpreter or package runner must be registered via the trusted admin route. _MCP_DENIED_COMMANDS = frozenset({ "sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh", "ash", "busybox", "cmd", "command.com", "powershell", "pwsh", "python", "pypy", "node", "nodejs", "deno", "bun", "ruby", "jruby", "perl", "raku", "php", "lua", "luajit", "tclsh", "wish", "expect", "rscript", "groovy", "scala", "elixir", "erl", "iex", "java", "javac", "jshell", "jbang", "kotlin", "kotlinc", "dotnet", "mono", "swift", "osascript", "tsx", "ts-node", "npx", "bunx", "uvx", "pipx", "npm", "pnpm", "yarn", "pip", "uv", "gem", "cargo", "go", "bundle", "poetry", "conda", "mamba", "brew", "apt", "apt-get", "yum", "dnf", "pacman", "apk", "env", "xargs", "nohup", "setsid", "nice", "ionice", "time", "timeout", "watch", "stdbuf", "unbuffer", "script", "ssh", "scp", "sshpass", "sudo", "doas", "su", "make", "cmake", "docker", "podman", "kubectl", "find", "awk", "gawk", "sed", "vi", "vim", "nvim", "emacs", "ed", "tee", "eval", }) # Argv flags that make even an allowlisted binary execute inline code. Matched # by prefix so glued forms (-cimport os, --eval=...) are caught, not just the # exact-token form. _MCP_CODE_EXEC_SHORT_FLAGS = ("-c", "-e", "-m") _MCP_CODE_EXEC_LONG_FLAGS = ("--eval", "--exec", "--print", "--module", "--command", "--require") _MCP_URL_SCHEMES = ("http://", "https://", "ftp://", "ftps://", "file://", "data:", "jar:", "blob:") # Shell metacharacters refused in command/args. Args are passed as an argv list # (no shell), but refusing these keeps the surface narrow and obvious. _MCP_SHELL_METACHARS = set(";|&$`><\n\r") # Env vars that let a child process load attacker-supplied code before main(). _MCP_DANGEROUS_ENV = frozenset({ "LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH", "PYTHONPATH", "PYTHONSTARTUP", "PYTHONHOME", "PYTHONEXECUTABLE", "NODE_OPTIONS", "NODE_PATH", "BASH_ENV", "ENV", "SHELLOPTS", "PERL5LIB", "PERL5OPT", "RUBYOPT", "RUBYLIB", "GEM_PATH", "R_PROFILE", "R_HOME", "PATH", "IFS", "PROMPT_COMMAND", }) def _mcp_allowed_commands() -> set: """Operator-configured allowlist of safe MCP launcher basenames for the agent path. Empty by default; set ODYSSEUS_MCP_ALLOWED_COMMANDS (comma-separated) to opt specific trusted binaries in. Denied commands are rejected even if listed here.""" raw = os.environ.get("ODYSSEUS_MCP_ALLOWED_COMMANDS", "") return {c.strip().lower() for c in raw.split(",") if c.strip()} def _validate_mcp_command(command, args, env) -> Optional[str]: """Validate a model-supplied stdio MCP registration. Returns an error string if it must be rejected, else None. Closes the RCE where manage_mcp 'add' passed prompt-injection-controlled command/args/env straight to a subprocess spawn (issue #438): a payload smuggled into a skill description, memory entry, fetched page, or email body could register a stdio server running arbitrary code as the app UID. """ if not isinstance(command, str) or not command.strip(): return "command must be a non-empty string" command = command.strip() if "/" in command or "\\" in command: return "command must be a bare executable name, not a path" if any(ch in _MCP_SHELL_METACHARS for ch in command): return "command contains shell metacharacters" base = command.lower() if base.endswith(".exe") or base.endswith(".cmd") or base.endswith(".bat"): base = base.rsplit(".", 1)[0] # Canonicalize a trailing version suffix so versioned aliases collapse to the # family name (python3.11 -> python, node18 -> node, pip3 -> pip); both the # raw basename and the canonical form are denied, so an operator cannot # accidentally allowlist a runtime alias back into the path. canon = re.sub(r"[-_.]?\d+(?:\.\d+)*$", "", base) if base in _MCP_DENIED_COMMANDS or canon in _MCP_DENIED_COMMANDS: return ( f"command '{command}' is not allowed on the agent MCP path: " "interpreters, runtimes, package runners, and shells can execute " "arbitrary code. Register such a server via the admin route instead." ) if base not in _mcp_allowed_commands(): return ( f"command '{command}' is not in the MCP allowlist. Add it to " "ODYSSEUS_MCP_ALLOWED_COMMANDS if you trust it, or register the " "server via the admin route." ) if args is not None: if isinstance(args, str): try: args = json.loads(args) except Exception: return "args must be a JSON list" if not isinstance(args, list): return "args must be a list" for a in args: if not isinstance(a, str): return "args must all be strings" s = a.strip() low = s.lower() if any(s == f or s.startswith(f) for f in _MCP_CODE_EXEC_SHORT_FLAGS): return f"arg '{a}' is a code-execution flag and is not allowed" if any(low == f or low.startswith(f + "=") for f in _MCP_CODE_EXEC_LONG_FLAGS): return f"arg '{a}' is a code-execution flag and is not allowed" if any(low.startswith(u) for u in _MCP_URL_SCHEMES): return f"arg '{a}' is a remote URL and is not allowed" if any(ch in _MCP_SHELL_METACHARS for ch in a): return f"arg '{a}' contains shell metacharacters" if env: if isinstance(env, str): try: env = json.loads(env) except Exception: return "env must be a JSON object" if not isinstance(env, dict): return "env must be an object" for k in env: if str(k).strip().upper() in _MCP_DANGEROUS_ENV: return f"env var '{k}' can inject code into the child process and is not allowed" return None async def do_manage_mcp(content: str, owner: Optional[str] = None) -> Dict: """Manage MCP servers: list, add, delete, enable, disable, reconnect.""" try: args = _parse_tool_args(content) except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} action = args.get("action", "list") if action == "list": mcp = get_mcp_manager() if not mcp: return {"response": "No MCP manager available", "servers": [], "exit_code": 0} from core.database import SessionLocal, McpServer db = SessionLocal() try: servers = db.query(McpServer).all() items = [] for s in servers: st = mcp.get_server_status(s.id) status = st.get("status", "disconnected") tool_count = st.get("tool_count", 0) items.append({"id": s.id, "name": s.name, "transport": s.transport, "is_enabled": s.is_enabled, "status": status, "tool_count": tool_count}) return {"response": f"{len(items)} MCP servers", "servers": items, "exit_code": 0} finally: db.close() elif action == "add": from core.database import SessionLocal, McpServer import uuid as _uuid from datetime import datetime name = args.get("name", "") command = args.get("command", "") cmd_args = args.get("args", []) env = args.get("env", {}) if not name or not command: return {"error": "name and command are required", "exit_code": 1} # Validate BEFORE any DB write or spawn: a rejected registration must # leave no enabled row (which would otherwise auto-reconnect on restart) # and must not attempt a connection. _mcp_err = _validate_mcp_command(command, cmd_args, env) if _mcp_err: return {"error": f"manage_mcp: refused unsafe server registration: {_mcp_err}", "exit_code": 1} sid = str(_uuid.uuid4())[:8] db = SessionLocal() try: srv = McpServer(id=sid, name=name, transport="stdio", command=command, args=json.dumps(cmd_args) if isinstance(cmd_args, list) else cmd_args, env=json.dumps(env) if isinstance(env, dict) else env, is_enabled=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow()) db.add(srv) db.commit() finally: db.close() # Try to connect mcp = get_mcp_manager() tool_count = 0 if mcp: try: await mcp.connect_server( sid, name, "stdio", command=command, args=cmd_args if isinstance(cmd_args, list) else json.loads(cmd_args), env=env if isinstance(env, dict) else json.loads(env), ) st = mcp.get_server_status(sid) tool_count = st.get("tool_count", 0) except Exception as e: logger.warning(f"MCP connect failed for {name}: {e}") return {"response": f"Added MCP server '{name}' ({tool_count} tools)", "exit_code": 0} elif action == "delete": sid = args.get("server_id", "") from core.database import SessionLocal, McpServer db = SessionLocal() try: srv = db.query(McpServer).filter(McpServer.id == sid).first() if not srv: return {"error": f"Server {sid} not found", "exit_code": 1} name = srv.name mcp = get_mcp_manager() if mcp: try: await mcp.disconnect_server(sid) except Exception: pass db.delete(srv) db.commit() return {"response": f"Deleted MCP server '{name}'", "exit_code": 0} finally: db.close() elif action == "reconnect": sid = args.get("server_id", "") mcp = get_mcp_manager() if not mcp: return {"error": "MCP manager not available", "exit_code": 1} try: await mcp.disconnect_server(sid) from core.database import SessionLocal, McpServer db2 = SessionLocal() try: srv = db2.query(McpServer).filter(McpServer.id == sid).first() if srv: _args = json.loads(srv.args) if srv.args else [] _env = json.loads(srv.env) if srv.env else {} await mcp.connect_server( server_id=sid, name=srv.name, transport=srv.transport, command=srv.command, args=_args, env=_env, url=srv.url, ) st = mcp.get_server_status(sid) return {"response": f"Reconnected '{srv.name}' ({st.get('tool_count', 0)} tools)", "exit_code": 0} return {"error": f"Server {sid} not found", "exit_code": 1} finally: db2.close() except Exception as e: return {"error": str(e), "exit_code": 1} elif action in ("enable", "disable"): sid = args.get("server_id", "") from core.database import SessionLocal, McpServer db = SessionLocal() try: srv = db.query(McpServer).filter(McpServer.id == sid).first() if not srv: return {"error": f"Server {sid} not found", "exit_code": 1} srv.is_enabled = (action == "enable") db.commit() return {"response": f"MCP server '{srv.name}' {action}d", "exit_code": 0} finally: db.close() elif action == "list_tools": mcp = get_mcp_manager() if not mcp: return {"response": "No MCP manager", "tools": [], "exit_code": 0} tools = mcp.get_all_tools() items = [{"name": t["name"], "server": t["server_name"], "description": t.get("description", "")[:100]} for t in tools] return {"response": f"{len(items)} MCP tools available", "tools": items, "exit_code": 0} else: return {"error": f"Unknown action: {action}", "exit_code": 1} # --------------------------------------------------------------------------- # Webhook management tool # --------------------------------------------------------------------------- async def do_manage_webhooks(content: str, owner: Optional[str] = None) -> Dict: """Manage webhooks: list, add, delete, enable, disable, test.""" from core.database import SessionLocal try: args = _parse_tool_args(content) except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} action = args.get("action", "list") db = SessionLocal() try: from core.database import Webhook if action == "list": hooks = db.query(Webhook).all() items = [{"id": h.id, "name": h.name, "url": h.url, "events": h.events, "is_active": h.is_active} for h in hooks] return {"response": f"{len(items)} webhooks", "webhooks": items, "exit_code": 0} elif action == "add": import uuid as _uuid from datetime import datetime from src.webhook_manager import validate_events, validate_webhook_url name = args.get("name", "") url = args.get("url", "") events = args.get("events", "chat.completed") if not url: return {"error": "url is required", "exit_code": 1} try: url = validate_webhook_url(url) events = validate_events(events) except ValueError as e: return {"error": str(e), "exit_code": 1} wid = str(_uuid.uuid4())[:8] hook = Webhook(id=wid, name=name or url, url=url, events=events, is_active=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow()) db.add(hook) db.commit() return {"response": f"Added webhook '{name or url}'", "exit_code": 0} elif action == "delete": wid = args.get("webhook_id", "") hook = db.query(Webhook).filter(Webhook.id == wid).first() if not hook: return {"error": f"Webhook {wid} not found", "exit_code": 1} name = hook.name db.delete(hook) db.commit() return {"response": f"Deleted webhook '{name}'", "exit_code": 0} elif action in ("enable", "disable"): wid = args.get("webhook_id", "") hook = db.query(Webhook).filter(Webhook.id == wid).first() if not hook: return {"error": f"Webhook {wid} not found", "exit_code": 1} hook.is_active = (action == "enable") db.commit() return {"response": f"Webhook '{hook.name}' {action}d", "exit_code": 0} else: return {"error": f"Unknown action: {action}", "exit_code": 1} except Exception as e: logger.error(f"manage_webhooks error: {e}") return {"error": str(e), "exit_code": 1} finally: db.close() # --------------------------------------------------------------------------- # API token management tool # --------------------------------------------------------------------------- async def do_manage_tokens(content: str, owner: Optional[str] = None) -> Dict: """Manage API tokens: list, create, delete.""" from core.database import SessionLocal, ApiToken try: args = _parse_tool_args(content) except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} action = args.get("action", "list") db = SessionLocal() try: if action == "list": tokens = db.query(ApiToken).all() items = [{"id": t.id, "name": t.name, "token_prefix": t.token_prefix + "...", "is_active": t.is_active} for t in tokens] return {"response": f"{len(items)} API tokens", "tokens": items, "exit_code": 0} elif action == "create": import uuid as _uuid, secrets, bcrypt from datetime import datetime name = args.get("name", "API Token") raw_token = secrets.token_urlsafe(32) token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode() tid = str(_uuid.uuid4())[:8] t = ApiToken(id=tid, name=name, token_hash=token_hash, token_prefix=raw_token[:8], is_active=True, created_at=datetime.utcnow(), updated_at=datetime.utcnow()) db.add(t) db.commit() return {"response": f"Created token '{name}'", "token": raw_token, "exit_code": 0} elif action == "delete": tid = args.get("token_id", "") t = db.query(ApiToken).filter(ApiToken.id == tid).first() if not t: return {"error": f"Token {tid} not found", "exit_code": 1} name = t.name db.delete(t) db.commit() return {"response": f"Deleted token '{name}'", "exit_code": 0} else: return {"error": f"Unknown action: {action}", "exit_code": 1} except Exception as e: logger.error(f"manage_tokens error: {e}") return {"error": str(e), "exit_code": 1} finally: db.close() # --------------------------------------------------------------------------- # Settings/preferences management tool # --------------------------------------------------------------------------- async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: """Manage user settings and preferences.""" try: args = _parse_tool_args(content) except ValueError: return {"error": "Invalid JSON arguments", "exit_code": 1} action = args.get("action", "list") from core.database import SessionLocal db = SessionLocal() try: # set/get/list/delete operate on the REAL app settings (the same store # the Settings panel writes), so changing a model / voice / search # engine / reminder channel from chat actually takes effect. from src.settings import load_settings, save_settings, DEFAULT_SETTINGS # Secrets/credentials the agent must NOT write: kept read-only (masked) # so API keys never flow through chat. User sets these in the panel. _SECRET_KEYS = { "brave_api_key", "google_pse_key", "google_pse_cx", "tavily_api_key", "serper_api_key", "app_public_url", } def _is_secret(k): # `token` must be a suffix, not a substring: otherwise the int # setting `agent_input_token_budget` (which even has a "token budget" # alias to set it from chat) is wrongly classified as a credential. return ( k in _SECRET_KEYS or k.endswith("token") or any(t in k for t in ("api_key", "_key", "secret", "password")) ) # Friendly aliases → real keys, so natural phrasing resolves. _ALIASES_SET = { "voice": "tts_voice", "tts voice": "tts_voice", "tts": "tts_enabled", "text to speech": "tts_enabled", "tts provider": "tts_provider", "speech speed": "tts_speed", "voice speed": "tts_speed", "stt": "stt_enabled", "speech to text": "stt_enabled", "transcription": "stt_enabled", "search engine": "search_provider", "search provider": "search_provider", "search results": "search_result_count", "result count": "search_result_count", "default model": "default_model", "chat model": "default_model", "default endpoint": "default_endpoint_id", "task model": "task_model", "background model": "task_model", "teacher model": "teacher_model", "teacher": "teacher_enabled", "utility model": "utility_model", "research model": "research_model", "research max tokens": "research_max_tokens", "vision model": "vision_model", "vision": "vision_enabled", "image model": "image_model", "image quality": "image_quality", "image gen": "image_gen_enabled", "image generation": "image_gen_enabled", "reminder channel": "reminder_channel", "reminders": "reminder_channel", "ntfy topic": "reminder_ntfy_topic", "webhook integration": "reminder_webhook_integration_id", "webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template", "agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls", "agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds", "token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget", "hard max": "agent_input_token_hard_max", "token budget cap": "agent_input_token_hard_max", "input budget cap": "agent_input_token_hard_max", } def _resolve(k): k2 = (k or "").strip().lower() if k2 in DEFAULT_SETTINGS: return k2 return _ALIASES_SET.get(k2, (k or "").strip()) _ENUMS = { "image_quality": ["low", "medium", "high"], "reminder_channel": ["browser", "email", "ntfy", "webhook"], } def _coerce(value, default): if isinstance(default, bool): return value if isinstance(value, bool) else str(value).strip().lower() in ("true", "on", "yes", "1", "enable", "enabled") if isinstance(default, int): return int(value) return value def _model_slug(value: str) -> str: import re as _re return _re.sub(r"[^a-z0-9]+", "", (value or "").lower()) def _endpoint_model_from_cache(model_query: str): """Resolve friendly model text to an enabled endpoint + real model id. The Settings UI stores both `_endpoint_id` and `_model`; writing only the model leaves the runtime on the old endpoint. Prefer cached model lists so this stays fast/offline. """ import json as _json import re as _re from core.database import ModelEndpoint wanted = (model_query or "").strip() wanted_slug = _model_slug(wanted) wanted_tokens = [_model_slug(t) for t in _re.findall(r"[A-Za-z0-9]+", wanted)] wanted_tokens = [t for t in wanted_tokens if t] if not wanted_slug: return None best = None for ep in db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all(): raw_models = [] try: raw_models = _json.loads(ep.cached_models or "[]") or [] except Exception: raw_models = [] # If cache is empty, still allow matching against endpoint name # for callers using model@endpoint elsewhere later. for mid in raw_models: mid = str(mid) mid_slug = _model_slug(mid) if not mid_slug: continue exact = mid.lower() == wanted.lower() compact_match = wanted_slug in mid_slug or mid_slug in wanted_slug token_match = bool(wanted_tokens) and all(tok in mid_slug for tok in wanted_tokens) if exact or compact_match or token_match: score = 3 if exact else (2 if compact_match else 1) if not best or score > best[0]: best = (score, ep.id, mid) if best: return {"endpoint_id": best[1], "model": best[2]} return None def _mask(k, v): return "••••• (set in panel)" if _is_secret(k) and v else v if action == "list": s = load_settings() shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)} return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0} elif action == "get": key = _resolve(args.get("key", "")) if not key: return {"error": "key is required", "exit_code": 1} if key not in DEFAULT_SETTINGS: return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1} val = load_settings().get(key, DEFAULT_SETTINGS.get(key)) return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0} elif action == "set": raw = args.get("key", "") value = args.get("value") if not raw: return {"error": "key is required", "exit_code": 1} key = _resolve(raw) if key not in DEFAULT_SETTINGS: return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1} if _is_secret(key): return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0} # Structured settings (dicts/lists like keybinds, default_model_fallbacks) # have no safe scalar coercion; _coerce would pass a bare string # straight through and clobber the structure. Refuse them here; they're # edited in their dedicated panels. (reset/delete still restore the # default structure, which is safe.) if isinstance(DEFAULT_SETTINGS[key], (dict, list)): return {"response": f"'{key}' is a structured setting. Edit it in its panel, not from chat. (You can reset it to default here.)", "exit_code": 0} try: value = _coerce(value, DEFAULT_SETTINGS[key]) except (ValueError, TypeError): return {"error": f"'{value}' isn't a valid value for {key} (expected {type(DEFAULT_SETTINGS[key]).__name__}).", "exit_code": 1} if key in _ENUMS and str(value).lower() not in _ENUMS[key]: return {"error": f"{key} must be one of: {', '.join(_ENUMS[key])}.", "exit_code": 1} s = load_settings() s[key] = value if key in {"default_model", "research_model", "utility_model", "task_model", "vision_model", "image_model"}: resolved = _endpoint_model_from_cache(str(value)) if resolved: prefix = key[:-6] s[f"{prefix}_endpoint_id"] = resolved["endpoint_id"] s[key] = resolved["model"] value = resolved["model"] save_settings(s) if key.endswith("_model") and s.get(f"{key[:-6]}_endpoint_id"): return {"response": f"Set {key} = {value} (endpoint {s.get(f'{key[:-6]}_endpoint_id')}).", "exit_code": 0} return {"response": f"Set {key} = {value}.", "exit_code": 0} elif action == "delete" or action == "reset": key = _resolve(args.get("key", "")) if key not in DEFAULT_SETTINGS: return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1} if _is_secret(key): return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0} s = load_settings() s[key] = DEFAULT_SETTINGS[key] save_settings(s) return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0} elif action in ("disable_tool", "enable_tool", "list_tools"): # Tool-toggle actions. These edit settings.json:disabled_tools # (the global list read on every chat request) rather than # prefs.json. Friendly aliases accepted: "shell" -> "bash", # "search" -> "web_search", "browser" -> "builtin_browser", # "documents" -> the document tool set, "memory" -> # manage_memory, etc. from src.settings import get_setting, save_settings, load_settings _ALIASES = { "shell": ["bash"], "terminal": ["bash"], "search": ["web_search", "web_fetch"], "web": ["web_search", "web_fetch"], "browser": ["builtin_browser"], "documents": ["create_document", "edit_document", "update_document", "suggest_document"], "doc": ["create_document", "edit_document", "update_document", "suggest_document"], "memory": ["manage_memory"], "skills": ["manage_skills"], "images": ["generate_image"], "image": ["generate_image"], "tasks": ["manage_tasks"], "notes": ["manage_notes"], "calendar": ["manage_calendar"], # The full built-in email tool set, in BOTH spellings: the # qualified mcp__email__* names drive MCP schema hiding, the # bare names drive function-schema hiding, and the runtime # gate accepts either — deriving from BUILTIN_EMAIL_TOOLS # keeps the toggle covering every tool the email server # exposes instead of a hand-picked subset. "email": sorted(BUILTIN_EMAIL_TOOLS) + [f"mcp__email__{t}" for t in sorted(BUILTIN_EMAIL_TOOLS)], "research": ["web_search", "web_fetch"], # research is a per-request flag, not a tool (closest analog) } if action == "list_tools": current = get_setting("disabled_tools", []) or [] return { "response": ( f"Currently disabled: {', '.join(current) if current else '(none)'}.\n" "Common toggles: shell (bash), search (web_search), browser, documents, " "memory, skills, images, tasks, notes, calendar, email." ), "disabled": list(current), "exit_code": 0, } tool_name = (args.get("tool") or args.get("name") or "").strip().lower() if not tool_name: return {"error": "tool name required (e.g. 'shell', 'search', 'bash')", "exit_code": 1} targets = _ALIASES.get(tool_name, [tool_name]) settings = load_settings() current = list(settings.get("disabled_tools") or []) before = set(current) if action == "disable_tool": for t in targets: if t not in current: current.append(t) else: # enable_tool current = [t for t in current if t not in targets] after = set(current) settings["disabled_tools"] = current save_settings(settings) verb = "Disabled" if action == "disable_tool" else "Enabled" changed = sorted(after.symmetric_difference(before)) return { "response": ( f"{verb} {tool_name} ({', '.join(targets)}). " f"Now disabled: {', '.join(current) if current else '(none)'}." ), "changed": changed, "disabled": list(current), "exit_code": 0, } else: return {"error": f"Unknown action: {action}", "exit_code": 1} except Exception as e: logger.error(f"manage_settings error: {e}") return {"error": str(e), "exit_code": 1} finally: db.close() # --------------------------------------------------------------------------- # API call tool # --------------------------------------------------------------------------- # ── registry adapters ──────────────────────────────────────────────────────── def _owner_adapter(fn): """Wrap a do_*(content, owner) impl as a registry execute(content, ctx).""" async def _execute(content: str, ctx: dict) -> dict: return await fn(content, ctx.get("owner")) return _execute ADMIN_TOOL_HANDLERS = { "manage_endpoints": _owner_adapter(do_manage_endpoints), "manage_mcp": _owner_adapter(do_manage_mcp), "manage_webhooks": _owner_adapter(do_manage_webhooks), "manage_tokens": _owner_adapter(do_manage_tokens), "manage_settings": _owner_adapter(do_manage_settings), }