from __future__ import annotations from pathlib import Path import hashlib import hmac import json import os import secrets 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 oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os from oclaw.runtime.operations.providers.registry import build_channel_registry from oclaw.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 oclaw.runtime.operations.stack import cmd_stack_down, cmd_stack_status, cmd_stack_up from oclaw.runtime.orchestration.vector_store import read_vector_memory_runtime from oclaw.platform.config.paths import PROJECT_ROOT, db_path from oclaw.platform.config.passwords import load_expected_password from oclaw.platform.persistence.sqlite_store import SqliteStore from oclaw.runtime.agents.specialists import discover_specialist_ids from oclaw.runtime.tools.mcp.installer import ( _safe_server_id, detect_local_dependencies, install_mcp_server, preflight_mcp_server, uninstall_mcp_server, ) from oclaw.runtime.tools.mcp.market import search_mcp_market, trending_mcp_market from oclaw.runtime.tools.mcp.manifest import McpServerManifest from oclaw.runtime.operations.mcp_registry_export import ( build_mcp_install_export_document, mcp_migrated_json_path, persist_mcp_migrated_file, ) from oclaw.runtime.tools.mcp.filesystem_argv import build_mcp_process_command from oclaw.runtime.tools.mcp.registry import McpRegistry from oclaw.runtime.tools.mcp.runtime import McpProcessRuntime from oclaw.interfaces.admin.mcp_e2e_probe import build_mcp_e2e_probe_plans from oclaw.runtime.tools.catalog import default_registry from oclaw.runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor from oclaw.platform.llm.chat_models import LLMToolCall from oclaw.runtime.agent_core_attempt import ALL_ATTEMPT_ERROR_CODES from oclaw.runtime.agent_core_run import DEFAULT_RETRYABLE_ERROR_CODES, resolve_retryable_error_codes from oclaw.runtime.prompt_prebuild import run_runtime_prewarm, runtime_prewarm_prompts_snapshot, runtime_prewarm_status from oclaw.runtime.workspaces.experts import create_expert, delete_expert, list_experts, normalize_expert_id, update_expert_files _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: """Run MCP initialize health + tools/list + persist tools (same semantics as check-all per row).""" sid = str(row.get("server_id") or "").strip() 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 sid: return None if not cmd: item: dict[str, Any] = { "server_id": sid, "ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing", "health": {"ok": False, "error_code": "mcp_entry_missing", "error": "entry_command_missing"}, "tools_synced": 0, } store.set_mcp_server_health(server_id=sid, status="error", detail=item["health"]) return item rt = McpProcessRuntime( build_mcp_process_command(cmd, args, store=store), timeout_s=float(row.get("timeout_s") or 30.0), ) try: health = rt.health() health_ok = bool(health.get("ok")) if not health_ok: item = { "server_id": sid, "ok": False, "error_code": str(health.get("error_code") or "mcp_healthcheck_failed"), "error": str(health.get("error") or "healthcheck_failed"), "health": health, "tools_synced": 0, } store.set_mcp_server_health(server_id=sid, status="error", detail=health) return item tools_res = rt.tools_list() if not bool(tools_res.get("ok")): item = { "server_id": sid, "ok": False, "error_code": str(tools_res.get("error_code") or "mcp_tools_list_invalid"), "error": str(tools_res.get("error") or "tools_list_failed"), "health": health, "tools_synced": 0, } store.set_mcp_server_health(server_id=sid, status="error", detail=tools_res) return item tools = tools_res.get("tools") if isinstance(tools_res.get("tools"), list) else [] store.replace_mcp_server_tools(server_id=sid, tools=tools if isinstance(tools, list) else []) store.set_mcp_server_health(server_id=sid, status="ok", detail={"synced_tools": len(tools)}) return {"server_id": sid, "ok": True, "health": health, "tools_synced": len(tools)} finally: rt.stop() 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_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 _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", "image") if x in set(base)] return preferred + [x for x in base if x not in set(preferred)] def _ordered_mcp_roles() -> list[str]: specs = _ordered_specialists() return ["manager", *[x for x in specs if x != "manager"]] def _ordered_roles() -> list[str]: """Canonical role list used by Admin preview APIs.""" specs = _ordered_specialists() return ["manager", *[x for x in specs if x != "manager"]] _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")), "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 = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from oclaw.runtime.tools.expert_registry import clear_expert_tool_cache from oclaw.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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from oclaw.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 = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from oclaw.platform.llm.tool_wire_policy import load_role_mode_for_role, load_tool_policies_dict_for_role from oclaw.runtime.tools.exposure_plan import build_internal_tool_specs, build_llm_tools_plan roles = _ordered_roles() items: list[dict[str, Any]] = [] total_perm_ban = 0 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, ) policies = load_tool_policies_dict_for_role(store, role=role) perm_ban = len([k for k, v in policies.items() if str(k).startswith("mcp__") and int(v) == 9999]) total_perm_ban += perm_ban total_wired += len(llm_plan.tools_wired) total_internal += len(internal_specs) items.append( { "role": role, "role_mode": load_role_mode_for_role(store, role=role), "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), "policy_perm_ban_9999": perm_ban, "mcp_enabled": bool(llm_plan.mcp_enabled), "wire_policy_effective": bool(llm_plan.wire_policy_effective), } ) return { "ok": True, "roles_total": len(roles), "roles": roles, "summary": { "total_internal_tools": total_internal, "total_wired_tools": total_wired, "total_perm_ban_9999": total_perm_ban, }, "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 + wire policy).""" store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from oclaw.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, "wire_policy_effective": plan.wire_policy_effective, "policy_keys": plan.policy_keys, "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(SqliteStore(db_path()), 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(SqliteStore(db_path()), 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(SqliteStore(db_path()), 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(SqliteStore(db_path()), 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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=SqliteStore(db_path())) 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 = SqliteStore(db_path()) 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(SqliteStore(db_path()), 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(SqliteStore(db_path()), 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(SqliteStore(db_path()), 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.get("/admin/api/tenants") def api_tenants( scope: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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/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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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}} return {"ok": True, "from_db": True, "policy": row} @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 = SqliteStore(db_path()) 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)) store.upsert_user_workspace_path_allowlist( tenant_id=tid, user_id=uid, extra_roots=norm, allow_any_path=allow_any, ) 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, "extra_roots_preview": norm[:500]}, ) row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid) return {"ok": True, "policy": row or {}} @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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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/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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") # Oclaw takeover: legacy tool-policy switches are disconnected (kept in DB for later). 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), 30)) if tmr_raw.isdigit() else 8 turn_max_context_messages = max(10, min(int(tmc_raw), 400)) if tmc_raw.isdigit() else 80 # Oclaw takeover: legacy runner switches are disconnected (kept in DB for later). turn_runner_impl = "oclaw" # Oclaw takeover: legacy manager decision mode is disconnected (kept in DB for later). 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 200_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") epl_raw = str(store.get_setting("AIA_ENABLE_PLUGIN_TOOLS") or "").strip().lower() enable_plugin_tools = epl_raw in ("1", "true", "yes", "on") erc_raw = str(store.get_setting("AIA_ENABLE_RUN_COMMAND") or "").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 = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") # Oclaw takeover: legacy tool-policy switches are disconnected (kept in DB for later). 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", 8) 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), 30)) except Exception: tmr_n = 8 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", 200000) 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 = 200_000 emcp = bool(payload.get("enable_mcp_tools", True)) epl = bool(payload.get("enable_plugin_tools", False)) 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. # Legacy-only (disconnected): do not write the following settings: # - AIA_DISABLE_TOOL_CONFIRM # - AIA_TOOL_ENFORCED_RETRY_MODE # - AIA_TOOL_LOOP_STATE_MACHINE # - AIA_TOOL_SIGNATURE_BUDGET 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)) # Legacy-only (disconnected): do not write AIA_MANAGER_DECISION_MODE here. 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") 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 = SqliteStore(db_path()) 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) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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/tool-wire") def api_mcp_tool_wire_get( role: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") from oclaw.platform.llm.tool_wire_policy import build_tool_wire_snapshot return build_tool_wire_snapshot(store, role=str(role or "").strip().lower() or None) @router.post("/admin/api/mcp/tool-wire/config") def api_mcp_tool_wire_config_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from oclaw.platform.llm.tool_wire_policy import SETTINGS_KEY_ADMIN_CONFIG, load_merged_admin_config payload = payload or {} store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") raw = store.get_setting(SETTINGS_KEY_ADMIN_CONFIG) cur: dict[str, Any] = {} if raw: try: cur = json.loads(raw) if isinstance(raw, str) else {} except Exception: cur = {} if not isinstance(cur, dict): cur = {} if "wire_policy" in payload: wp = str(payload.get("wire_policy") or "").strip().lower() if wp in ("inherit", "always", "never"): cur["wire_policy"] = wp if "top_n_full" in payload: cur["top_n_full"] = max(3, min(80, int(payload.get("top_n_full") or 20))) if "stale_hours" in payload: cur["stale_hours"] = max(0.25, min(720.0, float(payload.get("stale_hours") or 3))) if "penalty_minutes" in payload: cur["penalty_minutes"] = max(1.0, min(24 * 60, float(payload.get("penalty_minutes") or 30))) if "medium_rank_start" in payload: cur["medium_rank_start"] = int(payload.get("medium_rank_start") or 21) if "medium_rank_end" in payload: cur["medium_rank_end"] = int(payload.get("medium_rank_end") or 50) if "medium_desc_chars" in payload: cur["medium_desc_chars"] = max(80, min(4000, int(payload.get("medium_desc_chars") or 520))) if "minimal_desc_cap" in payload: cur["minimal_desc_cap"] = max(0, min(2000, int(payload.get("minimal_desc_cap") or 80))) if "penalty_disable" in payload: cur["penalty_disable"] = bool(payload.get("penalty_disable")) store.set_setting(SETTINGS_KEY_ADMIN_CONFIG, json.dumps(cur, ensure_ascii=False)) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_tool_wire_config_update", target_type="app_setting", target_id=SETTINGS_KEY_ADMIN_CONFIG, status="ok", detail={"keys": list(cur.keys())}, ) return {"ok": True, "config": load_merged_admin_config(store)} @router.post("/admin/api/mcp/tool-wire/role-mode") def api_mcp_tool_wire_role_mode_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from oclaw.platform.llm.tool_wire_policy import SETTINGS_KEY_ROLE_MODE_BY_ROLE payload = payload or {} store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") role = str(payload.get("role") or "").strip().lower() if not role: raise HTTPException(status_code=400, detail="role_required") valid_roles = set(_ordered_mcp_roles()) if role not in valid_roles: raise HTTPException(status_code=400, detail="invalid_role") mode = str(payload.get("mode") or "").strip().lower() if mode not in {"restricted", "unrestricted", "forbidden"}: raise HTTPException(status_code=400, detail="invalid_mode") raw = str(store.get_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE) or "").strip() or "{}" try: obj = json.loads(raw) except Exception: obj = {} if not isinstance(obj, dict): obj = {} obj[role] = mode store.set_setting(SETTINGS_KEY_ROLE_MODE_BY_ROLE, json.dumps(obj, ensure_ascii=False)) store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_tool_wire_role_mode_update", target_type="app_setting", target_id=f"{SETTINGS_KEY_ROLE_MODE_BY_ROLE}:{role}", status="ok", detail={"role": role, "mode": mode}, ) return {"ok": True, "role": role, "mode": mode} @router.post("/admin/api/mcp/tool-wire/penalty/reset") def api_mcp_tool_wire_penalty_reset( role: str | None = Query(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from oclaw.platform.llm.tool_wire_policy import SETTINGS_KEY_PENALTY_STATE, SETTINGS_KEY_PENALTY_STATE_BY_ROLE store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") r = str(role or "").strip().lower() if not r: store.set_setting(SETTINGS_KEY_PENALTY_STATE, "{}") target_id = SETTINGS_KEY_PENALTY_STATE else: # Reset only one role's penalty bucket. raw = str(store.get_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE) or "").strip() or "{}" try: obj = json.loads(raw) except Exception: obj = {} if not isinstance(obj, dict): obj = {} obj[r] = {} store.set_setting(SETTINGS_KEY_PENALTY_STATE_BY_ROLE, json.dumps(obj, ensure_ascii=False)) target_id = f"{SETTINGS_KEY_PENALTY_STATE_BY_ROLE}:{r}" store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_tool_wire_penalty_reset", target_type="app_setting", target_id=target_id, status="ok", detail={"reset": True, "role": r}, ) return {"ok": True, "penalty_state": {}, "role": r} @router.post("/admin/api/mcp/tool-wire/policies") def api_mcp_tool_wire_policies_save( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from oclaw.platform.llm.tool_wire_policy import ( SETTINGS_KEY_TOOL_POLICIES, SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, load_tool_policies_dict_for_role, ) payload = payload or {} store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") role = str(payload.get("role") or "").strip().lower() pol_in = payload.get("policies") if not isinstance(pol_in, dict): raise HTTPException(status_code=400, detail="policies must be an object") merged = dict(load_tool_policies_dict_for_role(store, role=role or None)) def _coerce_lv(v: Any) -> int | None: try: n = int(v) except (TypeError, ValueError): return None if n == 9999: return 9999 if n <= 0: return 0 return min(n, 9998) clears = payload.get("clears") if isinstance(clears, list): for w in clears: wn = str(w or "").strip() if wn.startswith("mcp__"): merged.pop(wn, None) for k, v in pol_in.items(): wn = str(k or "").strip() if not wn.startswith("mcp__"): continue co = _coerce_lv(v) if co is None: continue merged[wn] = co if not role: store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False)) target_id = SETTINGS_KEY_TOOL_POLICIES else: raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}" try: outer = json.loads(raw) except Exception: outer = {} if not isinstance(outer, dict): outer = {} outer[role] = merged store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False)) target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}" store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_tool_wire_policies_update", target_type="app_setting", target_id=target_id, status="ok", detail={"count": len(merged), "role": role}, ) return {"ok": True, "policies": merged, "role": role} @router.post("/admin/api/mcp/tool-wire/policies/batch") def api_mcp_tool_wire_policies_batch( payload: dict[str, Any] | None = Body(default=None), authorization: str | None = Header(default=None), ) -> dict[str, Any]: from oclaw.platform.llm.tool_wire_policy import ( SETTINGS_KEY_TOOL_POLICIES, SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, load_tool_policies_dict_for_role, ) payload = payload or {} store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") try: lv = int(payload.get("level")) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="level must be int") if lv != 9999 and (lv < 0 or lv > 9998): raise HTTPException(status_code=400, detail="invalid level") names = payload.get("wire_names") if not isinstance(names, list) or not names: raise HTTPException(status_code=400, detail="wire_names must be non-empty array") role = str(payload.get("role") or "").strip().lower() if role: valid_roles = set(_ordered_mcp_roles()) if role not in valid_roles: raise HTTPException(status_code=400, detail="invalid_role") merged = dict(load_tool_policies_dict_for_role(store, role=role or None)) for wn in names: s = str(wn or "").strip() if not s.startswith("mcp__"): continue if lv == 9999: merged[s] = 9999 elif lv <= 0: merged[s] = 0 else: merged[s] = min(lv, 9998) if not role: store.set_setting(SETTINGS_KEY_TOOL_POLICIES, json.dumps(merged, ensure_ascii=False)) target_id = SETTINGS_KEY_TOOL_POLICIES else: raw = str(store.get_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE) or "").strip() or "{}" try: outer = json.loads(raw) except Exception: outer = {} if not isinstance(outer, dict): outer = {} outer[role] = merged store.set_setting(SETTINGS_KEY_TOOL_POLICIES_BY_ROLE, json.dumps(outer, ensure_ascii=False)) target_id = f"{SETTINGS_KEY_TOOL_POLICIES_BY_ROLE}:{role}" store.add_admin_audit_log( actor_tenant_id=ctx["tenant_id"], actor_user_id=ctx["user_id"], action="mcp_tool_wire_policies_batch", target_type="app_setting", target_id=target_id, status="ok", detail={"level": lv, "n": len(names), "role": role}, ) return {"ok": True, "policies": merged, "role": role} @router.get("/admin/api/mcp/market/search") def api_mcp_market_search( q: str = Query(default=""), per_source_limit: int = Query(default=6), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") query = str(q or "").strip() if not query: return {"ok": True, "items": []} items = search_mcp_market(query, per_source_limit=per_source_limit) return {"ok": True, "items": items} @router.get("/admin/api/mcp/market/trending") def api_mcp_market_trending( per_source_limit: int = Query(default=5), refresh: int = Query(default=0), authorization: str | None = Header(default=None), ) -> dict[str, Any]: store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") items = trending_mcp_market(force_refresh=bool(int(refresh or 0)), per_source_limit=per_source_limit) return {"ok": True, "items": items} @router.get("/admin/api/mcp/dependencies") def api_mcp_dependencies(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") return {"ok": True, "items": detect_local_dependencies()} @router.get("/admin/api/mcp/binding") def api_mcp_binding(authorization: str | None = Header(default=None)) -> dict[str, Any]: store = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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)) 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 = SqliteStore(db_path()) 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,manager" 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 = SqliteStore(db_path()) 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", "manager") 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:tenant:write") expert_id = normalize_expert_id(payload.get("id")) 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) 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 = SqliteStore(db_path()) 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) try: files = _sanitize_expert_files_payload(files_raw) update_expert_files(expert_id=eid, files=files) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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"} or not source_ref: return {"ok": False, "error": "invalid_source"} server_id = _safe_server_id(str(payload.get("server_id") or source_ref)) manifest = McpServerManifest( server_id=server_id, 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), ) 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 {})}, 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": source_type, "source_ref": source_ref, "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/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 = SqliteStore(db_path()) 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/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 = SqliteStore(db_path()) 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)) 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 = SqliteStore(db_path()) 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)) 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 = SqliteStore(db_path()) 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"} 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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"} rt = McpProcessRuntime( build_mcp_process_command(cmd, args, store=store), timeout_s=float(row.get("timeout_s") or 30.0), ) 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 = SqliteStore(db_path()) 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"} rt = McpProcessRuntime( build_mcp_process_command(cmd, args, store=store), timeout_s=float(row.get("timeout_s") or 30.0), ) 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)}) return {"ok": True, "server_id": server_id, "tools": norm} 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 = SqliteStore(db_path()) 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/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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 oclaw.platform.embeddings.embedding_client import build_default_embedding_client from oclaw.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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 not in {"sqlite", "chroma", "qdrant"}: 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) _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 = SqliteStore(db_path()) _ensure_admin_bootstrap(store) tenant_id = str(payload.get("tenant_id") or "").strip() if not tenant_id: tenants = store.list_tenants(limit=1) if tenants: tenant_id = str((tenants[0] or {}).get("id") or "").strip() 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() 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 = SqliteStore(db_path()) 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 = SqliteStore(db_path()) token = _extract_bearer(authorization) if token: store.revoke_auth_session(session_token_hash=_sha256_hex(token)) return {"ok": True} @router.get("/admin/api/admin-audit") def api_admin_audit( limit: int = Query(default=200), 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 = SqliteStore(db_path()) ctx = _resolve_auth(store, authorization) _require_permission(ctx, "admin:user:write") rows = store.list_admin_audit_logs(tenant_id=str(ctx.get("tenant_id") or ""), limit=limit) a = str(action or "").strip() actor = str(actor_user_id or "").strip() st = str(status or "").strip() if a: rows = [r for r in rows if str(r.get("action") or "") == a] if actor: rows = [r for r in rows if str(r.get("actor_user_id") or "") == actor] if st: rows = [r for r in rows if str(r.get("status") or "") == st] return {"ok": True, "items": rows} from oclaw.interfaces.admin.chat_api import include_chat_routes from oclaw.interfaces.admin.models_api import include_model_mgmt_routes from oclaw.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"]