oclaw/interfaces/admin/routes.py
oliver dbbe3add6a 重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
2026-04-26 08:34:33 +08:00

3513 lines
158 KiB
Python

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 / "oclaw" / "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 / "oclaw" / "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 / "oclaw" / "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"]