from __future__ import annotations from pathlib import Path import hashlib import hmac import json import os import re import secrets from urllib import parse as urllib_parse from urllib import request as urllib_request from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Body, Header, Query from fastapi import HTTPException from fastapi.responses import HTMLResponse from runtime.operations.mcp_env import apply_gateway_mcp_env_to_os from runtime.agents.factory import build_gateway_executor from runtime.gateway import OclawGateway from runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist from runtime.operations.providers.registry import build_channel_registry from runtime.operations.runtime import ( cleanup_service_processes_by_pid, detect_orphan_service_processes, is_pid_running, list_listen_ports_for_pid, list_service_process_pids, status_services, ) from runtime.operations.stack import cmd_stack_down, cmd_stack_status, cmd_stack_up from runtime.orchestration.vector_store import read_vector_memory_runtime from svc.config.paths import PROJECT_ROOT, db_path from svc.config.passwords import load_expected_password from svc.persistence.sqlite_store import SqliteStore from svc.persistence.assistant_store import get_assistant_store from runtime.agents.specialists import discover_specialist_ids, parse_agent_profile_bindings from runtime.tools.mcp.env_config import mcp_runtime_for_row from runtime.tools.mcp.cursor_config import ( config_payload_to_upsert_fields, parse_cursor_mcp_document, registry_row_to_cursor_server, ) from runtime.tools.mcp.installer import ( _safe_server_id, install_mcp_server, preflight_mcp_server, uninstall_mcp_server, ) from runtime.tools.mcp.manifest import McpServerManifest from runtime.operations.mcp_registry_export import ( build_mcp_install_export_document, mcp_migrated_json_path, persist_mcp_migrated_file, ) from runtime.tools.mcp.filesystem_argv import build_mcp_process_command from runtime.tools.mcp.registry import McpRegistry from runtime.tools.mcp.runtime import McpProcessRuntime from interfaces.admin.mcp_e2e_probe import build_mcp_e2e_probe_plans from runtime.tools.catalog import default_registry from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor from svc.llm.chat_models import LLMToolCall from runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES from runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_error_codes from runtime.prompt_prebuild import run_runtime_prewarm, runtime_prewarm_prompts_snapshot, runtime_prewarm_status from runtime.workspaces.experts import ( create_expert, delete_expert, list_experts, normalize_expert_id, update_expert_files, update_expert_meta, ) _WECOM_CLEAR_KEYS = [ "wecom_mode", "wecom_bot_id", "wecom_bot_secret", "wecom_corp_id", "wecom_agent_id", "wecom_agent_secret", "wecom_access_token_cache", "wecom_last_msg_ts", "wecom_last_from_user", "wecom_recent_from_users", "wecom_last_cmd", "wecom_last_unknown_cmd_payload", "wecom_last_raw_body", "wecom_last_raw_from_user", "wecom_last_parse_error", ] def _wecom_bot_secret_key(tenant_id: str, user_id: str, account_id: str) -> str: return f"wecom:bot_secret:{tenant_id}:{user_id}:{account_id}" def _expand_mcp_entry_args(raw: list[Any] | None) -> list[str]: """Expand ``__REPO_ROOT__/...`` in argv (same as ``scripts/seed_mcp_registry.py``).""" out: list[str] = [] for x in raw or []: s = str(x).strip() if not s: continue if s.startswith("__REPO_ROOT__/"): rel = s.replace("__REPO_ROOT__/", "", 1) s = str((PROJECT_ROOT / rel).resolve()) out.append(s) return out def _mcp_health_and_sync_one(store: SqliteStore, row: dict[str, Any]) -> dict[str, Any] | None: """Persist MCP tools/list for one server. Health is best-effort and does not gate sync.""" from runtime.tools.mcp.sync_tools import sync_mcp_server_tools return sync_mcp_server_tools(store, row) def _is_bailian_webparser_remote(*, entry_command: str, entry_args: list[str]) -> bool: from runtime.tools.mcp.sync_tools import is_bailian_webparser_remote return is_bailian_webparser_remote(entry_command=entry_command, entry_args=entry_args) def _bailian_webparser_virtual_tools() -> list[dict[str, Any]]: from runtime.tools.mcp.sync_tools import bailian_webparser_virtual_tools return bailian_webparser_virtual_tools() def _spawn_async_runtime_prewarm(*, reason: str) -> dict[str, Any]: """Fire-and-forget prewarm after Admin MCP catalog changes (does not block the HTTP response).""" import threading reason_s = str(reason or "mcp_admin_sync").strip() or "mcp_admin_sync" def _run() -> None: try: run_runtime_prewarm(reason=reason_s, store=get_assistant_store()) except Exception: pass th = threading.Thread(target=_run, name="oclaw-mcp-sync-prewarm", daemon=True) th.start() return {"accepted": True, "mode": "async", "reason": reason_s} def _http_get_json(url: str, *, timeout: float = 8.0) -> dict[str, Any]: req = urllib_request.Request( url, headers={ "accept": "application/json", "user-agent": "oclaw-admin-mcp-check/1.0", }, method="GET", ) with urllib_request.urlopen(req, timeout=timeout) as resp: raw = resp.read().decode("utf-8", errors="ignore") data = json.loads(raw) if raw else {} return data if isinstance(data, dict) else {} def _github_repo_from_source_ref(source_ref: str) -> str: s = str(source_ref or "").strip() if not s: return "" m = re.search(r"github\.com[:/]+([^/]+)/([^/#]+)", s, re.I) if m: owner = str(m.group(1) or "").strip() repo = str(m.group(2) or "").strip().removesuffix(".git") return f"{owner}/{repo}" if owner and repo else "" if re.fullmatch(r"[^/\s]+/[^/\s]+", s): return s return "" def _detect_mcp_latest_version(source_type: str, source_ref: str) -> tuple[str, str]: st = str(source_type or "").strip().lower() sr = str(source_ref or "").strip() if st == "npm": pkg = urllib_parse.quote(sr, safe="@/") data = _http_get_json(f"https://registry.npmjs.org/{pkg}") tags = data.get("dist-tags") if isinstance(data.get("dist-tags"), dict) else {} latest = str(tags.get("latest") or "").strip() return latest, "npm:dist-tags.latest" if st == "pypi": pkg = urllib_parse.quote(sr, safe="") data = _http_get_json(f"https://pypi.org/pypi/{pkg}/json") info = data.get("info") if isinstance(data.get("info"), dict) else {} latest = str(info.get("version") or "").strip() return latest, "pypi:info.version" if st == "github": repo = _github_repo_from_source_ref(sr) if not repo: return "", "github:repo_parse_failed" try: data = _http_get_json(f"https://api.github.com/repos/{repo}/releases/latest") latest = str(data.get("tag_name") or data.get("name") or "").strip() if latest: return latest, "github:releases.latest" except Exception: pass data2 = _http_get_json(f"https://api.github.com/repos/{repo}/tags?per_page=1") if isinstance(data2, list): # defensive; _http_get_json returns dict for objects return "", "github:tags_empty" # GitHub tags endpoint returns list; if parse failed to dict, try raw fetch. req = urllib_request.Request( f"https://api.github.com/repos/{repo}/tags?per_page=1", headers={"accept": "application/json", "user-agent": "oclaw-admin-mcp-check/1.0"}, method="GET", ) with urllib_request.urlopen(req, timeout=8.0) as resp: arr_raw = resp.read().decode("utf-8", errors="ignore") arr = json.loads(arr_raw) if arr_raw else [] if isinstance(arr, list) and arr: top = arr[0] if isinstance(arr[0], dict) else {} return str(top.get("name") or "").strip(), "github:tags[0]" return "", "github:no_release_or_tag" return "", "unsupported_source_type" def _check_mcp_update_row(row: dict[str, Any]) -> dict[str, Any]: sid = str(row.get("server_id") or "").strip() source_type = str(row.get("source_type") or "").strip().lower() source_ref = str(row.get("source_ref") or "").strip() current_version = str(row.get("version") or "").strip() out = { "server_id": sid, "source_type": source_type, "source_ref": source_ref, "current_version": current_version, "latest_version": "", "has_update": False, "check_error": "", "check_source": "", } try: latest, src = _detect_mcp_latest_version(source_type, source_ref) out["latest_version"] = latest out["check_source"] = src if latest and current_version and current_version not in {"latest", "*"} and current_version != latest: out["has_update"] = True except Exception as exc: out["check_error"] = f"{type(exc).__name__}: {exc}" return out def _enrich_wecom_channel_account(store: SqliteStore, tenant_id: str, user_id: str, item: dict[str, Any]) -> dict[str, Any]: aid = str(item.get("account_id") or "") cfg = item.get("config") if not isinstance(cfg, dict): cfg = {} out = dict(item) out["has_bot_secret"] = bool(store.get_secret(_wecom_bot_secret_key(tenant_id, user_id, aid))) out["wecom_mode"] = "bot_api" return out def admin_static_dir() -> Path: return Path(__file__).resolve().parent / "static" def build_admin_router() -> APIRouter: router = APIRouter() static_dir = admin_static_dir() def _sha256_hex(v: str) -> str: return hashlib.sha256(str(v or "").encode("utf-8", errors="ignore")).hexdigest() def _now_utc() -> datetime: return datetime.now(timezone.utc) def _parse_iso(v: str) -> datetime | None: try: return datetime.fromisoformat(str(v)) except Exception: return None def _extract_bearer(authorization: str | None) -> str: blob = str(authorization or "").strip() if not blob: return "" parts = blob.split(None, 1) if len(parts) == 2 and parts[0].lower() == "bearer": return parts[1].strip() return "" def _resolve_login_tenant_id(store: SqliteStore, *, username: str, tenant_id: str) -> str: """Pick tenant when login body omits tenant_id. Chat login sends tenant_id=\"\". Using list_tenants(limit=1) alone picks the newest tenant (often pg-smoke), which has no chat history. For administrator, prefer the Team tenant, then the tenant with the most owned sessions. """ explicit = str(tenant_id or "").strip() if explicit: return explicit uname = str(username or "").strip().lower() tenants = store.list_tenants(limit=500) if not tenants: return "" if uname == "administrator": for row in tenants: tid = str((row or {}).get("id") or "").strip() if not tid: continue if str((row or {}).get("name") or "").strip().lower() != "team": continue if store.get_user_by_username(tenant_id=tid, username="administrator"): return tid best_tid = "" best_n = -1 for row in tenants: tid = str((row or {}).get("id") or "").strip() if not tid: continue user = store.get_user_by_username(tenant_id=tid, username="administrator") if not user: continue uid = str(user.get("id") or "") meta = store.get_sessions_list_meta_for_user(tenant_id=tid, user_id=uid) n = int(meta.session_count or 0) if n > best_n: best_n = n best_tid = tid if best_tid: return best_tid return str((tenants[0] or {}).get("id") or "").strip() def _resolve_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]: token = _extract_bearer(authorization) if not token: raise HTTPException(status_code=401, detail="missing_bearer_token") session = store.get_auth_session(session_token_hash=_sha256_hex(token)) if not session: raise HTTPException(status_code=401, detail="invalid_session") if session.get("revoked_at"): raise HTTPException(status_code=401, detail="session_revoked") expires_at = _parse_iso(str(session.get("expires_at") or "")) if expires_at is None or expires_at <= _now_utc(): raise HTTPException(status_code=401, detail="session_expired") user = store.get_user_by_id( tenant_id=str(session.get("tenant_id") or ""), user_id=str(session.get("user_id") or ""), ) if not user: raise HTTPException(status_code=401, detail="user_not_found") if not bool(user.get("is_active")): raise HTTPException(status_code=403, detail="user_disabled") store.touch_auth_session(session_token_hash=str(session.get("session_token_hash") or "")) perms = store.list_user_permissions( tenant_id=str(user.get("tenant_id") or ""), user_id=str(user.get("id") or ""), role=str(user.get("role") or "member"), ) return { "tenant_id": user["tenant_id"], "user_id": user["id"], "username": str(user.get("username") or ""), "role": user["role"], "permissions": perms, } def _require_permission(ctx: dict[str, Any], permission: str) -> None: perms = set(str(x) for x in (ctx.get("permissions") or [])) if permission in perms: return if str(ctx.get("role") or "") == "owner": return raise HTTPException(status_code=403, detail=f"forbidden:{permission}") def _require_tenant_scope(ctx: dict[str, Any], tenant_id: str) -> None: if str(ctx.get("tenant_id") or "") == str(tenant_id or ""): return # 控制台仅 administrator 可登录;该账号允许跨租户读写(与单租户会话 tenant_id 解耦)。 if str(ctx.get("username") or "").strip().lower() == "administrator": return raise HTTPException(status_code=403, detail="cross_tenant_forbidden") def _resolve_ops_ai_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]: shared = str(os.getenv("OCLAW_OPS_AI_SHARED_TOKEN") or "").strip() token = _extract_bearer(authorization) if shared and token and hmac.compare_digest(shared, token): return { "tenant_id": "", "user_id": "ops-ai-service", "username": "ops-ai-service", "role": "service", "permissions": ["admin:read"], } return _resolve_auth(store, authorization) def _ordered_specialists() -> list[str]: base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()] preferred = [x for x in ("generalist", "ops", "memory") if x in set(base)] return preferred + [x for x in base if x not in set(preferred)] def _ordered_mcp_roles() -> list[str]: return _ordered_specialists() def _ordered_roles() -> list[str]: """Canonical role list used by Admin preview APIs.""" return _ordered_specialists() _EXPERT_ALLOWED_FILES = {"SOUL.md", "ROLE_SYSTEM.md"} def _serialize_expert_row(row: dict[str, Any]) -> dict[str, Any]: files = row.get("files") if isinstance(row, dict) else {} obj = files if isinstance(files, dict) else {} return { "id": str(row.get("id") or ""), "builtin": bool(row.get("builtin")), "has_required_soul": bool(row.get("has_required_soul")), "display_name_en": str(row.get("display_name_en") or ""), "display_name_zh": str(row.get("display_name_zh") or ""), "role": str(row.get("role") or "expert"), "path": str(row.get("path") or ""), "files": { "SOUL.md": str(obj.get("SOUL.md") or ""), "ROLE_SYSTEM.md": str(obj.get("ROLE_SYSTEM.md") or ""), }, } def _sanitize_expert_files_payload(raw: Any) -> dict[str, str]: files_raw = raw if isinstance(raw, dict) else {} files = {str(k): str(v or "") for k, v in files_raw.items()} bad = [k for k in files.keys() if k not in _EXPERT_ALLOWED_FILES] if bad: raise ValueError("unsupported_file_name") return files @router.post("/admin/api/tools/internal/reload") def api_internal_tools_reload( authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from runtime.tools.expert_registry import clear_expert_tool_cache from runtime.tools.public_registry import clear_public_tool_cache clear_public_tool_cache() clear_expert_tool_cache() store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="internal_tools_reload", target_type="tool_cache", target_id="public+expert", status="ok", detail={"cleared": True}, ) return {"ok": True, "cleared": True} @router.get("/admin/api/tools/exposure-trace-setting") def api_tools_exposure_trace_setting_get( authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") raw = str(store.get_setting("AIA_TRACE_TOOL_EXPOSURE_PLAN") or "").strip().lower() enabled = raw in {"1", "true", "yes", "on"} return {"ok": True, "enabled": bool(enabled)} @router.post("/admin/api/tools/exposure-trace-setting") def api_tools_exposure_trace_setting_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") payload = payload or {} enabled = bool(payload.get("enabled")) store.set_setting("AIA_TRACE_TOOL_EXPOSURE_PLAN", "1" if enabled else "0") store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="tools_exposure_trace_setting_update", target_type="app_setting", target_id="AIA_TRACE_TOOL_EXPOSURE_PLAN", status="ok", detail={"enabled": bool(enabled)}, ) return {"ok": True, "enabled": bool(enabled)} @router.get("/admin/api/tools/internal/preview") def api_internal_tools_preview( role: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from runtime.tools.exposure_plan import build_internal_tool_specs r = str(role or "").strip().lower() available_roles = _ordered_roles() if not r: r = "generalist" if "generalist" in set(available_roles) else (available_roles[0] if available_roles else "") if r not in set(available_roles): raise HTTPException(status_code=400, detail="invalid_role") specs, diag = build_internal_tool_specs(role=r, preview=True) def _to_row(spec: Any, source: str) -> dict[str, Any]: return { "name": str(getattr(spec, "name", "") or ""), "description": str(getattr(spec, "description", "") or ""), "tags": sorted([str(x) for x in (getattr(spec, "tags", None) or [])]), "read_only": bool(getattr(spec, "read_only", False)), "risk_level": str(getattr(spec, "risk_level", "") or ""), "timeout_s": float(getattr(spec, "timeout_s", 0.0) or 0.0), } tools = [] source_by_name = dict(diag.get("source_by_name") or {}) for s in specs: src = str(source_by_name.get(str(getattr(s, "name", "") or ""), "expert")) tools.append({**_to_row(s, src), "source": src}) tools.sort(key=lambda x: (str(x.get("source") or ""), str(x.get("name") or ""))) return {"ok": True, "role": r, "available_roles": available_roles, "tools": tools, **diag} @router.get("/admin/api/tools/self-check") def api_tools_self_check( authorization: str | None = Header(default=None), ) -> dict[str, Any]: """One-shot diagnostic summary for role-based tool exposure.""" store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from runtime.tools.exposure_plan import build_internal_tool_specs, build_llm_tools_plan roles = _ordered_roles() items: list[dict[str, Any]] = [] total_wired = 0 total_internal = 0 for role in roles: internal_specs, internal_diag = build_internal_tool_specs(role=role, preview=True) llm_plan = build_llm_tools_plan( store=store, role=role, base_url=None, max_json_bytes=None, include_mcp=True, preview_internal=True, ) total_wired += len(llm_plan.tools_wired) total_internal += len(internal_specs) items.append( { "role": role, "role_mode": "unrestricted", "internal_count": len(internal_specs), "internal_public_count": int(internal_diag.get("public_count") or 0), "internal_expert_count": int(internal_diag.get("expert_count") or 0), "wired_count": len(llm_plan.tools_wired), "raw_count": len(llm_plan.tools_raw), "removed_total": len(llm_plan.removed_names), "removed_mcp_total": len(llm_plan.removed_mcp_names), "changed_total": len(llm_plan.changed_names), "mcp_enabled": bool(llm_plan.mcp_enabled), } ) return { "ok": True, "roles_total": len(roles), "roles": roles, "summary": { "total_internal_tools": total_internal, "total_wired_tools": total_wired, }, "items": items, } @router.get("/admin/api/tools/llm/preview") def api_llm_tools_preview( role: str | None = Query(default=None), base_url: str | None = Query(default=None), max_json_bytes: int | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: """Preview the final tools injected to LLM for a role (internal + MCP binding).""" store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from runtime.tools.exposure_plan import build_llm_tools_plan r = str(role or "").strip().lower() available_roles = _ordered_roles() if not r: r = "generalist" if "generalist" in set(available_roles) else (available_roles[0] if available_roles else "") if r not in set(available_roles): raise HTTPException(status_code=400, detail="invalid_role") plan = build_llm_tools_plan( store=store, role=r, base_url=base_url, max_json_bytes=max_json_bytes, include_mcp=True, preview_internal=True, ) return { "ok": True, "role": plan.role, "available_roles": available_roles, "base_url": plan.base_url, "max_json_bytes": plan.max_json_bytes, "mcp_enabled": plan.mcp_enabled, "role_mode": plan.role_mode, "raw_count": len(plan.tools_raw), "wired_count": len(plan.tools_wired), "removed_mcp_names": plan.removed_mcp_names, "tools_raw": plan.tools_raw, "tools_wired": plan.tools_wired, "public_risk_gate_allow_high": plan.public_risk_gate_allow_high, "public_blocked_high_risk_tools": plan.public_blocked_high_risk_tools, "skipped_public": plan.skipped_public, "skipped_expert": plan.skipped_expert, } def _load_mcp_specialist_binding(store: SqliteStore) -> dict[str, list[str]]: raw = str(store.get_setting("mcp_specialist_server_binding") or "").strip() if not raw: return {} try: obj = json.loads(raw) except Exception: return {} if not isinstance(obj, dict): return {} out: dict[str, list[str]] = {} for k, v in obj.items(): sid = str(k or "").strip().lower() if not sid: continue items = v if isinstance(v, list) else [] vals = [str(x).strip() for x in items if str(x).strip()] out[sid] = vals return out def _normalize_mcp_specialist_binding( *, available_specialists: list[str], servers: list[dict[str, Any]], mapping: dict[str, Any], ) -> dict[str, list[str]]: available_set = set(available_specialists) server_ids = {str(x.get("server_id") or "").strip() for x in servers if str(x.get("server_id") or "").strip()} out: dict[str, list[str]] = {} for sp in available_specialists: rows = mapping.get(sp) if isinstance(mapping, dict) else None items = rows if isinstance(rows, list) else [] vals = [] seen: set[str] = set() for x in items: sid = str(x or "").strip() if not sid or sid not in server_ids or sid in seen: continue seen.add(sid) vals.append(sid) out[sp] = vals # drop any unknown specialist keys by only returning available list keys for key in list(out.keys()): if key not in available_set: out.pop(key, None) return out def _ensure_admin_bootstrap(store: SqliteStore) -> None: tenants = store.list_tenants(limit=500) if not tenants: store.create_tenant("Team") tenants = store.list_tenants(limit=500) pwd = load_expected_password(store) if not pwd: # Fail closed: require explicit password configuration. raise HTTPException( status_code=400, detail="missing_admin_password: set AIA_ASSISTANT_PASSWORD env or store secret auth_password first", ) pwd_hash = _sha256_hex(pwd) for tenant in tenants: tid = str((tenant or {}).get("id") or "").strip() if not tid: continue if store.get_user_by_username(tenant_id=tid, username="administrator"): continue store.create_user_account( tenant_id=tid, username="administrator", display_name="Administrator", role="admin", password_hash=pwd_hash, is_active=True, ) @router.get("/admin", response_class=HTMLResponse) def admin_root() -> Any: p = static_dir / "index.html" return HTMLResponse(p.read_text(encoding="utf-8")) @router.get("/admin/api/channels") def api_channels(authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(get_assistant_store(), authorization) _require_permission(ctx, "admin:read") reg = build_channel_registry() items = [{"name": k, "type": reg[k].__class__.__name__} for k in sorted(reg.keys())] return {"ok": True, "channels": items} @router.get("/admin/api/stack/status") def api_stack_status(authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(get_assistant_store(), authorization) _require_permission(ctx, "admin:read") items = [] for s in status_services(): all_pids = list_service_process_pids(str(s.name)) pid_ports: dict[str, list[int]] = {} pid_running: dict[str, bool] = {} ports_set: set[int] = set() for p in all_pids: pp = list_listen_ports_for_pid(int(p)) pid_ports[str(int(p))] = [int(x) for x in pp] pid_running[str(int(p))] = bool(is_pid_running(int(p))) for x in pp: ports_set.add(int(x)) running_any = any(bool(v) for v in pid_running.values()) if pid_running else bool(s.running) # Choose primary pid from the current snapshot (do not "stick" to state pid). live = sorted([int(k) for k, v in pid_running.items() if v]) primary_pid = 0 if live: name = str(getattr(s, "name", "") or "") # Prefer gateway PID that actually binds the gateway port. if name == "gateway": prefer_port = 8787 candidates = [p for p in live if int(prefer_port) in set(pid_ports.get(str(p), []) or [])] if candidates: primary_pid = int(candidates[0]) if not primary_pid: # Prefer PID(s) with any listening ports. with_ports = [p for p in live if (pid_ports.get(str(p), []) or [])] if with_ports: # Choose the one with most ports, tie-breaker: smallest pid with_ports.sort(key=lambda p: (-len(pid_ports.get(str(p), []) or []), int(p))) primary_pid = int(with_ports[0]) if not primary_pid: # Fall back to smallest live pid for stability. primary_pid = int(live[0]) items.append( { "name": s.name, "pid": int(primary_pid or 0), "running": bool(running_any), "all_pids": sorted(int(x) for x in all_pids if int(x) > 0), "ports": sorted(ports_set), "pid_ports": pid_ports, "pid_running": pid_running, "duplicate_count": max(0, len(set(int(x) for x in all_pids if int(x) > 0)) - 1), } ) return {"ok": True, "items": items} @router.post("/admin/api/stack/up") def api_stack_up(channel: str = "wecom", authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(get_assistant_store(), authorization) _require_permission(ctx, "admin:runtime:write") # Use same defaults as CLI; this starts detached processes and writes runtime state. import argparse ns = argparse.Namespace( channel=channel, channel_mode="ws", channel_interval=3.0, deliver_outbound=True, pull_url=None, gateway_host="0.0.0.0", gateway_port=8787, with_ui=False, ui_port=8501, ) cmd_stack_up(ns) return api_stack_status() @router.post("/admin/api/stack/down") def api_stack_down(authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(get_assistant_store(), authorization) _require_permission(ctx, "admin:runtime:write") import argparse ns = argparse.Namespace() cmd_stack_down(ns) return api_stack_status() def _collect_runtime_anomalies(store: SqliteStore) -> dict[str, Any]: items = [] running = status_services() for s in running: if not bool(getattr(s, "running", False)): # Duplicate worker checks only make sense when a primary service # is currently running. Otherwise this creates noisy conflicts # with "missing service" alerts. continue keep = {int(s.pid)} if s.running and int(s.pid or 0) > 0 else set() all_pids = list_service_process_pids(s.name) if (not keep) and len(all_pids) > 1: # Ambiguous primary process: do not guess and do not auto-clean. # Require explicit stack restart to re-elect primary PID safely. items.append( { "type": "duplicate_process_ambiguous", "service": s.name, "expected_pid": 0, "orphan_pids": sorted(int(x) for x in all_pids), "severity": "critical", "message": ( f"{s.name} has multiple workers but no stable primary pid in runtime state: " f"{', '.join(str(x) for x in sorted(all_pids))}. " "Please restart stack (down/up)." ), } ) continue orphans = detect_orphan_service_processes(s.name, keep_pids=keep) if orphans: items.append( { "type": "duplicate_process", "service": s.name, "expected_pid": int(s.pid or 0), "orphan_pids": sorted(int(x) for x in orphans), "severity": "critical", "message": f"{s.name} has duplicate workers: {', '.join(str(x) for x in sorted(orphans))}", } ) last_parse_error = str(store.get_setting("wecom_last_parse_error") or "").strip() last_outbound_error = str(store.get_setting("wecom_last_outbound_error") or "").strip() if last_parse_error: sev = "warning" if "disconnected_event" in last_parse_error.lower(): sev = "critical" items.append( { "type": "wecom_parse_error", "service": "channel:wecom", "severity": sev, "message": f"wecom_last_parse_error={last_parse_error}", } ) if last_outbound_error: items.append( { "type": "wecom_outbound_error", "service": "channel:wecom", "severity": "critical", "message": f"wecom_last_outbound_error={last_outbound_error}", } ) must_cleanup = any(str(x.get("type") or "") == "duplicate_process" for x in items) return {"ok": True, "must_cleanup": must_cleanup, "items": items} @router.get("/admin/api/runtime/anomalies") def api_runtime_anomalies(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") return _collect_runtime_anomalies(store) @router.post("/admin/api/runtime/cleanup") def api_runtime_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") killed: list[dict[str, Any]] = [] # Cleanup rule: # - show all PIDs to user; cleanup should be conservative. # - for a service with multiple workers, kill those with NO listening ports. # - never delete the last remaining worker for a service. for s in status_services(): service = str(getattr(s, "name", "") or "").strip() if not service: continue all_pids = [int(x) for x in list_service_process_pids(service) if int(x) > 0] if len(all_pids) <= 1: continue pid_ports: dict[int, list[int]] = {int(p): list_listen_ports_for_pid(int(p)) for p in all_pids} with_ports = [p for p in all_pids if pid_ports.get(int(p))] without_ports = [p for p in all_pids if not pid_ports.get(int(p))] victims: list[int] = [] if with_ports: victims = list(without_ports) else: # If none are listening (e.g. long-polling channel workers), # keep the recorded primary pid when possible, otherwise keep the first. primary = int(getattr(s, "pid", 0) or 0) keep = primary if primary in all_pids else int(all_pids[0]) victims = [p for p in all_pids if int(p) != int(keep)] if victims and len(victims) >= len(all_pids): victims = victims[: max(0, len(all_pids) - 1)] dead = cleanup_service_processes_by_pid(service, victims) if dead: killed.append({"service": service, "killed_pids": sorted(int(x) for x in dead)}) anomalies = _collect_runtime_anomalies(store) return {"ok": True, "killed": killed, "anomalies": anomalies} @router.get("/admin/api/runtime/prewarm/status") def api_runtime_prewarm_status(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") return runtime_prewarm_status(store=store) @router.post("/admin/api/runtime/prewarm") def api_runtime_prewarm( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") body = payload or {} mode = str(body.get("mode") or "async").strip().lower() reason = str(body.get("reason") or "admin_manual").strip() or "admin_manual" if mode == "sync": return run_runtime_prewarm(reason=reason, store=store) import threading def _run() -> None: try: _ = run_runtime_prewarm(reason=reason, store=get_assistant_store()) except Exception: pass th = threading.Thread(target=_run, name="oclaw-admin-prewarm", daemon=True) th.start() return {"ok": True, "accepted": True, "mode": "async", "status": runtime_prewarm_status(store=store)} @router.get("/admin/api/runtime/prewarm/prompts") def api_runtime_prewarm_prompts( role: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") return runtime_prewarm_prompts_snapshot(store=store, role=str(role or "").strip().lower() or None) @router.get("/admin/api/runtime/scan-artifacts") def api_runtime_scan_artifacts(authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(get_assistant_store(), authorization) _require_permission(ctx, "admin:read") root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve() allowed_prefixes = ("history_entries_", "state_scan_") items: list[dict[str, Any]] = [] if root.exists() and root.is_dir(): for p in sorted(root.glob("*.json"), key=lambda x: x.name.lower()): if not p.name.startswith(allowed_prefixes): continue try: st = p.stat() items.append( { "name": p.name, "path": str(p), "bytes": int(st.st_size), "modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(), } ) except Exception: continue return {"ok": True, "dir": str(root), "count": len(items), "items": items} @router.post("/admin/api/runtime/scan-artifacts/cleanup") def api_runtime_scan_artifacts_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]: ctx = _resolve_auth(get_assistant_store(), authorization) _require_permission(ctx, "admin:runtime:write") root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve() allowed_prefixes = ("history_entries_", "state_scan_") removed = 0 if root.exists() and root.is_dir(): for p in root.glob("*.json"): if not p.name.startswith(allowed_prefixes): continue try: p.unlink(missing_ok=True) removed += 1 except Exception: continue return {"ok": True, "dir": str(root), "removed": int(removed)} @router.post("/admin/api/runtime/scan-artifacts/prune") def api_runtime_scan_artifacts_prune( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: body = payload or {} ctx = _resolve_auth(get_assistant_store(), authorization) _require_permission(ctx, "admin:runtime:write") root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve() try: keep_latest = int(body.get("keep_latest", 20)) except Exception: keep_latest = 20 try: max_age_days = int(body.get("max_age_days", 7)) except Exception: max_age_days = 7 keep_latest = max(0, min(keep_latest, 500)) max_age_days = max(0, min(max_age_days, 3650)) allowed_prefixes = ("history_entries_", "state_scan_") rows: list[tuple[Path, float]] = [] if root.exists() and root.is_dir(): for p in root.glob("*.json"): if not p.name.startswith(allowed_prefixes): continue try: rows.append((p, float(p.stat().st_mtime))) except Exception: continue rows.sort(key=lambda x: x[1], reverse=True) cutoff_ts = None if max_age_days > 0: cutoff_ts = (_now_utc() - timedelta(days=max_age_days)).timestamp() removed = 0 for idx, (p, mtime) in enumerate(rows): remove_by_rank = keep_latest >= 0 and idx >= keep_latest remove_by_age = cutoff_ts is not None and mtime < cutoff_ts if not remove_by_rank and not remove_by_age: continue try: p.unlink(missing_ok=True) removed += 1 except Exception: continue return { "ok": True, "dir": str(root), "removed": int(removed), "keep_latest": int(keep_latest), "max_age_days": int(max_age_days), } @router.post("/admin/api/runtime/sqlite-retention/prune") def api_runtime_sqlite_retention_prune( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: """Dry-run or apply retention prune for tool/trace/outbound noise. Body: keep_days (default 30), dry_run (default true), vacuum (default false), include_scheduled_runs (default false), include_outbound (default true). Apply requires dry_run=false and confirm=true. """ from svc.persistence.sqlite_retention import prune_sqlite_retention body = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") try: keep_days = int(body.get("keep_days", 30)) except Exception: keep_days = 30 dry_run = body.get("dry_run", True) if isinstance(dry_run, str): dry_run = dry_run.strip().lower() not in {"0", "false", "no", "off"} else: dry_run = bool(dry_run) vacuum = bool(body.get("vacuum", False)) include_scheduled_runs = bool(body.get("include_scheduled_runs", False)) include_outbound = body.get("include_outbound", True) if isinstance(include_outbound, str): include_outbound = include_outbound.strip().lower() not in {"0", "false", "no", "off"} else: include_outbound = bool(include_outbound) if not dry_run and not bool(body.get("confirm", False)): return { "ok": False, "error": "confirm_required", "hint": "Set dry_run=false and confirm=true to delete. Prefer dry_run first.", } return prune_sqlite_retention( store, keep_days=keep_days, dry_run=dry_run, vacuum=vacuum and not dry_run, include_outbound_sent=include_outbound, include_scheduled_runs=include_scheduled_runs, ) @router.get("/admin/api/tenants") def api_tenants( scope: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:read") rows = store.list_tenants(limit=500) if str(scope or "").strip().lower() == "mine": tid = str(ctx.get("tenant_id") or "").strip() rows = [r for r in rows if str(r.get("id") or "") == tid] return {"ok": True, "tenants": rows} @router.post("/admin/api/tenants/create") def api_tenants_create( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") name = str(payload.get("name") or "").strip() or "Team" tenant = store.create_tenant(name) return {"ok": True, "tenant": tenant} @router.post("/admin/api/tenants/delete") def api_tenants_delete( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") tenant_id = str(payload.get("tenant_id") or "").strip() _require_tenant_scope(ctx, tenant_id) if not tenant_id: return {"ok": False, "error": "tenant_id is required"} all_tenants = store.list_tenants(limit=1000) if len(all_tenants) <= 1: return {"ok": False, "error": "last_tenant_cannot_delete"} if tenant_id == str(ctx.get("tenant_id") or "").strip(): return {"ok": False, "error": "cannot_delete_current_tenant"} if not any(str(r.get("id") or "") == tenant_id for r in all_tenants): return {"ok": False, "error": "tenant_not_found"} deleted = store.delete_tenant(tenant_id=tenant_id) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="tenant_delete", target_type="tenant", target_id=tenant_id, status="ok" if deleted > 0 else "miss", ) return {"ok": True, "deleted": deleted} @router.get("/admin/api/bindings") def api_bindings( tenant_id: str, channel: str = "wecom", user_id: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:read") _require_tenant_scope(ctx, tenant_id) uid = str(user_id or "").strip() or None # Prefer v2 bindings for WeCom (includes account_id + account_name). if str(channel or "").strip().lower() == "wecom": rows = store.list_channel_identities_v2( tenant_id=tenant_id, channel=channel, user_id=uid, limit=2000 ) else: rows = store.list_channel_identities(tenant_id=tenant_id, channel=channel, limit=1000) if uid: rows = [r for r in rows if str(r.get("user_id") or "") == uid] return {"ok": True, "bindings": rows, "version": 2 if str(channel or "").strip().lower() == "wecom" else 1} @router.get("/admin/api/user-channel-accounts") def api_user_channel_accounts( tenant_id: str, user_id: str, channel: str = "wecom", include_inactive: int = Query(default=1), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:read") _require_tenant_scope(ctx, tenant_id) rows = store.list_user_channel_accounts( tenant_id=tenant_id, user_id=user_id, channel=channel, include_inactive=bool(int(include_inactive or 0)), ) if str(channel or "").strip().lower() == "wecom": rows = [_enrich_wecom_channel_account(store, tenant_id, user_id, dict(x)) for x in rows] return {"ok": True, "items": rows} @router.post("/admin/api/user-channel-accounts/upsert") def api_user_channel_accounts_upsert( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") tenant_id = str(payload.get("tenant_id") or "").strip() user_id = str(payload.get("user_id") or "").strip() channel = str(payload.get("channel") or "wecom").strip() or "wecom" account_id = str(payload.get("account_id") or "").strip() name = str(payload.get("name") or "").strip() _require_tenant_scope(ctx, tenant_id) if not tenant_id or not user_id or not account_id: return {"ok": False, "error": "tenant_id, user_id, account_id are required"} if not store.get_user_by_id(tenant_id=tenant_id, user_id=user_id): return {"ok": False, "error": "user_not_found"} is_active = payload.get("is_active") cfg: dict[str, Any] = {} existing_rows = store.list_user_channel_accounts( tenant_id=tenant_id, user_id=user_id, channel=channel, include_inactive=True ) cur_row = next((x for x in existing_rows if str(x.get("account_id") or "") == account_id), None) if cur_row and isinstance(cur_row.get("config"), dict): cfg = dict(cur_row["config"]) if isinstance(payload.get("config"), dict): cfg.update(payload["config"]) if str(channel or "").strip().lower() == "wecom": cfg["wecom_mode"] = "bot_api" cfg.pop("wecom_corp_id", None) cfg.pop("wecom_agent_id", None) bot_secret = str(payload.get("wecom_bot_secret") or payload.get("bot_secret") or "").strip() if bot_secret: store.set_secret(_wecom_bot_secret_key(tenant_id, user_id, account_id), bot_secret) if payload.get("clear_bot_secret") is True: store.delete_setting(_wecom_bot_secret_key(tenant_id, user_id, account_id)) store.upsert_user_channel_account( tenant_id=tenant_id, user_id=user_id, channel=channel, account_id=account_id, name=name, config=cfg, is_active=bool(is_active) if isinstance(is_active, bool) else True, ) rows = store.list_user_channel_accounts(tenant_id=tenant_id, user_id=user_id, channel=channel, include_inactive=True) row = next((x for x in rows if str(x.get("account_id") or "") == account_id), None) if str(channel or "").strip().lower() == "wecom" and row: row = _enrich_wecom_channel_account(store, tenant_id, user_id, dict(row)) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="user_channel_account_upsert", target_type="user_channel_account", target_id=f"{tenant_id}:{user_id}:{channel}:{account_id}", status="ok", detail={"name": name, "is_active": bool((row or {}).get("is_active", True))}, ) return {"ok": True, "item": row or {}} @router.post("/admin/api/user-channel-accounts/delete") def api_user_channel_accounts_delete( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") tenant_id = str(payload.get("tenant_id") or "").strip() user_id = str(payload.get("user_id") or "").strip() channel = str(payload.get("channel") or "wecom").strip() or "wecom" account_id = str(payload.get("account_id") or "").strip() _require_tenant_scope(ctx, tenant_id) if not tenant_id or not user_id or not account_id: return {"ok": False, "error": "tenant_id, user_id, account_id are required"} deleted = store.delete_user_channel_account( tenant_id=tenant_id, user_id=user_id, channel=channel, account_id=account_id, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="user_channel_account_delete", target_type="user_channel_account", target_id=f"{tenant_id}:{user_id}:{channel}:{account_id}", status="ok" if deleted > 0 else "miss", ) return {"ok": True, "deleted": deleted} @router.get("/admin/api/whatsapp/groups") def api_whatsapp_groups( tenant_id: str = Query(default="default"), account_id: str = Query(default=""), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tid = str(tenant_id or "default").strip() aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid) session_groups: list[dict[str, Any]] = [] try: with store._connect() as conn: rows = conn.execute( """ SELECT DISTINCT external_chat_id, session_id FROM channel_session_v2 WHERE tenant_id = ? AND channel = 'whatsapp' AND account_id = ? AND external_chat_id LIKE '%@g.us' """, (tid, aid), ).fetchall() for r in rows: jid = str(r[0] or "").strip() if jid: session_groups.append({"group_jid": jid, "session_id": str(r[1] or "")}) except Exception: session_groups = [] merged: dict[str, dict[str, Any]] = {} for g in known: jid = str(g.get("group_jid") or "").strip() if jid: merged[jid] = g for sg in session_groups: jid = str(sg.get("group_jid") or "").strip() if jid and jid not in merged: merged[jid] = { "tenant_id": tid, "account_id": aid, "group_jid": jid, "group_name": "", "last_seen_at": "", "session_id": sg.get("session_id"), } binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid) return {"ok": True, "items": list(merged.values()), "binding": binding} @router.get("/admin/api/whatsapp/alert-binding") def api_whatsapp_alert_binding_get( tenant_id: str = Query(default="default"), account_id: str = Query(default=""), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tid = str(tenant_id or "default").strip() aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid) return {"ok": True, "binding": binding} @router.post("/admin/api/whatsapp/alert-binding") def api_whatsapp_alert_binding_upsert( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tid = str(payload.get("tenant_id") or "default").strip() aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() group_jid = str(payload.get("group_jid") or "").strip() if not group_jid: return {"ok": False, "error": "group_jid_required"} binding = store.upsert_whatsapp_alert_binding( tenant_id=tid, account_id=aid, group_jid=group_jid, group_name=str(payload.get("group_name") or "").strip(), enabled=bool(payload.get("enabled", True)), ) return {"ok": True, "binding": binding} @router.post("/admin/api/whatsapp/alert-binding/test") def api_whatsapp_alert_binding_test( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tid = str(payload.get("tenant_id") or "default").strip() aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() binding = store.get_whatsapp_alert_binding(tenant_id=tid, account_id=aid) if not binding or not bool(binding.get("enabled")): return {"ok": False, "error": "binding_missing"} group_jid = str(binding.get("group_jid") or "").strip() if not group_jid: return {"ok": False, "error": "group_jid_missing"} msg_id = store.enqueue_channel_outbound_message( channel="whatsapp", chat_id=group_jid, text="[OClaw] WhatsApp 告警群绑定测试消息", tenant_id=tid, account_id=aid, source="admin.test", ) return {"ok": True, "outbound_id": msg_id} @router.get("/admin/api/whatsapp/access") def api_whatsapp_access_get( tenant_id: str = Query(default="default"), account_id: str = Query(default=""), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() tid = resolve_whatsapp_tenant_id(store, account_id=aid) legacy_tid = str(tenant_id or "default").strip() # Be defensive: older DBs/gateways may not have the new tables yet. # Do not break the entire Runtime page; return empty defaults instead. try: cfg = store.get_whatsapp_access_config(tenant_id=tid, account_id=aid) or {} if not cfg and legacy_tid and legacy_tid != tid: cfg = store.get_whatsapp_access_config(tenant_id=legacy_tid, account_id=aid) or {} except Exception: cfg = {} contacts_by_phone: dict[str, dict[str, Any]] = {} try: from runtime.extensions.whatsapp.access_control import contact_phone_key priority = {"admin": 4, "blacklist": 3, "whitelist": 2} def _contact_rank(row: dict[str, Any]) -> int: lt = str(row.get("list_type") or "").strip().lower() return int(priority.get(lt, 0)) for source_tid in (tid, legacy_tid): if not source_tid: continue for row in store.list_whatsapp_contacts(tenant_id=source_tid, account_id=aid, limit=500): phone_key = contact_phone_key(row) if not phone_key: continue prev = contacts_by_phone.get(phone_key) if not prev or _contact_rank(row) > _contact_rank(prev): contacts_by_phone[phone_key] = row contacts = list(contacts_by_phone.values()) contacts = [ row for row in contacts if str(row.get("list_type") or "").strip().lower() in {"admin", "whitelist", "blacklist"} ] except Exception: contacts = [] pending_by_id: dict[str, dict[str, Any]] = {} try: for source_tid in (tid, legacy_tid): if not source_tid: continue try: store.expire_stale_whatsapp_access_pending( tenant_id=source_tid, account_id=aid, older_than_hours=168, ) except Exception: pass for row in store.list_whatsapp_access_pending( tenant_id=source_tid, account_id=aid, status="pending", limit=50 ): pid = str(row.get("id") or "").strip() if pid: pending_by_id[pid] = row pending = list(pending_by_id.values()) except Exception: pending = [] denied_by_id: dict[str, dict[str, Any]] = {} try: for source_tid in (tid, legacy_tid): if not source_tid: continue for row in store.list_whatsapp_access_pending( tenant_id=source_tid, account_id=aid, status="denied", limit=50 ): pid = str(row.get("id") or "").strip() if pid: denied_by_id[pid] = row denied = list(denied_by_id.values()) except Exception: denied = [] return { "ok": True, "config": { "tenant_id": tid, "account_id": aid, "access_mode": str(cfg.get("access_mode") or "blacklist"), "lang": str(cfg.get("lang") or "en"), }, "contacts": contacts, "pending": pending, "denied": denied, } @router.post("/admin/api/whatsapp/access/config") def api_whatsapp_access_config_upsert( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() tid = resolve_whatsapp_tenant_id(store, account_id=aid) access_mode = str(payload.get("access_mode") or "blacklist").strip().lower() lang = str(payload.get("lang") or "en").strip().lower() cfg = store.upsert_whatsapp_access_config( tenant_id=tid, account_id=aid, access_mode=access_mode, lang=lang, ) return {"ok": True, "config": cfg} @router.post("/admin/api/whatsapp/access/contacts") def api_whatsapp_access_contact_upsert( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.extensions.whatsapp.access_control import ( normalize_whatsapp_phone, resolve_sender_phone, ) from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") aid = str(payload.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() tid = resolve_whatsapp_tenant_id(store, account_id=aid) target_raw = str(payload.get("phone") or payload.get("external_user_id") or "").strip() if not target_raw: return {"ok": False, "error": "phone_required"} try: phone_val = normalize_whatsapp_phone(target_raw) except Exception: return {"ok": False, "error": "invalid_phone"} list_type = str(payload.get("list_type") or "").strip().lower() if list_type not in {"admin", "whitelist", "blacklist"}: return {"ok": False, "error": "invalid_list_type"} contact = store.apply_whatsapp_contact_access( tenant_id=tid, account_id=aid, external_user_id=phone_val, push_name=str(payload.get("push_name") or ""), phone=phone_val, list_type=list_type, notes=str(payload.get("notes") or ""), ) resolved_by = str(ctx.get("user_id") or "") pending_status = "approved" if list_type in {"admin", "whitelist"} else "denied" legacy_tid = str(payload.get("tenant_id") or "default").strip() extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else [] store.resolve_whatsapp_pending_for_sender( tenant_id=tid, account_id=aid, external_user_id=phone_val, resolved_by=resolved_by, status=pending_status, extra_tenant_ids=extra_tids, ) return {"ok": True, "contact": contact} @router.delete("/admin/api/whatsapp/access/contacts") def api_whatsapp_access_contact_delete( tenant_id: str = Query(default="default"), account_id: str = Query(default=""), phone: str = Query(default=""), external_user_id: str = Query(default=""), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.extensions.whatsapp.access_control import normalize_whatsapp_phone from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() tid = resolve_whatsapp_tenant_id(store, account_id=aid) key = str(phone or external_user_id or "").strip() if not key: return {"ok": False, "error": "phone_required"} try: phone_val = normalize_whatsapp_phone(key) except Exception: return {"ok": False, "error": "invalid_phone"} legacy_tid = str(tenant_id or "default").strip() extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else [] deleted_count = store.delete_whatsapp_contact_aliases( tenant_id=tid, account_id=aid, external_user_id=phone_val, extra_tenant_ids=extra_tids, ) return {"ok": True, "deleted": deleted_count > 0, "deleted_count": deleted_count} @router.post("/admin/api/whatsapp/access/pending/resolve") def api_whatsapp_access_pending_resolve( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.extensions.whatsapp.access_control import resolve_sender_phone from runtime.extensions.whatsapp.tenant import resolve_whatsapp_tenant_id body = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") aid = str(body.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() tid = resolve_whatsapp_tenant_id(store, account_id=aid) legacy_tid = str(body.get("tenant_id") or "default").strip() extra_tids = [legacy_tid] if legacy_tid and legacy_tid != tid else [] pending_id = str(body.get("pending_id") or "").strip() action = str(body.get("action") or "").strip().lower() if not pending_id: return {"ok": False, "error": "pending_id_required"} if action not in {"approve", "deny", "dismiss"}: return {"ok": False, "error": "invalid_action"} items: list[dict[str, Any]] = [] seen: set[str] = set() for source_tid in (tid, *extra_tids): for status in ("pending", "denied"): for row in store.list_whatsapp_access_pending( tenant_id=source_tid, account_id=aid, status=status, limit=200 ): pid = str(row.get("id") or "") if pid and pid not in seen: seen.add(pid) items.append(row) row = next((x for x in items if str(x.get("id") or "") == pending_id), None) if not row: return {"ok": False, "error": "pending_not_found"} external_user_id = str(row.get("external_user_id") or "").strip() push_name = str(row.get("push_name") or "").strip() resolved_by = str(ctx.get("user_id") or "") if action == "dismiss": deleted = store.delete_whatsapp_access_pending(pending_id=pending_id) return {"ok": deleted, "action": "dismiss", "deleted": deleted} if action == "approve": phone_val = str(row.get("phone") or "").strip() or resolve_sender_phone(external_user_id) if not phone_val: return {"ok": False, "error": "invalid_phone"} store.apply_whatsapp_contact_access( tenant_id=tid, account_id=aid, external_user_id=phone_val, push_name=push_name, phone=phone_val, list_type="whitelist", ) changed = store.resolve_whatsapp_access_pending( pending_id=pending_id, status="approved", resolved_by=resolved_by, from_statuses=("pending", "denied"), ) store.resolve_whatsapp_pending_for_sender( tenant_id=tid, account_id=aid, external_user_id=phone_val, resolved_by=resolved_by, status="approved", extra_tenant_ids=extra_tids, ) try: from runtime.application.gateway.whatsapp_inbound_access import ( notify_whatsapp_access_decision, ) cfg = store.get_whatsapp_access_config(tenant_id=tid, account_id=aid) or {} notify_whatsapp_access_decision( store, tenant_id=tid, account_id=aid, lang=str(cfg.get("lang") or "en"), external_user_id=external_user_id, phone=phone_val, pending_id=pending_id, approved=True, ) except Exception: pass return { "ok": bool(changed), "action": "approve", "phone": phone_val, } phone_val = str(row.get("phone") or "").strip() or resolve_sender_phone(external_user_id) if phone_val: store.apply_whatsapp_contact_access( tenant_id=tid, account_id=aid, external_user_id=phone_val, push_name=push_name, phone=phone_val, list_type="blacklist", ) changed = store.resolve_whatsapp_access_pending( pending_id=pending_id, status="denied", resolved_by=resolved_by, from_statuses=("pending",), ) store.resolve_whatsapp_pending_for_sender( tenant_id=tid, account_id=aid, external_user_id=phone_val or external_user_id, resolved_by=resolved_by, status="denied", extra_tenant_ids=extra_tids, ) try: from runtime.application.gateway.whatsapp_inbound_access import ( notify_whatsapp_access_decision, ) cfg = store.get_whatsapp_access_config(tenant_id=tid, account_id=aid) or {} notify_whatsapp_access_decision( store, tenant_id=tid, account_id=aid, lang=str(cfg.get("lang") or "en"), external_user_id=external_user_id, phone=phone_val or "", pending_id=pending_id, approved=False, ) except Exception: pass return {"ok": bool(changed), "action": "deny", "phone": phone_val or external_user_id} @router.delete("/admin/api/whatsapp/access/pending") def api_whatsapp_access_pending_delete( pending_id: str = Query(default=""), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") pid = str(pending_id or "").strip() if not pid: return {"ok": False, "error": "pending_id_required"} deleted = store.delete_whatsapp_access_pending( pending_id=pid, statuses=("pending", "denied"), ) return {"ok": deleted, "deleted": deleted} @router.get("/admin/api/users") def api_users( tenant_id: str, q: str | None = Query(default=None), include_inactive: int = Query(default=1), offset: int = Query(default=0), limit: int = Query(default=1000), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:read") _require_tenant_scope(ctx, tenant_id) rows = store.list_users( tenant_id=tenant_id, q=q, include_inactive=bool(int(include_inactive or 0)), offset=offset, limit=limit, ) return {"ok": True, "users": rows} @router.post("/admin/api/users/create") def api_users_create( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") tenant_id = str(payload.get("tenant_id") or "").strip() or str(ctx.get("tenant_id") or "").strip() _require_tenant_scope(ctx, tenant_id) username = str(payload.get("username") or "").strip().lower() display_name = str(payload.get("display_name") or username or "User").strip() role = str(payload.get("role") or "member").strip() or "member" password = str(payload.get("password") or "").strip() if not username or not password: return {"ok": False, "error": "username, password are required"} existing = store.get_user_by_username(tenant_id=tenant_id, username=username) if existing: return {"ok": False, "error": "username_conflict"} row = store.create_user_account( tenant_id=tenant_id, username=username, display_name=display_name, role=role, password_hash=_sha256_hex(password), is_active=True, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="user_create", target_type="user", target_id=str(row.get("id") or ""), status="ok", detail={"username": username, "role": role}, ) return {"ok": True, "user": row} @router.post("/admin/api/users/update") def api_users_update( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") tenant_id = str(payload.get("tenant_id") or "").strip() user_id = str(payload.get("user_id") or "").strip() _require_tenant_scope(ctx, tenant_id) if not tenant_id or not user_id: return {"ok": False, "error": "tenant_id and user_id are required"} target = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) if not target: return {"ok": False, "error": "user_not_found"} if str(target.get("username") or "").strip().lower() == "administrator" and payload.get("is_active") is False: return {"ok": False, "error": "administrator_cannot_be_disabled"} pwd = payload.get("password") ok = store.update_user_account( tenant_id=tenant_id, user_id=user_id, display_name=payload.get("display_name"), role=payload.get("role"), is_active=payload.get("is_active") if isinstance(payload.get("is_active"), bool) else None, password_hash=_sha256_hex(str(pwd)) if isinstance(pwd, str) and pwd.strip() else None, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="user_update", target_type="user", target_id=user_id, status="ok" if ok else "miss", detail={"fields": list(payload.keys())}, ) return {"ok": True, "updated": bool(ok)} @router.post("/admin/api/users/delete") def api_users_delete( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:delete") tenant_id = str(payload.get("tenant_id") or "").strip() user_id = str(payload.get("user_id") or "").strip() _require_tenant_scope(ctx, tenant_id) if not tenant_id or not user_id: return {"ok": False, "error": "tenant_id and user_id are required"} target = store.get_user_by_id(tenant_id=tenant_id, user_id=user_id) if not target: return {"ok": False, "error": "user_not_found"} if str(target.get("username") or "").strip().lower() == "administrator": return {"ok": False, "error": "administrator_cannot_be_deleted"} if user_id == str(ctx.get("user_id") or ""): return {"ok": False, "error": "cannot_delete_self"} deleted = store.delete_user_account(tenant_id=tenant_id, user_id=user_id) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="user_delete", target_type="user", target_id=user_id, status="ok" if deleted > 0 else "miss", ) return {"ok": True, "deleted": deleted} def _workspace_path_policy_read_mode(ctx: dict[str, Any]) -> str | None: """``full`` = any user in tenant; ``self`` = only own user_id; ``None`` = forbidden.""" if str(ctx.get("role") or "") == "owner": return "full" perms = set(str(x) for x in (ctx.get("permissions") or [])) if "admin:user:read" in perms: return "full" if "admin:workspace_paths:read" in perms: return "self" return None def _workspace_path_policy_write_mode(ctx: dict[str, Any]) -> str | None: if str(ctx.get("role") or "") == "owner": return "full" perms = set(str(x) for x in (ctx.get("permissions") or [])) if "admin:user:write" in perms: return "full" if "admin:workspace_paths:write" in perms: return "self" return None def _normalize_workspace_extra_roots(raw: str) -> tuple[str, str | None]: """Split ``|``, resolve each segment; must be absolute. Returns (joined, error_code).""" parts = [x.strip().strip('"').strip("'") for x in str(raw or "").split("|") if str(x).strip()] if not parts: return "", None out: list[str] = [] for p in parts: try: rp = Path(p).expanduser().resolve() if not rp.is_absolute(): return "", "extra_roots_must_be_absolute" out.append(str(rp)) except Exception: return "", "extra_roots_invalid_path" return "|".join(out), None @router.get("/admin/api/users/workspace-path-policy") def api_users_workspace_path_policy_get( tenant_id: str = Query(default=""), user_id: str = Query(default=""), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) rmode = _workspace_path_policy_read_mode(ctx) if rmode is None: raise HTTPException(status_code=403, detail="forbidden") tid = str(tenant_id or "").strip() uid = str(user_id or "").strip() _require_tenant_scope(ctx, tid) if not tid or not uid: return {"ok": False, "error": "tenant_id and user_id are required"} if rmode == "self" and (tid != str(ctx.get("tenant_id") or "") or uid != str(ctx.get("user_id") or "")): raise HTTPException(status_code=403, detail="workspace_paths_self_only") row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid) if not row: return { "ok": True, "from_db": False, "policy": {"extra_roots": "", "allow_any_path": False}, "public_tools_allow_high": False, } return { "ok": True, "from_db": True, "policy": row, "public_tools_allow_high": bool(row.get("allow_high_risk_public_tools")), } @router.post("/admin/api/users/workspace-path-policy") def api_users_workspace_path_policy_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) wmode = _workspace_path_policy_write_mode(ctx) if wmode is None: raise HTTPException(status_code=403, detail="forbidden") tid = str(payload.get("tenant_id") or "").strip() uid = str(payload.get("user_id") or "").strip() _require_tenant_scope(ctx, tid) if not tid or not uid: return {"ok": False, "error": "tenant_id and user_id are required"} if wmode == "self" and (tid != str(ctx.get("tenant_id") or "") or uid != str(ctx.get("user_id") or "")): raise HTTPException(status_code=403, detail="workspace_paths_self_only") if not store.get_user_by_id(tenant_id=tid, user_id=uid): return {"ok": False, "error": "user_not_found"} extra_raw = str(payload.get("extra_roots") or "") norm, err = _normalize_workspace_extra_roots(extra_raw) if err: return {"ok": False, "error": err} allow_any = bool(payload.get("allow_any_path", False)) allow_high = bool(payload.get("public_tools_allow_high", False)) store.upsert_user_workspace_path_allowlist( tenant_id=tid, user_id=uid, extra_roots=norm, allow_any_path=allow_any, allow_high_risk_public_tools=allow_high, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="user_workspace_path_policy_save", target_type="user", target_id=uid, status="ok", detail={ "tenant_id": tid, "allow_any_path": allow_any, "public_tools_allow_high": bool(allow_high), "extra_roots_preview": norm[:500], }, ) row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid) return {"ok": True, "policy": row or {}, "public_tools_allow_high": bool((row or {}).get("allow_high_risk_public_tools"))} @router.post("/admin/api/users/delete-unbound") def api_users_delete_unbound( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:delete") tenant_id = str(payload.get("tenant_id") or "").strip() _require_tenant_scope(ctx, tenant_id) channel = str(payload.get("channel") or "wecom").strip() or "wecom" if not tenant_id: return {"ok": False, "error": "tenant_id is required"} users = store.list_users(tenant_id=tenant_id, limit=5000) if str(channel).strip().lower() == "wecom": binds = store.list_channel_identities_v2(tenant_id=tenant_id, channel=channel, limit=5000) else: binds = store.list_channel_identities(tenant_id=tenant_id, channel=channel, limit=5000) bound_user_ids = {str(b.get("user_id") or "") for b in binds} orphan_ids = [ str(u.get("id") or "") for u in users if str(u.get("id") or "") not in bound_user_ids and str(u.get("username") or "").strip().lower() != "administrator" ] deleted = 0 if orphan_ids: with store._connect() as conn: for uid in orphan_ids: cur = conn.execute( "DELETE FROM app_user WHERE tenant_id = ? AND id = ?", (tenant_id, uid), ) deleted += int(cur.rowcount or 0) return { "ok": True, "deleted": deleted, "users_total": len(users), "bound_users": len([x for x in bound_user_ids if x]), "orphan_users": len(orphan_ids), } @router.get("/admin/api/bind-codes") def api_bind_codes(tenant_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:read") _require_tenant_scope(ctx, tenant_id) rows = store.list_bind_codes(tenant_id=tenant_id, limit=200) return {"ok": True, "codes": rows} @router.post("/admin/api/bind-codes/create") def api_bind_codes_create( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") tenant_id = str(payload.get("tenant_id") or "").strip() _require_tenant_scope(ctx, tenant_id) role = str(payload.get("role") or "member").strip() or "member" if not tenant_id: return {"ok": False, "error": "tenant_id is required"} code = str(payload.get("code") or "").strip() or secrets.token_urlsafe(6) row = store.create_bind_code(tenant_id=tenant_id, role=role, code=code) return {"ok": True, "code": row} @router.post("/admin/api/bind-codes/delete") def api_bind_codes_delete( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") tenant_id = str(payload.get("tenant_id") or "").strip() _require_tenant_scope(ctx, tenant_id) code = str(payload.get("code") or "").strip() if not tenant_id: return {"ok": False, "error": "tenant_id is required"} if not code: return {"ok": False, "error": "code is required"} with store._connect() as conn: cur = conn.execute( "DELETE FROM bind_code WHERE tenant_id = ? AND code = ?", (tenant_id, code), ) deleted = int(cur.rowcount or 0) return {"ok": True, "deleted": deleted} @router.get("/admin/api/wecom/config") def api_wecom_config(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") return { "ok": True, "config": { "wecom_mode": "bot_api", "wecom_bot_id": str(store.get_setting("wecom_bot_id") or ""), "wecom_bot_secret": str(store.get_secret("wecom_bot_secret") or ""), "wecom_auto_bind_enabled": str(store.get_setting("wecom_auto_bind_enabled") or "1"), "wecom_auto_bind_tenant_name": str(store.get_setting("wecom_auto_bind_tenant_name") or "Team"), "wecom_auto_bind_role": str(store.get_setting("wecom_auto_bind_role") or "member"), }, } @router.get("/admin/api/wecom/health") def api_wecom_health(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") svc = next((s for s in status_services() if str(s.name) == "channel:wecom"), None) def _gs(k: str) -> str: return str(store.get_setting(k) or "").strip() recent_raw = _gs("wecom_recent_from_users") try: recent = json.loads(recent_raw) if recent_raw else [] if not isinstance(recent, list): recent = [] except Exception: recent = [] return { "ok": True, "service": { "name": "channel:wecom", "running": bool(getattr(svc, "running", False)) if svc else False, "pid": int(getattr(svc, "pid", 0) or 0) if svc else 0, }, "inbound": { "last_msg_ts": _gs("wecom_last_msg_ts"), "last_from_user": _gs("wecom_last_from_user"), "recent_from_users": recent[:20], "last_cmd": _gs("wecom_last_cmd"), "last_parse_error": _gs("wecom_last_parse_error"), }, "outbound": { "last_mode": _gs("wecom_last_outbound_mode"), "last_error": _gs("wecom_last_outbound_error"), "last_ack_req_id": _gs("wecom_last_ack_req_id"), "last_ack_errcode": _gs("wecom_last_ack_errcode"), "last_ack_errmsg": _gs("wecom_last_ack_errmsg"), }, "raw": { "last_raw_from_user": _gs("wecom_last_raw_from_user"), "last_raw_body": _gs("wecom_last_raw_body"), "last_unknown_cmd_payload": _gs("wecom_last_unknown_cmd_payload"), }, } @router.post("/admin/api/wecom/config") def api_wecom_config_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") store.set_setting("wecom_mode", "bot_api") store.set_setting("wecom_bot_id", str(payload.get("wecom_bot_id") or "").strip()) bot_secret = str(payload.get("wecom_bot_secret") or "").strip() if bot_secret: store.set_secret("wecom_bot_secret", bot_secret) auto_enabled = str(payload.get("wecom_auto_bind_enabled") or "").strip().lower() enabled_truthy = auto_enabled in ("1", "true", "yes", "on") store.set_setting("wecom_auto_bind_enabled", "1" if enabled_truthy else "0") store.set_setting( "wecom_auto_bind_tenant_name", str(payload.get("wecom_auto_bind_tenant_name") or "").strip() or "Team", ) store.set_setting( "wecom_auto_bind_role", str(payload.get("wecom_auto_bind_role") or "").strip() or "member", ) return api_wecom_config() @router.post("/admin/api/wecom/unbind") def api_wecom_unbind(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") with store._connect() as conn: cur_ident = conn.execute("DELETE FROM channel_identity WHERE channel = ?", ("wecom",)) ident_deleted = int(cur_ident.rowcount or 0) cur_sess = conn.execute("DELETE FROM channel_session WHERE channel = ?", ("wecom",)) sess_deleted = int(cur_sess.rowcount or 0) for k in _WECOM_CLEAR_KEYS: store.delete_setting(k) with store._connect() as conn: conn.execute( "DELETE FROM app_setting WHERE key LIKE 'wecom:bot_secret:%' OR key LIKE 'wecom:agent_secret:%'" ) cfg = api_wecom_config() return { "ok": True, "deleted_channel_identity": ident_deleted, "deleted_channel_session": sess_deleted, "config": cfg.get("config", {}), } @router.get("/admin/api/audit") def api_audit( session_id: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") rows = store.list_agent_audit_logs(limit=200, session_id=session_id) return {"ok": True, "audit": rows} @router.get("/admin/api/audit/session-health") def api_audit_session_health( session_id: str | None = Query(default=None), limit: int = Query(default=80), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") rows = store.list_session_tool_health(session_id=session_id, limit=limit) warn = [x for x in rows if str(x.get("status") or "") == "warn_no_tool_calls"] return {"ok": True, "items": rows, "warn_count": len(warn)} @router.get("/admin/api/trace") def api_trace( session_id: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") if not session_id: return {"ok": True, "trace": []} rows = store.list_trace_events(session_id=session_id, limit=300) return {"ok": True, "trace": rows} @router.get("/admin/api/oclaw/tasks") @router.get("/admin/api/oclaw/tasks") def api_oclaw_tasks( task_id: str | None = Query(default=None), session_id: str | None = Query(default=None), status: str | None = Query(default=None), limit: int = Query(default=80), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tenant_id = str(ctx.get("tenant_id") or "") sid = str(session_id or "").strip() or None st = str(status or "").strip() or None if task_id: task = store.oclaw_task_get(task_id=str(task_id), tenant_id=tenant_id) return {"ok": True, "task": None if not task else task.__dict__} rows = store.oclaw_task_list( status=st, session_id=sid, tenant_id=tenant_id, limit=max(1, min(int(limit or 80), 300)), ) by_status: dict[str, int] = {"queued": 0, "claimed": 0, "done": 0, "failed": 0} for r in rows: s = str(r.status or "") by_status[s] = int(by_status.get(s, 0)) + 1 return {"ok": True, "tasks": [r.__dict__ for r in rows], "counts": by_status} @router.get("/admin/api/oclaw/runs") @router.get("/admin/api/oclaw/runs") def api_oclaw_runs( run_id: str | None = Query(default=None), session_id: str | None = Query(default=None), status: str | None = Query(default=None), limit: int = Query(default=80), include_attempts: int = Query(default=1), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tenant_id = str(ctx.get("tenant_id") or "") if run_id: row = store.oclaw_run_get(run_id=str(run_id), tenant_id=tenant_id) if not row: return {"ok": True, "run": None} out = dict(row.__dict__) if int(include_attempts or 0): out["attempts"] = store.oclaw_attempt_list(run_id=row.run_id, limit=50) return {"ok": True, "run": out} rows = store.oclaw_run_list( tenant_id=tenant_id, session_id=str(session_id or "").strip() or None, status=str(status or "").strip() or None, limit=max(1, min(int(limit or 80), 300)), ) by_status: dict[str, int] = {"running": 0, "success": 0, "failed": 0} out_rows = [] for r in rows: s = str(r.status or "") by_status[s] = int(by_status.get(s, 0)) + 1 item = dict(r.__dict__) if int(include_attempts or 0): item["attempts"] = store.oclaw_attempt_list(run_id=r.run_id, limit=20) out_rows.append(item) configured_retryable = sorted(resolve_retryable_error_codes(store=store)) return { "ok": True, "runs": out_rows, "counts": by_status, "retry_policy": { "setting_key": "AIA_OCLAW_RETRYABLE_ERROR_CODES", "effective_retryable_error_codes": configured_retryable, "default_retryable_error_codes": list(DEFAULT_RETRYABLE_ERROR_CODES), }, } @router.get("/admin/api/scheduled-jobs/stats") def api_scheduled_jobs_stats( recent_limit: int = Query(default=80), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tenant_id = str(ctx.get("tenant_id") or "") summary = store.scheduled_job_failure_summary( tenant_id=tenant_id, recent_limit=max(10, min(int(recent_limit or 80), 300)), ) return {"ok": True, **summary} @router.get("/admin/api/scheduled-jobs/meta/recipe-templates") def api_scheduled_jobs_meta_recipe_templates( authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.scheduler.recipe import list_ops_recipe_templates store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") return {"ok": True, "items": list_ops_recipe_templates()} @router.get("/admin/api/scheduled-jobs/meta/targets") def api_scheduled_jobs_meta_targets( tenant_id: str = Query(default=""), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.scheduler.session_resolver import resolve_weixin_binding store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tid = str(tenant_id or ctx.get("tenant_id") or "default").strip() _require_tenant_scope(ctx, tid) aid = str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip() known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid) weixin = resolve_weixin_binding(store, tenant_id=tid) return {"ok": True, "whatsapp_groups": known, "weixin_binding": weixin, "whatsapp_account_id": aid} @router.get("/admin/api/scheduled-jobs") def api_scheduled_jobs_list( status: str | None = Query(default=None), limit: int = Query(default=100), offset: int = Query(default=0), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tenant_id = str(ctx.get("tenant_id") or "") rows = store.scheduled_job_list( tenant_id=tenant_id, status=str(status or "").strip() or None, limit=max(1, min(int(limit or 100), 300)), offset=max(0, int(offset or 0)), ) return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]} @router.get("/admin/api/scheduled-jobs/{job_id}") def api_scheduled_jobs_get( job_id: str, authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tenant_id = str(ctx.get("tenant_id") or "") row = store.scheduled_job_get(job_id=str(job_id), tenant_id=tenant_id) return {"ok": True, "job": None if not row else store.scheduled_job_to_dict(row)} @router.post("/admin/api/scheduled-jobs") def api_scheduled_jobs_create( payload: dict[str, Any], authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.scheduler.cron_service import build_default_delivery from runtime.scheduler.expressions import normalize_schedule_kind from runtime.scheduler.system_timezone import default_system_timezone store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tenant_id = str(ctx.get("tenant_id") or "") name = str(payload.get("name") or "").strip() prompt_text = str(payload.get("prompt_text") or "").strip() schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron") schedule_expr = str(payload.get("schedule_expr") or "").strip() if not name or not schedule_expr: return {"ok": False, "error": "name, schedule_expr are required"} delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None if delivery is None: wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "") delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat) from runtime.scheduler.recipe import ( list_ops_recipe_templates, normalize_recipe, prompt_summary_from_recipe, recipe_has_playbook, resolve_ops_recipe_template, synthesize_recipe_from_prompt, ) recipe = payload.get("recipe") if isinstance(payload.get("recipe"), dict) else None if isinstance(recipe, dict): recipe = normalize_recipe(recipe) template_id = str(payload.get("recipe_template_id") or payload.get("template_id") or "").strip() if template_id and not recipe_has_playbook(recipe): tmpl = resolve_ops_recipe_template(template_id) if tmpl is None: return { "ok": False, "error": "unknown_recipe_template", "template_id": template_id, "available": list_ops_recipe_templates(), } recipe = tmpl if recipe_has_playbook(recipe) and not prompt_text: prompt_text = prompt_summary_from_recipe(recipe, fallback=name) if not prompt_text: return {"ok": False, "error": "name, prompt_text, schedule_expr are required"} if not recipe_has_playbook(recipe): synth = synthesize_recipe_from_prompt( prompt_text, session_id=str(payload.get("source_session_id") or ""), ) if recipe_has_playbook(synth): recipe = synth if not str((recipe.get("source") or {}).get("compiled_at") or "").strip(): from datetime import datetime, timezone recipe.setdefault("source", {})["compiled_at"] = datetime.now(timezone.utc).isoformat() # WhatsApp field ops default to English when delivery targets WA and lang omitted. lang = str(payload.get("lang") or "").strip() if not lang: ch = str((delivery or {}).get("channel") or "").strip().lower() lang = "en" if ch in {"whatsapp", "wa"} else "zh" row = store.scheduled_job_create( tenant_id=tenant_id, name=name, prompt_text=prompt_text, schedule_kind=schedule_kind, schedule_expr=schedule_expr, timezone_name=str(payload.get("timezone") or default_system_timezone()), description=str(payload.get("description") or ""), interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"), specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"), lang=lang, delivery=delivery, recipe=recipe, source_session_id=str(payload.get("source_session_id") or "").strip() or None, created_by_user_id=str(ctx.get("user_id") or ""), source="admin", ) store.add_admin_audit_log( actor_tenant_id=tenant_id, actor_user_id=str(ctx.get("user_id") or ""), action="scheduled_job_create", target_type="scheduled_job", target_id=row.id, status="ok", ) return {"ok": True, "job": store.scheduled_job_to_dict(row)} @router.patch("/admin/api/scheduled-jobs/{job_id}") def api_scheduled_jobs_patch( job_id: str, payload: dict[str, Any], authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tenant_id = str(ctx.get("tenant_id") or "") row = store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=dict(payload or {})) if not row: return {"ok": False, "error": "job_not_found"} store.add_admin_audit_log( actor_tenant_id=tenant_id, actor_user_id=str(ctx.get("user_id") or ""), action="scheduled_job_update", target_type="scheduled_job", target_id=str(job_id), status="ok", ) return {"ok": True, "job": store.scheduled_job_to_dict(row)} @router.post("/admin/api/scheduled-jobs/{job_id}/pause") def api_scheduled_jobs_pause( job_id: str, authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tenant_id = str(ctx.get("tenant_id") or "") ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="paused") return {"ok": bool(ok)} @router.post("/admin/api/scheduled-jobs/{job_id}/resume") def api_scheduled_jobs_resume( job_id: str, authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tenant_id = str(ctx.get("tenant_id") or "") ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="active") return {"ok": bool(ok)} @router.delete("/admin/api/scheduled-jobs/{job_id}") def api_scheduled_jobs_delete( job_id: str, authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tenant_id = str(ctx.get("tenant_id") or "") ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(job_id)) return {"ok": bool(ok)} @router.post("/admin/api/scheduled-jobs/{job_id}/run-now") def api_scheduled_jobs_run_now( job_id: str, authorization: str | None = Header(default=None), ) -> dict[str, Any]: from runtime.scheduler.service import run_scheduled_job_now store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:runtime:write") tenant_id = str(ctx.get("tenant_id") or "") out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=str(job_id)) return out @router.get("/admin/api/scheduled-jobs/{job_id}/runs") def api_scheduled_jobs_runs( job_id: str, limit: int = Query(default=50), offset: int = Query(default=0), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") tenant_id = str(ctx.get("tenant_id") or "") rows = store.scheduled_job_run_list( job_id=str(job_id), tenant_id=tenant_id, limit=max(1, min(int(limit or 50), 200)), offset=max(0, int(offset or 0)), ) return {"ok": True, "items": [store.scheduled_job_run_to_dict(r) for r in rows]} @router.get("/admin/api/replay/turn") def api_replay_turn( session_id: str, trace_id: str, authorization: str | None = Header(default=None), ) -> dict[str, Any]: """Return a best-effort replay bundle for a single turn (trace_id).""" store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") sid = str(session_id or "").strip() tid = str(trace_id or "").strip() if not sid or not tid: return {"ok": False, "error": "session_id_and_trace_id_required"} # Restrict non-administrator users to their own sessions only. uname = str(ctx.get("username") or "").strip().lower() if uname != "administrator": # Must be owned by current user in current tenant. owner = store.get_session_for_user( session_id=sid, tenant_id=str(ctx.get("tenant_id") or ""), user_id=str(ctx.get("user_id") or "") ) if not owner: raise HTTPException(status_code=403, detail="forbidden") trace_rows = store.list_trace_events_for_trace(session_id=sid, trace_id=tid, limit=800) start_ts, end_ts = store.get_turn_time_window(session_id=sid, trace_id=tid) msgs = store.list_messages_in_time_window(session_id=sid, start_ts=start_ts, end_ts=end_ts, limit=1200) def _to_ms(v: Any) -> int | None: s = str(v or "").strip() if not s: return None try: if s.endswith("Z"): s = s[:-1] + "+00:00" return int(datetime.fromisoformat(s).timestamp() * 1000) except Exception: return None ws_accepted_ms: int | None = None gateway_received_ms: int | None = None model_chat_start_ms: int | None = None ws_first_token_ms: int | None = None for row in trace_rows or []: if not isinstance(row, dict): continue event_type = str(row.get("event_type") or "").strip() payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} row_ts = _to_ms(row.get("timestamp")) if event_type == "gateway_received": gateway_received_ms = row_ts if row_ts is not None else gateway_received_ms if ws_accepted_ms is None: try: v = payload.get("ws_client_send_ms") if v is not None: ws_accepted_ms = int(v) except Exception: pass elif event_type == "model_chat_start": model_chat_start_ms = row_ts if row_ts is not None else model_chat_start_ms elif event_type == "ws_first_token": if ws_first_token_ms is None: try: vft = payload.get("ws_first_token_ms") if vft is not None: ws_first_token_ms = int(vft) except Exception: pass if ws_accepted_ms is None: try: va = payload.get("ws_accepted_ms") if va is not None: ws_accepted_ms = int(va) except Exception: pass if ws_first_token_ms is None: ws_first_token_ms = row_ts if row_ts is not None else ws_first_token_ms def _delta(a: int | None, b: int | None) -> int | None: if a is None or b is None: return None d = int(b - a) return d if d >= 0 else None ttft = { "ws_accepted_ms": ws_accepted_ms, "gateway_received_ms": gateway_received_ms, "model_chat_start_ms": model_chat_start_ms, "ws_first_token_ms": ws_first_token_ms, "accepted_to_gateway_ms": _delta(ws_accepted_ms, gateway_received_ms), "gateway_to_model_start_ms": _delta(gateway_received_ms, model_chat_start_ms), "model_start_to_first_token_ms": _delta(model_chat_start_ms, ws_first_token_ms), "gateway_to_first_token_ms": _delta(gateway_received_ms, ws_first_token_ms), "accepted_to_first_token_ms": _delta(ws_accepted_ms, ws_first_token_ms), } return { "ok": True, "session_id": sid, "trace_id": tid, "start_ts": start_ts, "end_ts": end_ts, "ttft": ttft, "trace": trace_rows, "messages": msgs, } @router.get("/admin/api/plugins") def api_plugins(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") rows = store.list_tool_plugins() return {"ok": True, "plugins": rows} @router.get("/admin/api/tool-policy") def api_tool_policy(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") # Oclaw takeover: legacy tool-policy switches removed (fixed display values for API compat). disabled = False retry_mode = "first_round_only" loop_state_machine = True signature_budget = 2 tmw_raw = str(store.get_setting("AIA_TURN_MAX_TOOL_WORKERS") or "").strip() tmr_raw = str(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or "").strip() tmc_raw = str(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or "").strip() turn_max_tool_workers = max(1, min(int(tmw_raw), 32)) if tmw_raw.isdigit() else 8 turn_max_tool_rounds = max(1, min(int(tmr_raw), 300)) if tmr_raw.isdigit() else 100 turn_max_context_messages = max(10, min(int(tmc_raw), 400)) if tmc_raw.isdigit() else 80 turn_runner_impl = "oclaw" manager_decision_mode = "" sse_raw = str(store.get_setting("AIA_SSE_QUEUE_MAXSIZE") or "").strip() sse_queue_maxsize = max(200, min(int(sse_raw), 50_000)) if sse_raw.isdigit() else 2000 tl_raw = str(store.get_setting("AIA_TOOL_LOG_MAX_CHARS") or "").strip() tool_log_max_chars = max(20_000, min(int(tl_raw), 2_000_000)) if tl_raw.isdigit() else 64_000 emcp_raw = str(store.get_setting("AIA_ENABLE_MCP_TOOLS") or "").strip().lower() enable_mcp_tools = emcp_raw not in ("0", "false", "no", "off") # Align with catalog._plugin_tools_enabled: unset → ON (env may override). epl_sv = store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") if epl_sv is None or str(epl_sv).strip() == "": import os as _os epl_env = str( _os.getenv("AIA_ENABLE_PLUGIN_TOOLS") or _os.getenv("AIA_PLUGIN_TOOLS_ENABLED") or "1" ).strip().lower() enable_plugin_tools = epl_env not in ("0", "false", "no", "off") else: epl_raw = str(epl_sv).strip().lower() enable_plugin_tools = epl_raw in ("1", "true", "yes", "on") # Absent DB row must read as OFF: matches LocalAdapter.run_command (no row → env only; no env → disabled). # Previously `str(None or "")` made the UI show ON while execution stayed disabled (confusing on new machines). erc_sv = store.get_setting("AIA_ENABLE_RUN_COMMAND") if erc_sv is None: enable_run_command = False else: erc_raw = str(erc_sv).strip().lower() enable_run_command = erc_raw not in ("0", "false", "no", "off") tctx_raw = str(store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower() tool_context_truncate_enabled = tctx_raw not in ("0", "false", "no", "off") ttft_raw = str(store.get_setting("AIA_CHAT_SHOW_TTFT_DEBUG") or "").strip().lower() # default OFF (release-safe): only enabled when explicitly set truthy. chat_show_ttft_debug = ttft_raw in ("1", "true", "yes", "on") skl_raw = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower() skill_runtime_enabled = skl_raw not in ("0", "false", "no", "off") sai_raw = str(store.get_setting("AIA_SKILL_AUTO_INSTALL_ENABLED") or "").strip().lower() skill_auto_install_enabled = sai_raw not in ("0", "false", "no", "off") tmsg_raw = str(store.get_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS") or "").strip() if tmsg_raw.isdigit(): _n = int(tmsg_raw) tool_llm_message_max_chars = 0 if _n == 0 else max(4096, min(_n, 500_000)) else: tool_llm_message_max_chars = 0 mcp_fs_raw = str(store.get_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS") or "").strip() mcp_env_allow_raw = str(store.get_setting("AIA_MCP_ENV_ALLOWLIST") or "").strip() retry_codes_raw = str(store.get_setting("AIA_OCLAW_RETRYABLE_ERROR_CODES") or "").strip() if not retry_codes_raw: retry_codes_raw = ",".join(DEFAULT_RETRYABLE_ERROR_CODES) retry_strict_raw = str(store.get_setting("AIA_OCLAW_RETRY_CODES_STRICT_MODE") or "").strip().lower() retry_codes_strict_mode = retry_strict_raw in ("1", "true", "yes", "on") wc_workers_raw = str(store.get_setting("AIA_WECOM_LONGCONN_WORKERS") or "").strip() if not wc_workers_raw: wc_workers_raw = str(store.get_setting("WECOM_LONGCONN_WORKERS") or "").strip() wc_in_q_raw = str(store.get_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() if not wc_in_q_raw: wc_in_q_raw = str(store.get_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE") or "").strip() wecom_longconn_workers = max(1, min(int(wc_workers_raw), 8)) if wc_workers_raw.isdigit() else 2 wecom_longconn_inbound_queue_maxsize = max(20, min(int(wc_in_q_raw), 5000)) if wc_in_q_raw.isdigit() else 200 return { "ok": True, "disable_tool_confirm": disabled, "enforced_retry_mode": retry_mode, "tool_loop_state_machine": loop_state_machine, "tool_signature_budget": signature_budget, "turn_max_tool_workers": turn_max_tool_workers, "turn_max_tool_rounds": turn_max_tool_rounds, "turn_max_context_messages": turn_max_context_messages, "turn_runner_impl": turn_runner_impl, "manager_decision_mode": manager_decision_mode, "sse_queue_maxsize": sse_queue_maxsize, "tool_log_max_chars": tool_log_max_chars, "enable_mcp_tools": enable_mcp_tools, "enable_plugin_tools": enable_plugin_tools, "enable_run_command": enable_run_command, "tool_context_truncate_enabled": tool_context_truncate_enabled, "chat_show_ttft_debug": chat_show_ttft_debug, "skill_runtime_enabled": skill_runtime_enabled, "skill_auto_install_enabled": skill_auto_install_enabled, "tool_llm_message_max_chars": tool_llm_message_max_chars, "mcp_filesystem_extra_roots": mcp_fs_raw, "mcp_env_allowlist": mcp_env_allow_raw, "oclaw_retryable_error_codes": retry_codes_raw, "oclaw_retry_codes_strict_mode": retry_codes_strict_mode, "wecom_longconn_workers": wecom_longconn_workers, "wecom_longconn_inbound_queue_maxsize": wecom_longconn_inbound_queue_maxsize, } @router.post("/admin/api/tool-policy") def api_tool_policy_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") # Legacy tool-policy switches removed from product surface (fixed values for API compat). disable_confirm = False retry_mode = "first_round_only" sm = True sig_budget_n = 2 tmw = payload.get("turn_max_tool_workers", 8) tmr = payload.get("turn_max_tool_rounds", 100) tmc = payload.get("turn_max_context_messages", 80) tri = "oclaw" try: tmw_n = max(1, min(int(tmw), 32)) except Exception: tmw_n = 8 try: tmr_n = max(1, min(int(tmr), 300)) except Exception: tmr_n = 100 try: tmc_n = max(10, min(int(tmc), 400)) except Exception: tmc_n = 80 md = "" sse_q = payload.get("sse_queue_maxsize", 2000) tl_cap = payload.get("tool_log_max_chars", 64000) try: sse_q_n = max(200, min(int(sse_q), 50_000)) except Exception: sse_q_n = 2000 try: tl_cap_n = max(20_000, min(int(tl_cap), 2_000_000)) except Exception: tl_cap_n = 64_000 emcp = bool(payload.get("enable_mcp_tools", True)) epl = bool(payload.get("enable_plugin_tools", True)) erc = bool(payload.get("enable_run_command", True)) tctx = bool(payload.get("tool_context_truncate_enabled", True)) ttft_debug = bool(payload.get("chat_show_ttft_debug", False)) skl_rt = bool(payload.get("skill_runtime_enabled", True)) skl_ai = bool(payload.get("skill_auto_install_enabled", True)) tmsg = payload.get("tool_llm_message_max_chars", 0) mcp_fs_roots = str(payload.get("mcp_filesystem_extra_roots") or "").strip() mcp_env_allow = str(payload.get("mcp_env_allowlist") or "").strip() retry_codes = str(payload.get("oclaw_retryable_error_codes") or "").strip() retry_codes_strict_mode = bool(payload.get("oclaw_retry_codes_strict_mode", False)) unknown_retry_codes: list[str] = [] if retry_codes: vals = [x.strip().lower() for x in retry_codes.split(",") if x and x.strip()] allowed = set(ALL_ATTEMPT_ERROR_CODES) known_vals: list[str] = [] for x in vals: if x in allowed: known_vals.append(x) else: unknown_retry_codes.append(x) # dedupe while preserving order seen: set[str] = set() kept = [] for x in known_vals: if x in seen: continue seen.add(x) kept.append(x) retry_codes = ",".join(kept) else: retry_codes = ",".join(DEFAULT_RETRYABLE_ERROR_CODES) if retry_codes_strict_mode and unknown_retry_codes: raise HTTPException( status_code=400, detail={ "code": "invalid_retryable_error_codes", "message": "Unknown oclaw retryable error codes", "unknown_retryable_error_codes": unknown_retry_codes, "allowed_retryable_error_codes": list(ALL_ATTEMPT_ERROR_CODES), }, ) if not retry_codes: retry_codes = ",".join(DEFAULT_RETRYABLE_ERROR_CODES) wc_workers = payload.get("wecom_longconn_workers", 2) wc_in_q = payload.get("wecom_longconn_inbound_queue_maxsize", 200) try: _tn = int(tmsg) tmsg_n = 0 if _tn == 0 else max(4096, min(_tn, 500_000)) except Exception: tmsg_n = 0 try: wc_workers_n = max(1, min(int(wc_workers), 8)) except Exception: wc_workers_n = 2 try: wc_in_q_n = max(20, min(int(wc_in_q), 5000)) except Exception: wc_in_q_n = 200 # Canonical professional prefix. store.set_setting("AIA_TURN_MAX_TOOL_WORKERS", str(tmw_n)) store.set_setting("AIA_TURN_MAX_TOOL_ROUNDS", str(tmr_n)) store.set_setting("AIA_TURN_MAX_CONTEXT_MESSAGES", str(tmc_n)) store.set_setting("AIA_SSE_QUEUE_MAXSIZE", str(sse_q_n)) store.set_setting("AIA_TOOL_LOG_MAX_CHARS", str(tl_cap_n)) store.set_setting("AIA_ENABLE_MCP_TOOLS", "1" if emcp else "0") store.set_setting("AIA_ENABLE_PLUGIN_TOOLS", "1" if epl else "0") os.environ["AIA_ENABLE_PLUGIN_TOOLS"] = "1" if epl else "0" store.set_setting("AIA_ENABLE_RUN_COMMAND", "1" if erc else "0") # Keep runtime gate aligned with Admin toggle immediately. os.environ["AIA_ENABLE_RUN_COMMAND"] = "1" if erc else "0" store.set_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED", "1" if tctx else "0") store.set_setting("AIA_CHAT_SHOW_TTFT_DEBUG", "1" if ttft_debug else "0") store.set_setting("AIA_SKILL_RUNTIME_ENABLED", "1" if skl_rt else "0") store.set_setting("AIA_SKILL_AUTO_INSTALL_ENABLED", "1" if skl_ai else "0") store.set_setting("AIA_TOOL_LLM_MESSAGE_MAX_CHARS", str(tmsg_n)) store.set_setting("AIA_MCP_FILESYSTEM_EXTRA_ROOTS", mcp_fs_roots) store.set_setting("AIA_MCP_ENV_ALLOWLIST", mcp_env_allow) store.set_setting("AIA_OCLAW_RETRYABLE_ERROR_CODES", retry_codes) store.set_setting("AIA_OCLAW_RETRY_CODES_STRICT_MODE", "1" if retry_codes_strict_mode else "0") store.set_setting("AIA_WECOM_LONGCONN_WORKERS", str(wc_workers_n)) store.set_setting("AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", str(wc_in_q_n)) store.set_setting("WECOM_LONGCONN_WORKERS", str(wc_workers_n)) store.set_setting("WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", str(wc_in_q_n)) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="tool_policy_update", target_type="tool_policy", target_id="AIA_TURN_MAX_TOOL_WORKERS,AIA_TURN_MAX_TOOL_ROUNDS,AIA_TURN_MAX_CONTEXT_MESSAGES,AIA_SSE_QUEUE_MAXSIZE,AIA_TOOL_LOG_MAX_CHARS,AIA_ENABLE_MCP_TOOLS,AIA_ENABLE_PLUGIN_TOOLS,AIA_ENABLE_RUN_COMMAND,AIA_TOOL_CONTEXT_TRUNCATE_ENABLED,AIA_CHAT_SHOW_TTFT_DEBUG,AIA_SKILL_RUNTIME_ENABLED,AIA_SKILL_AUTO_INSTALL_ENABLED,AIA_TOOL_LLM_MESSAGE_MAX_CHARS,AIA_MCP_FILESYSTEM_EXTRA_ROOTS,AIA_MCP_ENV_ALLOWLIST,AIA_OCLAW_RETRYABLE_ERROR_CODES,AIA_OCLAW_RETRY_CODES_STRICT_MODE,AIA_WECOM_LONGCONN_WORKERS,AIA_WECOM_LONGCONN_INBOUND_QUEUE_MAXSIZE", status="ok", detail={ "disable_tool_confirm": disable_confirm, "enforced_retry_mode": retry_mode, "tool_loop_state_machine": sm, "tool_signature_budget": sig_budget_n, "turn_max_tool_workers": tmw_n, "turn_max_tool_rounds": tmr_n, "turn_max_context_messages": tmc_n, "turn_runner_impl": tri, "manager_decision_mode": md, "sse_queue_maxsize": sse_q_n, "tool_log_max_chars": tl_cap_n, "enable_mcp_tools": emcp, "enable_plugin_tools": epl, "enable_run_command": erc, "tool_context_truncate_enabled": tctx, "chat_show_ttft_debug": ttft_debug, "skill_runtime_enabled": skl_rt, "skill_auto_install_enabled": skl_ai, "tool_llm_message_max_chars": tmsg_n, "mcp_filesystem_extra_roots": mcp_fs_roots, "mcp_env_allowlist": mcp_env_allow, "oclaw_retryable_error_codes": retry_codes, "oclaw_retry_codes_strict_mode": retry_codes_strict_mode, "unknown_retryable_error_codes": unknown_retry_codes, "wecom_longconn_workers": wc_workers_n, "wecom_longconn_inbound_queue_maxsize": wc_in_q_n, }, ) return { "ok": True, "disable_tool_confirm": disable_confirm, "enforced_retry_mode": retry_mode, "tool_loop_state_machine": sm, "tool_signature_budget": sig_budget_n, "turn_max_tool_workers": tmw_n, "turn_max_tool_rounds": tmr_n, "turn_max_context_messages": tmc_n, "turn_runner_impl": tri, "manager_decision_mode": md, "sse_queue_maxsize": sse_q_n, "tool_log_max_chars": tl_cap_n, "enable_mcp_tools": emcp, "enable_plugin_tools": epl, "enable_run_command": erc, "tool_context_truncate_enabled": tctx, "chat_show_ttft_debug": ttft_debug, "skill_runtime_enabled": skl_rt, "skill_auto_install_enabled": skl_ai, "tool_llm_message_max_chars": tmsg_n, "mcp_filesystem_extra_roots": mcp_fs_roots, "mcp_env_allowlist": mcp_env_allow, "oclaw_retryable_error_codes": retry_codes, "oclaw_retry_codes_strict_mode": retry_codes_strict_mode, "unknown_retryable_error_codes": unknown_retry_codes, "wecom_longconn_workers": wc_workers_n, "wecom_longconn_inbound_queue_maxsize": wc_in_q_n, } @router.get("/admin/api/mcp/servers") def api_mcp_servers(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") rows = McpRegistry(store).list_servers(enabled_only=False) health = {str(x.get("server_id") or ""): x for x in store.list_mcp_server_health()} for r in rows: sid = str(r.get("server_id") or "") r["health"] = health.get(sid) or {} r["tools"] = store.list_mcp_server_tools(server_id=sid) r["cursor"] = registry_row_to_cursor_server(r) return {"ok": True, "servers": rows} @router.get("/admin/api/mcp/export") def api_mcp_export(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") return { "ok": True, "local_path": str(mcp_migrated_json_path()), "document": build_mcp_install_export_document(store), } @router.get("/admin/api/mcp/failures") def api_mcp_failures( limit: int = Query(default=20), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") return {"ok": True, "items": store.list_mcp_install_failure_summary(limit=limit)} @router.get("/admin/api/mcp/usage") def api_mcp_usage( server_id: str | None = Query(default=None), limit: int = Query(default=200), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") return { "ok": True, "summary": store.list_mcp_tool_usage_summary(limit=limit), "calls": store.list_mcp_tool_call_logs(server_id=server_id, limit=limit), } @router.get("/admin/api/mcp/binding") def api_mcp_binding(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") available = _ordered_mcp_roles() servers = store.list_mcp_servers(enabled_only=False) normalized_servers = [ { "server_id": str(x.get("server_id") or ""), "source_type": str(x.get("source_type") or ""), "enabled": bool(x.get("enabled")), } for x in servers if str(x.get("server_id") or "").strip() ] mapping = _normalize_mcp_specialist_binding( available_specialists=available, servers=normalized_servers, mapping=_load_mcp_specialist_binding(store), ) return {"ok": True, "available_specialists": available, "servers": normalized_servers, "mapping": mapping} @router.post("/admin/api/mcp/binding") def api_mcp_binding_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") available = _ordered_mcp_roles() servers = store.list_mcp_servers(enabled_only=False) normalized_servers = [ { "server_id": str(x.get("server_id") or ""), "source_type": str(x.get("source_type") or ""), "enabled": bool(x.get("enabled")), } for x in servers if str(x.get("server_id") or "").strip() ] mapping_raw = payload.get("mapping") if isinstance(payload.get("mapping"), dict) else {} mapping = _normalize_mcp_specialist_binding( available_specialists=available, servers=normalized_servers, mapping=mapping_raw, ) store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False)) try: from runtime.direct_loop import invalidate_tool_wire_cache invalidate_tool_wire_cache(reason="mcp_binding_update") except Exception: pass store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_binding_update", target_type="mcp_config", target_id="specialist_server_binding", status="ok", detail={"mapping": mapping}, ) return {"ok": True, "available_specialists": available, "servers": normalized_servers, "mapping": mapping} @router.get("/admin/api/mcp/specialists") def api_mcp_specialists(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") available = _ordered_mcp_roles() raw = str(store.get_setting("mcp_allowed_specialists") or "").strip() or "generalist,ops" allowed = [x.strip().lower() for x in raw.split(",") if x.strip()] allowed_set = set(allowed) ordered = [x for x in available if x in allowed_set] if not ordered: ordered = ["generalist"] if "generalist" in available else (available[:1] if available else []) return {"ok": True, "available_specialists": available, "allowed_specialists": ordered} @router.post("/admin/api/mcp/specialists") def api_mcp_specialists_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") available = _ordered_mcp_roles() available_set = set(available) raw = payload.get("allowed_specialists") items = raw if isinstance(raw, list) else [] allowset = {str(x).strip().lower() for x in items if str(x).strip().lower() in available_set} if not allowset: defaults = {x for x in ("generalist", "ops") if x in available_set} allowset = defaults if defaults else (set(available[:1]) if available else set()) ordered = [x for x in available if x in allowset] store.set_setting("mcp_allowed_specialists", ",".join(ordered)) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_specialists_update", target_type="mcp_config", target_id="allowed_specialists", status="ok", detail={"allowed_specialists": ordered}, ) return {"ok": True, "available_specialists": available, "allowed_specialists": ordered} @router.get("/admin/api/experts") def api_experts_list(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") items = [_serialize_expert_row(x) for x in list_experts()] return {"ok": True, "items": items} @router.post("/admin/api/experts") def api_experts_create( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") expert_id = normalize_expert_id(payload.get("id")) display_name_en = str(payload.get("display_name_en") or "").strip() display_name_zh = str(payload.get("display_name_zh") or "").strip() role = str(payload.get("role") or "").strip().lower() files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {} try: files = _sanitize_expert_files_payload(files_raw) created = create_expert( expert_id=expert_id, files=files, display_name_en=display_name_en, display_name_zh=display_name_zh, role=role or None, ) except ValueError as exc: return {"ok": False, "error": str(exc)} store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="expert_create", target_type="expert", target_id=str(created.get("id") or ""), status="ok", detail={"id": str(created.get("id") or "")}, ) items = [_serialize_expert_row(x) for x in list_experts()] return {"ok": True, "created": str(created.get("id") or ""), "items": items} @router.patch("/admin/api/experts/{expert_id}") def api_experts_update( expert_id: str, payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") files_raw = payload.get("files") if isinstance(payload.get("files"), dict) else {} eid = normalize_expert_id(expert_id) display_name_en = str(payload.get("display_name_en") or "").strip() display_name_zh = str(payload.get("display_name_zh") or "").strip() role = str(payload.get("role") or "").strip().lower() try: files = _sanitize_expert_files_payload(files_raw) update_expert_files(expert_id=eid, files=files) update_expert_meta( expert_id=eid, display_name_en=display_name_en, display_name_zh=display_name_zh, role=role or None, ) except ValueError as exc: return {"ok": False, "error": str(exc)} store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="expert_update", target_type="expert", target_id=eid, status="ok", detail={"id": eid, "updated_files": sorted(list(files.keys()))[:10]}, ) items = [_serialize_expert_row(x) for x in list_experts()] return {"ok": True, "updated": eid, "items": items} @router.delete("/admin/api/experts/{expert_id}") def api_experts_delete(expert_id: str, authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") eid = normalize_expert_id(expert_id) try: delete_expert(eid) except ValueError as exc: return {"ok": False, "error": str(exc)} store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="expert_delete", target_type="expert", target_id=eid, status="ok", detail={"id": eid}, ) items = [_serialize_expert_row(x) for x in list_experts()] return {"ok": True, "deleted": eid, "items": items} @router.post("/admin/api/mcp/install") def api_mcp_install( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") dry_run = bool(payload.get("dry_run", False)) try: items = parse_cursor_mcp_document(payload) except ValueError as exc: return {"ok": False, "error": str(exc)} results: list[dict[str, Any]] = [] all_ok = True for item in items: entry_args = _expand_mcp_entry_args( item.get("entry_args") if isinstance(item.get("entry_args"), list) else [] ) manifest = McpServerManifest( server_id=str(item.get("server_id") or ""), source_type=str(item.get("source_type") or "local"), source_ref=str(item.get("source_ref") or ""), version=str(item.get("version") or "").strip(), entry_command=str(item.get("entry_command") or "").strip(), entry_args=entry_args, env_schema=item.get("env_schema") if isinstance(item.get("env_schema"), dict) else {}, permissions=[str(x) for x in (item.get("required_permissions") or [])], risk_level=str(item.get("risk_level") or "high"), enabled=bool(item.get("enabled", True)), timeout_s=float(item.get("timeout_s") or 30.0), ) install = install_mcp_server(manifest, dry_run=dry_run) store.upsert_mcp_server( server_id=manifest.server_id, source_type=manifest.source_type, source_ref=manifest.source_ref, version=manifest.version, entry_command=manifest.entry_command, entry_args=manifest.entry_args, env_schema=manifest.env_schema, required_permissions=manifest.permissions, risk_level=manifest.risk_level, timeout_s=manifest.timeout_s, enabled=manifest.enabled if install.ok else False, ) store.add_mcp_installation_log( server_id=manifest.server_id, status="ok" if install.ok else "error", error_code=install.error_code, detail={"error": install.error, **(install.details or {})}, install_command=install.install_command, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_install", target_type="mcp_server", target_id=manifest.server_id, status="ok" if install.ok else "error", detail={ "source_type": manifest.source_type, "source_ref": manifest.source_ref, "error_code": install.error_code, "cursor": True, }, ) row_out = { "ok": bool(install.ok), "server_id": manifest.server_id, "install_command": install.install_command, "error_code": install.error_code, "error": install.error, "cursor": registry_row_to_cursor_server( { "server_id": manifest.server_id, "entry_command": manifest.entry_command, "entry_args": manifest.entry_args, "env_schema": manifest.env_schema, } ), } if not install.ok: all_ok = False results.append(row_out) if all_ok: persist_mcp_migrated_file(store) return { "ok": all_ok, "results": results, "installed": [x.get("server_id") for x in results if x.get("ok")], "mcp_migrated_saved": str(mcp_migrated_json_path()) if all_ok else "", } @router.post("/admin/api/mcp/config") def api_mcp_config( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") server_id = str(payload.get("server_id") or "").strip() if not server_id: return {"ok": False, "error": "server_id_required"} rows = store.list_mcp_servers(enabled_only=False) existing = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) if not existing: return {"ok": False, "error": "server_not_found"} try: fields = config_payload_to_upsert_fields(payload, existing=existing) except ValueError as exc: return {"ok": False, "error": str(exc)} entry_args = _expand_mcp_entry_args( fields.get("entry_args") if isinstance(fields.get("entry_args"), list) else [] ) reinstall = bool(payload.get("reinstall", False)) dry_run = bool(payload.get("dry_run", False)) manifest = McpServerManifest( server_id=str(fields.get("server_id") or server_id), source_type=str(fields.get("source_type") or "local"), source_ref=str(fields.get("source_ref") or server_id), version=str(fields.get("version") or "").strip(), entry_command=str(fields.get("entry_command") or "").strip(), entry_args=entry_args, env_schema=fields.get("env_schema") if isinstance(fields.get("env_schema"), dict) else {}, permissions=[str(x) for x in (fields.get("required_permissions") or [])], risk_level=str(fields.get("risk_level") or "high"), enabled=bool(fields.get("enabled", True)), timeout_s=float(fields.get("timeout_s") or 30.0), ) install_ok = True install_command = "" error_code = "" error = "" if reinstall: install = install_mcp_server(manifest, dry_run=dry_run) install_ok = bool(install.ok) install_command = install.install_command error_code = install.error_code error = install.error store.add_mcp_installation_log( server_id=manifest.server_id, status="ok" if install.ok else "error", error_code=install.error_code, detail={"error": install.error, **(install.details or {}), "config_reinstall": True}, install_command=install.install_command, ) store.upsert_mcp_server( server_id=manifest.server_id, source_type=manifest.source_type, source_ref=manifest.source_ref, version=manifest.version, entry_command=manifest.entry_command, entry_args=manifest.entry_args, env_schema=manifest.env_schema, required_permissions=manifest.permissions, risk_level=manifest.risk_level, timeout_s=manifest.timeout_s, enabled=manifest.enabled if install_ok else False, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_config_update", target_type="mcp_server", target_id=manifest.server_id, status="ok" if install_ok else "error", detail={"reinstall": reinstall, "error_code": error_code}, ) if not install_ok: return { "ok": False, "server_id": manifest.server_id, "error_code": error_code, "error": error, } persist_mcp_migrated_file(store) cursor = registry_row_to_cursor_server( { "server_id": manifest.server_id, "entry_command": manifest.entry_command, "entry_args": manifest.entry_args, "env_schema": manifest.env_schema, } ) return { "ok": True, "server_id": manifest.server_id, "install_command": install_command, "cursor": cursor, "mcp_migrated_saved": str(mcp_migrated_json_path()), } @router.post("/admin/api/mcp/reinstall") def api_mcp_reinstall( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") server_id = str(payload.get("server_id") or "").strip() if not server_id: return {"ok": False, "error": "server_id_required"} rows = store.list_mcp_servers(enabled_only=False) row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) if not row: return {"ok": False, "error": "server_not_found"} manifest = McpServerManifest( server_id=str(row.get("server_id") or ""), source_type=str(row.get("source_type") or ""), source_ref=str(row.get("source_ref") or ""), version=str(row.get("version") or ""), entry_command=str(row.get("entry_command") or ""), entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()], env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {}, permissions=[str(x) for x in (row.get("required_permissions") or [])], risk_level=str(row.get("risk_level") or "high"), enabled=bool(row.get("enabled")), timeout_s=float(row.get("timeout_s") or 30.0), ) dry_run = bool(payload.get("dry_run", False)) install = install_mcp_server(manifest, dry_run=dry_run) store.upsert_mcp_server( server_id=manifest.server_id, source_type=manifest.source_type, source_ref=manifest.source_ref, version=manifest.version, entry_command=manifest.entry_command, entry_args=manifest.entry_args, env_schema=manifest.env_schema, required_permissions=manifest.permissions, risk_level=manifest.risk_level, timeout_s=manifest.timeout_s, enabled=manifest.enabled if install.ok else False, ) store.add_mcp_installation_log( server_id=manifest.server_id, status="ok" if install.ok else "error", error_code=install.error_code, detail={"error": install.error, **(install.details or {}), "reinstall": True}, install_command=install.install_command, ) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_reinstall", target_type="mcp_server", target_id=manifest.server_id, status="ok" if install.ok else "error", detail={"error_code": install.error_code}, ) if not install.ok: return {"ok": False, "server_id": manifest.server_id, "error_code": install.error_code, "error": install.error} persist_mcp_migrated_file(store) return { "ok": True, "server_id": manifest.server_id, "install_command": install.install_command, "mcp_migrated_saved": str(mcp_migrated_json_path()), } @router.post("/admin/api/mcp/update") def api_mcp_update( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") server_id = str(payload.get("server_id") or "").strip() enabled_only = bool(payload.get("enabled_only", True)) dry_run = bool(payload.get("dry_run", False)) sync_tools = bool(payload.get("sync_tools", True)) version_override = str(payload.get("version") or "").strip() update_to_latest = bool(payload.get("update_to_latest", True)) rows = store.list_mcp_servers(enabled_only=enabled_only if not server_id else False) if server_id: rows = [x for x in rows if str(x.get("server_id") or "").strip() == server_id] if not rows: return {"ok": False, "error": "server_not_found"} apply_gateway_mcp_env_to_os() out: list[dict[str, Any]] = [] for row in rows: sid = str(row.get("server_id") or "").strip() req_version = version_override if not req_version: req_version = "" if update_to_latest else str(row.get("version") or "").strip() manifest = McpServerManifest( server_id=sid, source_type=str(row.get("source_type") or ""), source_ref=str(row.get("source_ref") or ""), version=req_version, entry_command=str(row.get("entry_command") or ""), entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()], env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {}, permissions=[str(x) for x in (row.get("required_permissions") or [])], risk_level=str(row.get("risk_level") or "high"), enabled=bool(row.get("enabled")), timeout_s=float(row.get("timeout_s") or 30.0), ) install = install_mcp_server(manifest, dry_run=dry_run) store.upsert_mcp_server( server_id=manifest.server_id, source_type=manifest.source_type, source_ref=manifest.source_ref, version=manifest.version, entry_command=manifest.entry_command, entry_args=manifest.entry_args, env_schema=manifest.env_schema, required_permissions=manifest.permissions, risk_level=manifest.risk_level, timeout_s=manifest.timeout_s, enabled=manifest.enabled if install.ok else False, ) item: dict[str, Any] = { "server_id": manifest.server_id, "ok": bool(install.ok), "error_code": str(install.error_code or ""), "error": str(install.error or ""), "install_command": str(install.install_command or ""), "requested_version": req_version, "tools_synced": 0, } if install.ok and sync_tools and (not dry_run): latest_row = next( (x for x in store.list_mcp_servers(enabled_only=False) if str(x.get("server_id") or "") == sid), None, ) if isinstance(latest_row, dict): hs = _mcp_health_and_sync_one(store, latest_row) if isinstance(hs, dict): item["tools_synced"] = int(hs.get("tools_synced") or 0) if not bool(hs.get("ok")): item["ok"] = False item["error_code"] = str(hs.get("error_code") or "mcp_sync_failed") item["error"] = str(hs.get("error") or "sync_failed") store.add_mcp_installation_log( server_id=manifest.server_id, status="ok" if item["ok"] else "error", error_code=str(item["error_code"] or None), detail={ "error": item["error"], "update": True, "requested_version": req_version, "tools_synced": int(item.get("tools_synced") or 0), }, install_command=install.install_command, ) out.append(item) ok_count = len([x for x in out if bool(x.get("ok"))]) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_update" if server_id else "mcp_update_batch", target_type="mcp_server", target_id=server_id or "batch", status="ok" if ok_count == len(out) else "error", detail={ "enabled_only": enabled_only, "dry_run": dry_run, "sync_tools": sync_tools, "update_to_latest": update_to_latest, "requested_version": version_override, "ok_count": ok_count, "error_count": len(out) - ok_count, }, ) if ok_count > 0: persist_mcp_migrated_file(store) return { "ok": ok_count == len(out), "total": len(out), "ok_count": ok_count, "error_count": len(out) - ok_count, "items": out, "mcp_migrated_saved": str(mcp_migrated_json_path()), } @router.post("/admin/api/mcp/uninstall") def api_mcp_uninstall( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") server_id = str(payload.get("server_id") or "").strip() remove_record = bool(payload.get("remove_record", True)) if not server_id: return {"ok": False, "error": "server_id_required"} rows = store.list_mcp_servers(enabled_only=False) row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) if not row: return {"ok": False, "error": "server_not_found"} manifest = McpServerManifest( server_id=str(row.get("server_id") or ""), source_type=str(row.get("source_type") or ""), source_ref=str(row.get("source_ref") or ""), version=str(row.get("version") or ""), entry_command=str(row.get("entry_command") or ""), entry_args=[str(x) for x in (row.get("entry_args") or []) if str(x).strip()], env_schema=row.get("env_schema") if isinstance(row.get("env_schema"), dict) else {}, permissions=[str(x) for x in (row.get("required_permissions") or [])], risk_level=str(row.get("risk_level") or "high"), enabled=False, timeout_s=float(row.get("timeout_s") or 30.0), ) dry_run = bool(payload.get("dry_run", False)) res = uninstall_mcp_server(manifest, dry_run=dry_run) store.add_mcp_installation_log( server_id=manifest.server_id, status="ok" if res.ok else "error", error_code=res.error_code, detail={"error": res.error, **(res.details or {}), "uninstall": True}, install_command=res.install_command, ) deleted = {"registry": 0, "tools": 0, "health": 0, "install_logs": 0} if res.ok and remove_record and not dry_run: deleted = store.delete_mcp_server(server_id=manifest.server_id) mapping = _load_mcp_specialist_binding(store) for sp, sids in list(mapping.items()): mapping[sp] = [sid for sid in sids if sid != manifest.server_id] store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False)) try: from runtime.direct_loop import invalidate_tool_wire_cache invalidate_tool_wire_cache(reason=f"mcp_uninstall_binding:{manifest.server_id}") except Exception: pass store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_uninstall", target_type="mcp_server", target_id=manifest.server_id, status="ok" if res.ok else "error", detail={"error_code": res.error_code, "remove_record": remove_record, "deleted": deleted}, ) if not res.ok: return {"ok": False, "server_id": manifest.server_id, "error_code": res.error_code, "error": res.error} persist_mcp_migrated_file(store) return {"ok": True, "server_id": manifest.server_id, "uninstall_command": res.install_command, "deleted": deleted} @router.post("/admin/api/mcp/delete") def api_mcp_delete( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") server_id = str(payload.get("server_id") or "").strip() if not server_id: return {"ok": False, "error": "server_id_required"} deleted = store.delete_mcp_server(server_id=server_id) mapping = _load_mcp_specialist_binding(store) for sp, sids in list(mapping.items()): mapping[sp] = [sid for sid in sids if sid != server_id] store.set_setting("mcp_specialist_server_binding", json.dumps(mapping, ensure_ascii=False)) try: from runtime.direct_loop import invalidate_tool_wire_cache invalidate_tool_wire_cache(reason=f"mcp_delete_binding:{server_id}") except Exception: pass store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_delete", target_type="mcp_server", target_id=server_id, status="ok" if any(int(v or 0) > 0 for v in deleted.values()) else "miss", detail={"deleted": deleted}, ) if any(int(v or 0) > 0 for v in deleted.values()): persist_mcp_migrated_file(store) return {"ok": True, "server_id": server_id, "deleted": deleted} @router.post("/admin/api/mcp/preflight") def api_mcp_preflight( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") source_type = str(payload.get("source_type") or "").strip().lower() source_ref = str(payload.get("source_ref") or "").strip() if source_type not in {"github", "npm", "pypi", "local"} or not source_ref: return {"ok": False, "error_code": "mcp_invalid_source", "error": "invalid_source"} manifest = McpServerManifest( server_id=_safe_server_id(str(payload.get("server_id") or source_ref)), source_type=source_type, source_ref=source_ref, version=str(payload.get("version") or "").strip(), entry_command=str(payload.get("entry_command") or "").strip(), entry_args=_expand_mcp_entry_args(payload.get("entry_args") if isinstance(payload.get("entry_args"), list) else []), env_schema=payload.get("env_schema") if isinstance(payload.get("env_schema"), dict) else {}, permissions=[str(x) for x in (payload.get("required_permissions") or [])], risk_level=str(payload.get("risk_level") or "high"), enabled=bool(payload.get("enabled", True)), timeout_s=float(payload.get("timeout_s") or 30.0), ) res = preflight_mcp_server(manifest) return {"ok": bool(res.get("ok")), **res} @router.post("/admin/api/mcp/toggle") def api_mcp_toggle( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") server_id = str(payload.get("server_id") or "").strip() enabled = bool(payload.get("enabled")) if not server_id: return {"ok": False, "error": "server_id_required"} n = store.set_mcp_server_enabled(server_id=server_id, enabled=enabled) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_toggle", target_type="mcp_server", target_id=server_id, status="ok" if n > 0 else "miss", detail={"enabled": enabled}, ) return {"ok": True, "updated": n} @router.post("/admin/api/mcp/healthcheck") def api_mcp_healthcheck( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") apply_gateway_mcp_env_to_os() server_id = str(payload.get("server_id") or "").strip() if not server_id: return {"ok": False, "error": "server_id_required"} rows = store.list_mcp_servers(enabled_only=False) row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) if not row: return {"ok": False, "error": "server_not_found"} cmd = str(row.get("entry_command") or "").strip() args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()] if not cmd: return {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"} if _is_bailian_webparser_remote(entry_command=cmd, entry_args=args): detail = {"ok": True, "status": "ok", "compat_mode": "bailian_webparser", "tools_count": 1} store.set_mcp_server_health(server_id=server_id, status="ok", detail=detail) return {"ok": True, "response": detail} rt = mcp_runtime_for_row(row, store=store) try: response = rt.health() ok = bool(response.get("ok")) store.set_mcp_server_health(server_id=server_id, status="ok" if ok else "error", detail=response) if ok: return {"ok": True, "response": response} return { "ok": False, "error_code": str(response.get("error_code") or "mcp_healthcheck_failed"), "error": str(response.get("error") or "healthcheck_failed"), "response": response, } except Exception as exc: store.set_mcp_server_health( server_id=server_id, status="error", detail={"error_code": "mcp_healthcheck_failed", "error": f"{type(exc).__name__}: {exc}"}, ) return {"ok": False, "error_code": "mcp_healthcheck_failed", "error": f"{type(exc).__name__}: {exc}"} finally: rt.stop() @router.post("/admin/api/mcp/tools/sync") def api_mcp_tools_sync( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") apply_gateway_mcp_env_to_os() server_id = str(payload.get("server_id") or "").strip() if not server_id: return {"ok": False, "error": "server_id_required"} rows = store.list_mcp_servers(enabled_only=False) row = next((x for x in rows if str(x.get("server_id") or "") == server_id), None) if not row: return {"ok": False, "error": "server_not_found"} cmd = str(row.get("entry_command") or "").strip() args = [str(x) for x in (row.get("entry_args") or []) if str(x).strip()] if not cmd: return {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"} if _is_bailian_webparser_remote(entry_command=cmd, entry_args=args): norm = _bailian_webparser_virtual_tools() store.replace_mcp_server_tools(server_id=server_id, tools=norm) store.set_mcp_server_health( server_id=server_id, status="ok", detail={"synced_tools": len(norm), "compat_mode": "bailian_webparser"}, ) try: from runtime.direct_loop import invalidate_tool_wire_cache invalidate_tool_wire_cache(reason=f"mcp_admin_sync:{server_id}") except Exception: pass prewarm = _spawn_async_runtime_prewarm(reason=f"mcp_admin_sync:{server_id}") return { "ok": True, "server_id": server_id, "synced_tools": len(norm), "tool_names": [str(t.get("tool_name") or "") for t in norm if str(t.get("tool_name") or "").strip()], "compat_mode": "bailian_webparser", "prewarm": prewarm, } rt = mcp_runtime_for_row(row, store=store) try: response = rt.tools_list() items = response.get("tools") if isinstance(response, dict) else None if not isinstance(items, list): return {"ok": False, "error_code": "mcp_tools_list_invalid", "error": "tools_list_invalid"} norm = [] for it in items: if not isinstance(it, dict): continue tool_name = str(it.get("tool_name") or it.get("name") or "").strip() if not tool_name: continue norm.append( { "tool_name": tool_name, "description": str(it.get("description") or ""), "parameters": it.get("parameters") if isinstance(it.get("parameters"), dict) else {}, } ) store.replace_mcp_server_tools(server_id=server_id, tools=norm) store.set_mcp_server_health(server_id=server_id, status="ok", detail={"synced_tools": len(norm)}) try: from runtime.direct_loop import invalidate_tool_wire_cache invalidate_tool_wire_cache(reason=f"mcp_admin_sync:{server_id}") except Exception: pass prewarm = _spawn_async_runtime_prewarm(reason=f"mcp_admin_sync:{server_id}") return { "ok": True, "server_id": server_id, "synced_tools": len(norm), "tool_names": [str(t.get("tool_name") or "") for t in norm if str(t.get("tool_name") or "").strip()], "prewarm": prewarm, } finally: rt.stop() @router.post("/admin/api/mcp/check-all") def api_mcp_check_all( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") apply_gateway_mcp_env_to_os() enabled_only = bool(payload.get("enabled_only", True)) rows = store.list_mcp_servers(enabled_only=enabled_only) out: list[dict[str, Any]] = [] for row in rows: item = _mcp_health_and_sync_one(store, row) if item is not None: out.append(item) ok_count = len([x for x in out if bool(x.get("ok"))]) return {"ok": True, "total": len(out), "ok_count": ok_count, "error_count": len(out) - ok_count, "items": out} @router.post("/admin/api/mcp/check-updates") def api_mcp_check_updates( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") enabled_only = bool(payload.get("enabled_only", True)) rows = store.list_mcp_servers(enabled_only=enabled_only) items = [_check_mcp_update_row(row) for row in rows] update_count = len([x for x in items if bool(x.get("has_update"))]) return { "ok": True, "total": len(items), "update_count": update_count, "up_to_date_count": len(items) - update_count, "items": items, } @router.post("/admin/api/mcp/repair-weak") def api_mcp_repair_weak( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: """ For enabled MCP rows whose persisted health is not ``ok`` and/or cached tool list is empty, run the same health + tools/list + replace flow as ``check-all`` (only those servers). """ payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") apply_gateway_mcp_env_to_os() enabled_only = bool(payload.get("enabled_only", True)) rows = store.list_mcp_servers(enabled_only=enabled_only) health_by_sid = {str(x.get("server_id") or ""): x for x in store.list_mcp_server_health()} targets: list[dict[str, Any]] = [] skipped_healthy = 0 for row in rows: sid = str(row.get("server_id") or "").strip() if not sid: continue h = health_by_sid.get(sid) or {} st = str(h.get("status") or "").strip().lower() n_tools = len(store.list_mcp_server_tools(server_id=sid)) if st == "ok" and n_tools > 0: skipped_healthy += 1 continue targets.append(row) out: list[dict[str, Any]] = [] for row in targets: item = _mcp_health_and_sync_one(store, row) if item is not None: out.append(item) ok_count = len([x for x in out if bool(x.get("ok"))]) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_repair_weak", target_type="mcp_server", target_id="batch", status="ok", detail={ "selected": len(targets), "skipped_healthy": skipped_healthy, "ok_count": ok_count, "error_count": len(out) - ok_count, }, ) return { "ok": True, "selected": len(targets), "skipped_healthy": skipped_healthy, "total": len(out), "ok_count": ok_count, "error_count": len(out) - ok_count, "items": out, } @router.post("/admin/api/mcp/e2e-check") def api_mcp_e2e_check( authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") apply_gateway_mcp_env_to_os() sess = store.create_session("mcp-e2e-check") session_id = str(sess.id) try: store.add_message(session_id=session_id, role="user", content="mcp e2e check") reg = default_registry(expert="generalist+workspace+productivity", specialist="generalist") runtime = ToolExecutor() names = {t.name for t in reg.list()} plans, skipped_servers = build_mcp_e2e_probe_plans(reg, workspace_root=str(PROJECT_ROOT.resolve())) out: list[dict[str, Any]] = [] for server_id, full_name, args in plans: row = { "server_id": server_id, "tool_name": full_name, "in_registry": full_name in names, "ok": False, "duration_ms": 0, "error": "", } if not bool(row["in_registry"]): row["error"] = "tool_not_in_registry" out.append(row) continue call = LLMToolCall(id=f"call_{server_id}", name=full_name, arguments=args) tool_ctx = ToolExecutionContext( store=store, tools=reg, session_id=session_id, lang="zh", user_text=f"e2e check {full_name}", specialist="generalist", task_kind="mcp_e2e", policy_engine=None, trace_id="mcp-e2e", parent_span_id="root", ) _tool_msgs, result_by_id = runtime.execute_tool_uses( ctx=tool_ctx, assistant_msg_id=0, tool_uses=[call], on_tool_ui=None, should_stop=None, ) hit = result_by_id.get(call.id) if hit: result, dur = hit row["duration_ms"] = int(dur or 0) row["ok"] = bool((result or {}).get("ok")) row["error"] = str((result or {}).get("error") or (result or {}).get("error_code") or "") else: row["error"] = "no_result" out.append(row) for sid in skipped_servers: out.append( { "server_id": sid, "tool_name": "", "in_registry": True, "ok": False, "duration_ms": 0, "error": "e2e_no_probe_candidate", } ) ok_count = len([x for x in out if bool(x.get("ok"))]) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_e2e_check", target_type="mcp_server", target_id="all", status="ok", detail={"session_id": session_id, "total": len(out), "ok_count": ok_count}, ) return { "ok": True, "session_id": session_id, "total": len(out), "ok_count": ok_count, "error_count": len(out) - ok_count, "items": out, } except Exception as e: return {"ok": False, "session_id": session_id, "error": f"{type(e).__name__}: {e}"} @router.get("/admin/api/memory/hits") def api_memory_hits( tenant_id: str | None = Query(default=None), user_id: str | None = Query(default=None), limit: int = Query(default=100), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") if tenant_id: _require_tenant_scope(ctx, tenant_id) rows = store.list_memory_hit_logs(tenant_id=tenant_id, user_id=user_id, limit=limit) return {"ok": True, "hits": rows} @router.get("/admin/api/memory/stats") def api_memory_stats( tenant_id: str | None = Query(default=None), user_id: str | None = Query(default=None), limit: int = Query(default=300), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") if tenant_id: _require_tenant_scope(ctx, tenant_id) hits = store.list_memory_hit_logs(tenant_id=tenant_id, user_id=user_id, limit=limit) items = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=500, offset=0) hit_count = len(hits) avg_score = (sum(float(x.get("score") or 0.0) for x in hits) / hit_count) if hit_count else 0.0 source_count: dict[str, int] = {} for h in hits: src = str(h.get("source") or "unknown") source_count[src] = source_count.get(src, 0) + 1 top_sources = sorted(source_count.items(), key=lambda x: x[1], reverse=True)[:5] return { "ok": True, "stats": { "hit_count": hit_count, "avg_score": avg_score, "item_count": len(items), "top_sources": [{"source": k, "count": v} for k, v in top_sources], }, } @router.get("/admin/api/memory/items") def api_memory_items( tenant_id: str | None = Query(default=None), user_id: str | None = Query(default=None), limit: int = Query(default=100), offset: int = Query(default=0), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") if tenant_id: _require_tenant_scope(ctx, tenant_id) rows = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=limit, offset=offset) runtime = read_vector_memory_runtime(store) return {"ok": True, "items": rows, "runtime": runtime.__dict__} @router.post("/admin/api/memory/delete") def api_memory_delete( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:memory:write") memory_id = str(payload.get("memory_id") or "").strip() if not memory_id: return {"ok": False, "error": "memory_id is required"} deleted = store.delete_memory_item(memory_id=memory_id) return {"ok": True, "deleted": deleted} @router.post("/admin/api/memory/reindex") def api_memory_reindex( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:memory:write") tenant_id = str(payload.get("tenant_id") or "").strip() or None if tenant_id: _require_tenant_scope(ctx, tenant_id) user_id = str(payload.get("user_id") or "").strip() or None from svc.embeddings.embedding_client import build_default_embedding_client from runtime.orchestration.vector_store import build_vector_store, MemoryVectorItem client = build_default_embedding_client() vector = build_vector_store(store) items = store.list_memory_items(tenant_id=tenant_id, user_id=user_id, limit=500, offset=0) updated = 0 for row in items: content = str(row.get("content") or "").strip() if not content: continue emb = client.embed(content) item = MemoryVectorItem( memory_id=str(row.get("memory_id") or ""), tenant_id=str(row.get("tenant_id") or ""), user_id=str(row.get("user_id") or ""), session_id=str(row.get("session_id") or ""), memory_type=str(row.get("memory_type") or "semantic_memory"), content=content, confidence=float(row.get("confidence") or 0.0), created_at=str(row.get("created_at") or ""), updated_at=str(row.get("updated_at") or ""), expires_at=str(row.get("expires_at") or "") or None, metadata=row.get("metadata") if isinstance(row.get("metadata"), dict) else {}, ) vector.upsert(item, emb.vector, model=emb.model) updated += 1 return {"ok": True, "reindexed": updated} @router.get("/admin/api/memory/config") def api_memory_config(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") runtime = read_vector_memory_runtime(store) rag_mode = str(store.get_setting("rag_mode") or store.get_setting("AIA_RAG_MODE") or "keyword").strip().lower() if rag_mode not in {"keyword", "vector"}: rag_mode = "keyword" emb_mode = str(store.get_setting("AIA_RAG_EMBEDDING_MODE") or "").strip().lower() if emb_mode not in {"", "openai", "hash", "offline"}: emb_mode = "" ttl_raw = str( store.get_setting("AIA_MEMORY_EPISODIC_TTL_DAYS") or store.get_setting("MEMORY_EPISODIC_TTL_DAYS") or "90" ).strip() try: ttl_days = max(1, min(int(float(ttl_raw)), 3650)) except Exception: ttl_days = 90 cfg = dict(runtime.__dict__) cfg["rag_mode"] = rag_mode cfg["rag_embedding_mode"] = emb_mode cfg["memory_episodic_ttl_days"] = ttl_days return {"ok": True, "config": cfg} @router.post("/admin/api/memory/config") def api_memory_config_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:memory:write") store.set_setting("MEMORY_VECTOR_ENABLED", "1" if str(payload.get("enabled") or "").lower() in ("1", "true", "yes", "on") else "0") backend = str(payload.get("backend") or "sqlite").strip().lower() if backend != "sqlite": backend = "sqlite" store.set_setting("MEMORY_VECTOR_BACKEND", backend) store.set_setting("MEMORY_VECTOR_TOPK", str(payload.get("top_k") or 5)) store.set_setting( "MEMORY_WRITE_ENABLED", "1" if str(payload.get("writer_enabled") or "").lower() in ("1", "true", "yes", "on") else "0", ) store.set_setting("MEMORY_WRITE_MIN_CONFIDENCE", str(payload.get("write_min_confidence") or 0.75)) rag_mode = str(payload.get("rag_mode") or "keyword").strip().lower() if rag_mode not in {"keyword", "vector"}: rag_mode = "keyword" emb_mode = str(payload.get("rag_embedding_mode") or "").strip().lower() if emb_mode not in {"", "openai", "hash", "offline"}: emb_mode = "" ttl_in = payload.get("memory_episodic_ttl_days", 90) try: ttl_days = max(1, min(int(float(ttl_in)), 3650)) except Exception: ttl_days = 90 store.set_setting("rag_mode", rag_mode) store.set_setting("AIA_RAG_MODE", rag_mode) store.set_setting("AIA_RAG_EMBEDDING_MODE", emb_mode) store.set_setting("AIA_MEMORY_EPISODIC_TTL_DAYS", str(ttl_days)) store.set_setting("MEMORY_EPISODIC_TTL_DAYS", str(ttl_days)) return api_memory_config() @router.post("/admin/api/memory/cleanup-low-confidence") def api_memory_cleanup_low_conf( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:memory:write") try: threshold = float(payload.get("max_confidence") or 0.35) except Exception: threshold = 0.35 deleted = store.clear_low_confidence_memory(max_confidence=threshold) return {"ok": True, "deleted": deleted, "max_confidence": threshold} @router.post("/admin/api/secrets/migrate") def api_secrets_migrate(authorization: str | None = Header(default=None)) -> dict[str, Any]: """Migrate legacy b64 secrets to fernet (requires AIA_ASSISTANT_MASTER_KEY).""" store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") try: res = store.migrate_secrets_to_fernet() except Exception as e: return {"ok": False, "error": f"{type(e).__name__}: {e}"} store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="secrets_migrate_to_fernet", target_type="secrets", target_id="all", status="ok", detail=res, ) return {"ok": True, **res} @router.get("/admin/api/secrets/status") def api_secrets_status(authorization: str | None = Header(default=None)) -> dict[str, Any]: """Expose legacy secret stats for admin UI warnings.""" store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:read") stats = store.legacy_secret_stats() import os has_master_key = bool((os.getenv("AIA_ASSISTANT_MASTER_KEY") or "").strip()) return {"ok": True, "has_master_key": has_master_key, **stats} @router.post("/admin/api/auth/bootstrap") def api_auth_bootstrap() -> dict[str, Any]: store = get_assistant_store() _ensure_admin_bootstrap(store) return {"ok": True} @router.post("/admin/api/auth/login") def api_auth_login(payload: dict[str, Any] | None = Body(default=None)) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() _ensure_admin_bootstrap(store) purpose = str(payload.get("purpose") or "console").strip().lower() if purpose not in {"console", "chat"}: purpose = "console" if purpose == "console": username = str(payload.get("username") or "administrator").strip().lower() else: username = str(payload.get("username") or "").strip().lower() tenant_id = _resolve_login_tenant_id( store, username=username, tenant_id=str(payload.get("tenant_id") or "").strip(), ) password = str(payload.get("password") or "").strip() if not tenant_id or not password: return {"ok": False, "error": "tenant_id, username, password are required"} if not username: return {"ok": False, "error": "tenant_id, username, password are required"} user = store.get_user_by_username(tenant_id=tenant_id, username=username) if not user: return {"ok": False, "error": "invalid_credentials"} if not bool(user.get("is_active")): return {"ok": False, "error": "user_disabled"} expected = str(user.get("password_hash") or "") if not expected or not hmac.compare_digest(expected, _sha256_hex(password)): return {"ok": False, "error": "invalid_credentials"} role = str(user.get("role") or "member") if purpose == "console": if role == "owner" and username == "administrator": uid = str(user.get("id") or "") if uid and store.update_user_account(tenant_id=tenant_id, user_id=uid, role="admin"): user = store.get_user_by_username(tenant_id=tenant_id, username=username) or user role = str(user.get("role") or "member") perms = store.list_user_permissions( tenant_id=tenant_id, user_id=str(user.get("id") or ""), role=role, ) token = secrets.token_urlsafe(32) expires = (_now_utc() + timedelta(hours=12)).isoformat() store.create_auth_session( session_token_hash=_sha256_hex(token), tenant_id=tenant_id, user_id=str(user.get("id") or ""), role=role, expires_at=expires, ) return { "ok": True, "token": token, "session": { "tenant_id": tenant_id, "user_id": str(user.get("id") or ""), "username": str(user.get("username") or ""), "display_name": str(user.get("display_name") or ""), "role": str(user.get("role") or ""), "permissions": perms, "expires_at": expires, }, } @router.get("/admin/api/auth/me") def api_auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) return {"ok": True, "session": ctx} @router.post("/admin/api/auth/logout") def api_auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() token = _extract_bearer(authorization) if token: store.revoke_auth_session(session_token_hash=_sha256_hex(token)) return {"ok": True} @router.post("/admin/api/ops-ai/analyze-sync") def api_ops_ai_analyze_sync( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: payload = payload or {} store = get_assistant_store() ctx = _resolve_ops_ai_auth(store, authorization) _require_permission(ctx, "admin:read") question = str(payload.get("question") or "").strip() dataset_ref = payload.get("dataset_ref") if isinstance(payload.get("dataset_ref"), dict) else {} context = payload.get("context") if isinstance(payload.get("context"), dict) else {} constraints = payload.get("constraints") if isinstance(payload.get("constraints"), dict) else {} analysis_request_id = str(payload.get("analysis_request_id") or "").strip() if not question: raise HTTPException(status_code=400, detail="question_required") severity_summary = context.get("severity_summary") if isinstance(context.get("severity_summary"), list) else [] top_alarm_codes = context.get("top_alarm_codes") if isinstance(context.get("top_alarm_codes"), list) else [] top_ne = context.get("top_ne") if isinstance(context.get("top_ne"), list) else [] findings = context.get("findings") if isinstance(context.get("findings"), list) else [] protocol_summary = context.get("protocol_summary") if isinstance(context.get("protocol_summary"), list) else [] lang = str(constraints.get("language") or "zh-CN") interaction_mode = normalize_interaction_mode(payload.get("interaction_mode") or "expert") selected_specialist = normalize_requested_specialist(payload.get("specialist") or "ops") visible_profiles = store.list_llm_profiles(visible_only=True) visible_profile_ids = {str(p.get("id") or "").strip() for p in visible_profiles if str(p.get("id") or "").strip()} ops_ai_active_profile_id = str(store.get_setting("ops_ai_active_llm_profile_id") or "").strip() if ops_ai_active_profile_id not in visible_profile_ids: ops_ai_active_profile_id = "" raw_ops_ai_bindings = parse_agent_profile_bindings(store.get_setting("ops_ai_agent_profile_bindings")) def _ops_ai_profile_for_role(role_id: str) -> str | None: bound = str(raw_ops_ai_bindings.get(role_id) or "").strip() if bound and bound in visible_profile_ids: return bound return None selected_profile_id = _ops_ai_profile_for_role(selected_specialist) if not selected_profile_id: raise HTTPException(status_code=400, detail=f"ops_ai_profile_unbound:{selected_specialist}") ops_prompt = ( f"问题:{question}\n" f"数据范围:{json.dumps(dataset_ref, ensure_ascii=False)}\n" f"级别分布:{json.dumps(severity_summary, ensure_ascii=False)}\n" f"高频告警码:{json.dumps(top_alarm_codes, ensure_ascii=False)}\n" f"高频网元:{json.dumps(top_ne, ensure_ascii=False)}\n" f"协议/领域分布:{json.dumps(protocol_summary, ensure_ascii=False)}\n" f"附加发现:{json.dumps(findings, ensure_ascii=False)}\n" "请以网络运维专家视角给出:" "1) 根因假设;2) 证据与缺口;3) 处置优先级;4) 需要进一步采集的数据。" ) tenant_id = str(ctx.get("tenant_id") or "") user_id = str(ctx.get("user_id") or "ops-ai-service") username = str(ctx.get("username") or "ops-ai-service") role = str(ctx.get("role") or "service") gw_result = None answer = "" gateway_error = "" try: apply_gateway_mcp_env_to_os() # Service-token calls may not map to a real UI user row. # Use internal session to avoid ui_session_owner foreign-key constraints. ops_session = store.create_session( title=f"ops-ai-sync:{str(dataset_ref.get('batch_id') or 'adhoc')}" ) manager_agent = build_gateway_executor( store, lang=lang, specialist="generalist", profile_id=selected_profile_id, openai_api_key=None, llm_mode=None, model=None, base_url=None, # Internal ops-ai should use the global model pool bindings # instead of service-user personal visibility filtering. viewer_user_id=None, viewer_username="administrator", viewer_tenant_id=None, policy_session_id=str(ops_session.id), path_policy_tenant_id=tenant_id or None, path_policy_user_id=user_id or None, ) specialist_factory = lambda sid: build_gateway_executor( store, lang=lang, specialist=sid, profile_id=_ops_ai_profile_for_role(str(sid or "").strip().lower()) or selected_profile_id, openai_api_key=None, llm_mode=None, model=None, base_url=None, viewer_user_id=None, viewer_username="administrator", viewer_tenant_id=None, policy_session_id=str(ops_session.id), path_policy_tenant_id=tenant_id or None, path_policy_user_id=user_id or None, ) gw = OclawGateway(store=store) gw_result = gw.handle_turn( msg=StandardMessage( session_id=str(ops_session.id), tenant_id=tenant_id, user_id=user_id, role=role, channel="ops_ai_api", text=ops_prompt, attachments=[], metadata={ "tenant_id": tenant_id, "user_id": user_id, "role": role, "channel": "ops_ai_api", "interaction_mode": interaction_mode, "selected_specialist": selected_specialist, "memory_mode": "default", "execution_mode": "normal", }, ), lang=lang, executor=manager_agent, run_id=(analysis_request_id or None), specialist_executor_factory=specialist_factory, ) answer = str(getattr(gw_result, "reply_text", "") or "") except Exception as exc: gateway_error = str(exc) sev_text = ";".join( f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" for x in severity_summary[:5] if isinstance(x, dict) ) code_text = ";".join( f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" for x in top_alarm_codes[:5] if isinstance(x, dict) ) ne_text = ";".join( f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" for x in top_ne[:5] if isinstance(x, dict) ) proto_text = ";".join( f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" for x in protocol_summary[:5] if isinstance(x, dict) ) answer = ( "网关专家暂时不可用,先给出统计结论:\n" f"- 级别分布:{sev_text or '无'}\n" f"- 高频告警码:{code_text or '无'}\n" f"- 高频网元:{ne_text or '无'}\n" f"- 协议/领域:{proto_text or '无'}\n" "- 建议优先处理 critical/major 的热点网元,并结合 top 告警码排查是否告警风暴。" ) try: store.add_admin_audit_log( actor_tenant_id=str(ctx.get("tenant_id") or ""), actor_user_id=str(ctx.get("user_id") or ""), action="ops_ai_analyze_sync", target_type="ops_ai", target_id=analysis_request_id or str(dataset_ref.get("batch_id") or "unknown"), status="ok", detail={ "question": question[:200], "dataset_ref": dataset_ref, "risk_hint": findings[:3] if findings else [], "mode": interaction_mode, "specialist": selected_specialist, "gateway_error": gateway_error, }, ) except Exception: pass return { "ok": True, "analysis_request_id": analysis_request_id, "answer": answer, "evidence": { "severity_summary": severity_summary[:10], "top_alarm_codes": top_alarm_codes[:10], "top_ne": top_ne[:10], "protocol_summary": protocol_summary[:10], "findings": findings[:10], }, "followups": [ "请给出过去2小时 critical 告警的 top5 网元与同比变化。", "请按告警码聚类,定位是否为单一根因引发的告警风暴。", ], "metadata": { "engine": "oclaw_ops_ai_sync_v1", "source": "gateway_expert" if not gateway_error else "fallback_context", "interaction_mode": str(getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode), "selected_specialist": str(getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist), "ops_ai_active_profile_id": ops_ai_active_profile_id, "ops_ai_role_profile_id": _ops_ai_profile_for_role(selected_specialist) or "", "gateway_error": gateway_error, "caller": str(ctx.get("username") or ""), }, } @router.get("/admin/api/ops-ai/logs") def api_ops_ai_logs( limit: int = Query(default=50), offset: int = Query(default=0), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_ops_ai_auth(store, authorization) _require_permission(ctx, "admin:read") lim = max(1, min(int(limit), 200)) off = max(0, int(offset)) rows = store.list_admin_audit_logs( tenant_id=str(ctx.get("tenant_id") or ""), action="ops_ai_analyze_sync", actor_user_id=None, status=None, limit=lim, offset=off, ) total = store.count_admin_audit_logs( tenant_id=str(ctx.get("tenant_id") or ""), action="ops_ai_analyze_sync", actor_user_id=None, status=None, ) return {"ok": True, "items": rows, "total": int(total), "limit": lim, "offset": off} @router.get("/admin/api/ops-ai/health") def api_ops_ai_health(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_ops_ai_auth(store, authorization) _require_permission(ctx, "admin:read") return {"ok": True, "service": "oclaw", "component": "ops-ai", "status": "ok", "caller": str(ctx.get("username") or "")} @router.get("/admin/api/admin-audit") def api_admin_audit( limit: int = Query(default=200), offset: int = Query(default=0), action: str | None = Query(default=None), actor_user_id: str | None = Query(default=None), status: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = get_assistant_store() ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") a = str(action or "").strip() actor = str(actor_user_id or "").strip() st = str(status or "").strip() lim = max(1, min(int(limit), 500)) off = max(0, int(offset)) tenant_id = str(ctx.get("tenant_id") or "") rows = store.list_admin_audit_logs( tenant_id=tenant_id, action=a or None, actor_user_id=actor or None, status=st or None, limit=lim, offset=off, ) total = store.count_admin_audit_logs( tenant_id=tenant_id, action=a or None, actor_user_id=actor or None, status=st or None, ) return { "ok": True, "items": rows, "logs": rows, "total": int(total), "limit": int(lim), "offset": int(off), } from interfaces.admin.chat_api import include_chat_routes from interfaces.admin.models_api import include_model_mgmt_routes from interfaces.admin.skills_api import include_skill_routes include_chat_routes(router, resolve_auth=_resolve_auth) include_model_mgmt_routes(router, resolve_auth=_resolve_auth) include_skill_routes(router, resolve_auth=_resolve_auth) return router __all__ = ["build_admin_router", "admin_static_dir"]