"""FastAPI web server for the Boardware Genius frontend.""" from __future__ import annotations import asyncio import ipaddress import json import os import re import secrets import shlex import shutil import time import uuid import zipfile from pathlib import Path from typing import TYPE_CHECKING, Any from urllib.parse import urlsplit, urlunsplit import httpx from fastapi import ( BackgroundTasks, FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect, ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from loguru import logger from pydantic import BaseModel, Field from nanobot.bus.queue import MessageBus from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import Config from nanobot.cron.runtime import run_cron_job from nanobot.cron.service import CronService from nanobot.cron.types import CronExecutionResult, CronJob, CronSchedule from nanobot.providers.registry import PROVIDERS from nanobot.session.manager import Session, SessionManager from nanobot.utils.helpers import get_cron_store_path, parse_session_key if TYPE_CHECKING: from nanobot.channels.web import WebChannel def _has_backend_identity(config: Config) -> bool: return bool( config.backend_identity.backend_id and config.backend_identity.client_id and config.backend_identity.client_secret ) def _frontend_port() -> int: raw = os.getenv("NANOBOT_FRONTEND_PORT", "3080").strip() try: return int(raw) except ValueError: return 3080 def _frontend_public_base_url() -> str: return os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL", "").strip().rstrip("/") def _uses_managed_outlook_mcp(config: Config) -> bool: return bool( getattr(config, "authz", None) and config.authz.enabled and config.authz.base_url.strip() and config.authz.outlook_mcp_url.strip() ) def _mcp_server_snapshot(server_cfg: Any | None) -> dict[str, Any] | None: if server_cfg is None: return None if hasattr(server_cfg, "model_dump"): return server_cfg.model_dump(mode="json") return { "command": getattr(server_cfg, "command", ""), "args": list(getattr(server_cfg, "args", []) or []), "env": dict(getattr(server_cfg, "env", {}) or {}), "url": getattr(server_cfg, "url", ""), "headers": dict(getattr(server_cfg, "headers", {}) or {}), "auth_mode": getattr(server_cfg, "auth_mode", ""), "auth_audience": getattr(server_cfg, "auth_audience", ""), "auth_scopes": list(getattr(server_cfg, "auth_scopes", []) or []), "tool_timeout": int(getattr(server_cfg, "tool_timeout", 30)), "sensitive": bool(getattr(server_cfg, "sensitive", False)), } async def _reconcile_managed_outlook_mcp(config: Config) -> bool: if not (_uses_managed_outlook_mcp(config) and _has_backend_identity(config)): return False from nanobot.web.outlook import ( OUTLOOK_SERVER_ID, ensure_outlook_authz_permissions, ensure_outlook_mcp_registration, ) before = _mcp_server_snapshot(config.tools.mcp_servers.get(OUTLOOK_SERVER_ID)) ensure_outlook_mcp_registration(config) await ensure_outlook_authz_permissions(config) after = _mcp_server_snapshot(config.tools.mcp_servers.get(OUTLOOK_SERVER_ID)) return before != after def _terminate_process_after_delay(delay_seconds: float = 1.0, exit_code: int = 1) -> None: if delay_seconds > 0: time.sleep(delay_seconds) logger.warning("Self-restart requested; exiting backend process with code {}", exit_code) os._exit(exit_code) # ============================================================================ # Request/Response models # ============================================================================ class ChatRequest(BaseModel): message: str session_id: str = "web:default" attachments: list[dict[str, str]] | None = None class ChatResponse(BaseModel): response: str session_id: str class AddCronJobRequest(BaseModel): # 任务展示名。 name: str # 提醒文案或 task prompt。 message: str # `reminder` 直接发消息,`task` 重新进入 agent 执行。 mode: str | None = None # task 模式可选复用的原会话 key。 session_key: str | None = None every_seconds: int | None = None cron_expr: str | None = None at_iso: str | None = None deliver: bool = False channel: str | None = None to: str | None = None class ToggleCronJobRequest(BaseModel): enabled: bool class AddMarketplaceRequest(BaseModel): source: str class ApproveSkillReviewRequest(BaseModel): overwrite: bool = False class AddAgentRequest(BaseModel): # 可选稳定 ID;若未提供,后端会尝试从 A2A card 推导。 id: str | None = None name: str | None = None description: str | None = None protocol: str = "a2a" base_url: str | None = None endpoint: str | None = None card_url: str | None = None auth_env: str | None = None auth_mode: str = "none" auth_audience: str | None = None auth_scopes: list[str] = Field(default_factory=list) enabled: bool = True tags: list[str] = Field(default_factory=list) aliases: list[str] = Field(default_factory=list) metadata: dict[str, Any] | None = None _AGENT_CARD_PATHS = ( "/.well-known/agent-card", "/.well-known/agent-card.json", "/.well-known/agent.json", ) _AGENT_ID_SANITIZE_RE = re.compile(r"[^a-z0-9]+") def _first_text(*values: Any) -> str | None: for value in values: text = str(value or "").strip() if text: return text return None def _dedupe_texts(*groups: Any) -> list[str]: result: list[str] = [] seen: set[str] = set() for group in groups: if not isinstance(group, list): continue for item in group: text = str(item or "").strip() if not text: continue key = text.lower() if key in seen: continue seen.add(key) result.append(text) return result def _is_localish_host(host: str) -> bool: probe = host.strip().strip("[]").lower() if not probe: return False if probe in {"localhost", "127.0.0.1", "0.0.0.0", "::1", "::"} or probe.endswith(".local"): return True try: ip = ipaddress.ip_address(probe) except ValueError: return False return bool(ip.is_private or ip.is_loopback or ip.is_unspecified or ip.is_link_local) def _normalize_probe_urls(raw_value: str) -> list[str]: value = raw_value.strip() if not value: return [] raw_candidates: list[str] = [] if "://" in value: raw_candidates.append(value) else: host = urlsplit(f"//{value}").hostname or "" schemes = ["http", "https"] if _is_localish_host(host) else ["https", "http"] raw_candidates.extend(f"{scheme}://{value}" for scheme in schemes) result: list[str] = [] seen: set[str] = set() for candidate in raw_candidates: parsed = urlsplit(candidate) normalized = urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")).rstrip("/") if not normalized: continue variants = [normalized] origin = urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/") if origin and origin.lower() != normalized.lower(): variants.append(origin) for variant in variants: key = variant.lower() if key in seen: continue seen.add(key) result.append(variant) return result def _looks_like_agent_card_url(url: str) -> bool: path = urlsplit(url).path.rstrip("/").lower() return any(path.endswith(candidate.rstrip("/")) for candidate in _AGENT_CARD_PATHS) def _slugify_agent_id(*values: Any) -> str: for value in values: text = str(value or "").strip().lower() if not text: continue slug = _AGENT_ID_SANITIZE_RE.sub("-", text).strip("-") if slug: return slug return "a2a-agent" async def _discover_agent_payload( req: AddAgentRequest, config: Config, ) -> dict[str, Any]: from nanobot.a2a.client import A2AClient from nanobot.agent.agent_registry import AgentDescriptor probe_inputs = [req.card_url, req.endpoint, req.base_url] if not any(str(item or "").strip() for item in probe_inputs): raise ValueError("missing probe input") client = A2AClient( timeout_seconds=config.tools.a2a.timeout_seconds, card_cache_ttl_seconds=0, allowed_hosts=config.tools.a2a.allowed_hosts, ) last_error: Exception | None = None for probe_input in probe_inputs: text = str(probe_input or "").strip() if not text: continue for normalized in _normalize_probe_urls(text): descriptor = AgentDescriptor( id=_slugify_agent_id(req.id, req.name, normalized, "a2a-agent"), name=_first_text(req.name, req.id, "A2A Agent") or "A2A Agent", description=_first_text(req.description, req.name, req.id, "A2A Agent") or "A2A Agent", source="workspace", kind="a2a_remote", protocol="a2a", base_url=None if _looks_like_agent_card_url(normalized) else normalized, endpoint=None if _looks_like_agent_card_url(normalized) else normalized, card_url=normalized if _looks_like_agent_card_url(normalized) else None, auth_env=req.auth_env, auth_mode=(req.auth_mode or "none").strip().lower() or "none", auth_audience=req.auth_audience, auth_scopes=list(req.auth_scopes), ) try: discovered_card_url, card = await client.fetch_agent_card_with_url(descriptor) except Exception as exc: last_error = exc continue primary_url = _first_text( client._resolve_primary_url(card, descriptor), descriptor.endpoint, descriptor.base_url, ) agent_id = _slugify_agent_id( req.id, card.get("id"), card.get("name"), primary_url, discovered_card_url, ) name = _first_text(req.name, card.get("name"), req.id, agent_id) or agent_id description = _first_text(req.description, card.get("description"), name) or name auth_mode = _first_text( req.auth_mode if req.auth_mode != "none" else None, card.get("auth_mode"), "none", ) or "none" return { "id": agent_id, "name": name, "description": description, "protocol": "a2a", "base_url": _first_text(descriptor.base_url, primary_url), "endpoint": _first_text(primary_url, descriptor.endpoint, descriptor.base_url), "card_url": _first_text(discovered_card_url, req.card_url), "auth_env": _first_text(req.auth_env, card.get("auth_env")), "auth_mode": auth_mode.strip().lower() or "none", "auth_audience": _first_text(req.auth_audience, card.get("auth_audience")), "auth_scopes": _dedupe_texts(req.auth_scopes, card.get("auth_scopes")), "enabled": req.enabled, "tags": _dedupe_texts(req.tags, card.get("tags")), "aliases": _dedupe_texts(req.aliases, card.get("aliases")), "capabilities": card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {}, "support_streaming": client._supports_streaming(card), "metadata": dict(req.metadata or {}), } if last_error: raise last_error raise ValueError("agent card discovery failed") def _manual_agent_payload(req: AddAgentRequest) -> dict[str, Any]: agent_id = _first_text(req.id) if not agent_id: raise HTTPException(status_code=400, detail="缺少智能体 ID,且无法从 A2A card 自动发现") name = _first_text(req.name, agent_id) or agent_id return { "id": agent_id, "name": name, "description": _first_text(req.description, req.name, agent_id) or name, "protocol": req.protocol, "base_url": req.base_url, "endpoint": req.endpoint, "card_url": req.card_url, "auth_env": req.auth_env, "auth_mode": (req.auth_mode or "none").strip().lower() or "none", "auth_audience": req.auth_audience, "auth_scopes": _dedupe_texts(req.auth_scopes), "enabled": req.enabled, "tags": _dedupe_texts(req.tags), "aliases": _dedupe_texts(req.aliases), "metadata": dict(req.metadata or {}), } def _should_auto_discover_agent(req: AddAgentRequest) -> bool: has_probe = any(str(value or "").strip() for value in (req.base_url, req.endpoint, req.card_url)) is_complete_manual_entry = bool( _first_text(req.id) and _first_text(req.name) and _first_text(req.description) and (_first_text(req.endpoint) or _first_text(req.card_url)) ) return has_probe and not is_complete_manual_entry class MCPServerRequest(BaseModel): # MCP server 的稳定配置 ID。 id: str command: str = "" args: list[str] = Field(default_factory=list) env: dict[str, str] = Field(default_factory=dict) url: str = "" headers: dict[str, str] = Field(default_factory=dict) auth_mode: str = "none" auth_audience: str = "" auth_scopes: list[str] = Field(default_factory=list) tool_timeout: int = 30 sensitive: bool = False class SubagentRequest(BaseModel): id: str name: str | None = None description: str | None = None system_prompt: str = "" model: str | None = None enabled: bool = True delegation_mode: str = "remote_a2a_only" allow_mcp: bool = True tags: list[str] = Field(default_factory=list) aliases: list[str] = Field(default_factory=list) mcp_servers: dict[str, dict[str, Any]] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict) class OutlookConnectionRequest(BaseModel): email: str password: str username: str | None = None domain: str | None = None service_endpoint: str | None = None server: str | None = None autodiscover: bool = False default_timezone: str = "Asia/Shanghai" class LoginRequest(BaseModel): username: str password: str class RegisterRequest(BaseModel): username: str email: str | None = None password: str authz_base_url: str | None = None backend_name: str | None = None backend_id: str | None = None base_url: str | None = None frontend_base_url: str | None = None class AuthzRegisterBackendRequest(BaseModel): name: str | None = None backend_id: str | None = None base_url: str | None = None frontend_base_url: str | None = None save_to_backend: bool = True authz_base_url: str | None = None class LocalBackendIdentityRequest(BaseModel): backend_id: str client_id: str client_secret: str name: str | None = None public_base_url: str | None = None authz_base_url: str | None = None authz_enabled: bool = True class HandoffConsumeRequest(BaseModel): code: str class WebSocketBroadcaster: """Track authenticated websocket connections and broadcast JSON events.""" def __init__(self) -> None: self._connections: dict[int, tuple[WebSocket, asyncio.Lock]] = {} self._lock = asyncio.Lock() async def register(self, websocket: WebSocket, send_lock: asyncio.Lock) -> None: async with self._lock: self._connections[id(websocket)] = (websocket, send_lock) async def unregister(self, websocket: WebSocket) -> None: async with self._lock: self._connections.pop(id(websocket), None) async def broadcast(self, payload: dict[str, Any]) -> None: async with self._lock: targets = list(self._connections.items()) stale: list[int] = [] for key, (websocket, send_lock) in targets: try: async with send_lock: await websocket.send_text(json.dumps(payload)) except Exception: stale.append(key) if stale: async with self._lock: for key in stale: self._connections.pop(key, None) def _resolve_cron_session_key(job: CronJob) -> str: """Mirror cron runtime session resolution for web-side notifications.""" if job.payload.session_key: return job.payload.session_key if job.payload.channel and job.payload.to: return f"{job.payload.channel}:{job.payload.to}" return f"cron:{job.id}" def _infer_cron_route_from_session_key(session_key: str | None) -> tuple[str | None, str | None]: """Best-effort route inference so cron jobs can target the correct web chat.""" normalized = (session_key or "").strip() if not normalized: return None, None try: channel, chat_id = parse_session_key(normalized) except ValueError: return None, None return channel, chat_id def _record_cron_result_for_web_session( *, session_manager: SessionManager, job: CronJob, result: CronExecutionResult, ) -> str | None: """Persist standalone web cron output so the frontend can surface it.""" target_session_key = _resolve_cron_session_key(job) if not target_session_key.startswith("web:"): return None # agent_turn jobs already write their own history via AgentLoop.process_direct(). if job.payload.kind == "agent_turn": return target_session_key # reminder/system_event jobs bypass the agent loop, so standalone web mode # must append the final message into the target session explicitly. if job.payload.kind != "system_event" or not job.payload.deliver or not result.response: return None session = session_manager.get_or_create(target_session_key) session.add_message( "assistant", result.response, metadata={ "source": "cron", "job_id": job.id, "job_name": job.name, }, ) session_manager.save(session) return target_session_key # ============================================================================ # App factory # ============================================================================ def create_app( *, bus: MessageBus | None = None, web_channel: "WebChannel | None" = None, session_manager: SessionManager | None = None, config: Config | None = None, cron_service: CronService | None = None, ) -> FastAPI: """Create and configure the FastAPI application. Two modes: - **Gateway mode** (bus + web_channel provided): messages go through the MessageBus; the WebChannel's ``_handle_message`` publishes inbound messages and the AgentLoop processes them asynchronously. - **Standalone mode** (no bus): creates its own AgentLoop and uses ``process_direct()`` for synchronous request-response (legacy). """ if config is None: config = load_config() app = FastAPI(title="nanobot", version="0.1.0") websocket_broadcaster = WebSocketBroadcaster() # CORS for frontend dev server app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Standalone fallback: create an isolated AgentLoop when no bus provided if bus is None: from nanobot.agent.loop import AgentLoop bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) cron_store_path = get_cron_store_path(config.workspace_path) cron_service = CronService(cron_store_path) agent = AgentLoop( bus=bus, provider=provider, workspace=config.workspace_path, model=config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, a2a_config=config.tools.a2a, cron_service=cron_service, restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, mcp_servers=config.tools.mcp_servers, authz_config=config.authz, backend_identity=config.backend_identity, gateway_port=config.gateway.port, ) async def _handle_direct_delegation_announcement( content: str, origin: dict[str, str], sender_id: str, notify_session_update: bool, ) -> None: origin_channel = str(origin.get("channel") or "cli").strip() or "cli" origin_chat_id = str(origin.get("chat_id") or "direct").strip() or "direct" await agent.process_system_announcement( content, origin_channel=origin_channel, origin_chat_id=origin_chat_id, sender_id=sender_id, ) if notify_session_update and origin_channel == "web": await websocket_broadcaster.broadcast({ "type": "session_updated", "session_id": f"{origin_channel}:{origin_chat_id}", "source": "delegation", }) agent.delegation.set_direct_announcement_callback(_handle_direct_delegation_announcement) # Single-user mode: cron jobs execute via the same in-process agent. async def on_cron_job(job: CronJob) -> CronExecutionResult: result = await run_cron_job( job, agent=agent, bus=bus, default_channel="web", default_chat_id="default", ) target_session_key = _record_cron_result_for_web_session( session_manager=session_manager, job=job, result=result, ) if target_session_key: await websocket_broadcaster.broadcast({ "type": "session_updated", "session_id": target_session_key, "source": "cron", "job_id": job.id, "job_name": job.name, }) return result cron_service.on_job = on_cron_job @app.on_event("startup") async def _startup() -> None: should_reload_mcp = False try: if _uses_managed_outlook_mcp(app.state.config) and _has_backend_identity(app.state.config): config_changed = await _reconcile_managed_outlook_mcp(app.state.config) if config_changed: save_config(app.state.config, app.state.config_path) should_reload_mcp = True except Exception as exc: logger.warning("Managed Outlook MCP startup reconciliation failed: {}", exc) if should_reload_mcp: try: await agent.reload_mcp_servers(app.state.config.tools.mcp_servers) except Exception as exc: logger.warning("Managed Outlook MCP reload failed during startup: {}", exc) await cron_service.start() @app.on_event("shutdown") async def _shutdown() -> None: cron_service.stop() agent.stop() await agent.close_mcp() app.state.agent = agent else: app.state.agent = None # gateway mode – no standalone agent if session_manager is None: session_manager = SessionManager(config.workspace_path) if cron_service is None: cron_store_path = get_cron_store_path(config.workspace_path) cron_service = CronService(cron_store_path) app.state.config = config app.state.config_path = get_config_path() app.state.runtime_env_path = _get_runtime_env_file_path(app.state.config_path) _sync_authz_runtime_env(app.state.config, app.state.runtime_env_path) app.state.session_manager = session_manager app.state.cron_service = cron_service app.state.bus = bus app.state.web_channel = web_channel # may be None in standalone app.state.websocket_broadcaster = websocket_broadcaster app.state.auth_tokens: dict[str, str] = {} app.state.handoff_codes: dict[str, dict[str, Any]] = {} app.state.auth_file = _get_auth_file_path() app.state.subagent_tasks: dict[str, dict[str, Any]] = {} _register_routes(app) return app def _make_provider(config: Config): """Create LLM provider from config.""" from nanobot.providers.custom_provider import CustomProvider from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) p = config.get_provider(model) if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider( default_model=model, request_timeout_seconds=p.request_timeout_seconds if p else 600, ) if provider_name == "custom": return CustomProvider( api_key=p.api_key if p else "no-key", api_base=config.get_api_base(model) or "http://localhost:8000/v1", default_model=model, request_timeout_seconds=p.request_timeout_seconds if p else 600, ) if not (p and p.api_key) and not model.startswith("bedrock/"): raise RuntimeError("No API key configured. Set one in ~/.nanobot/config.json") return LiteLLMProvider( api_key=p.api_key if p else None, api_base=config.get_api_base(model), default_model=model, extra_headers=p.extra_headers if p else None, provider_name=provider_name, request_timeout_seconds=p.request_timeout_seconds if p else 600, ) # ============================================================================ # Routes # ============================================================================ def _with_attachment_hints(content: str, media_paths: list[str]) -> str: """Append local attachment paths so the agent can open them via file tools.""" if not media_paths: return content hints = "\n".join(f"- {p}" for p in media_paths) return f"{content}\n\n[Attached files]\n{hints}" def _resolve_attachment_paths( workspace: Path, attachments: list[dict[str, str]] | None, ) -> list[str]: """Resolve uploaded attachment ids to local file paths.""" if not attachments: return [] from nanobot.web.files import get_file_path media_paths: list[str] = [] for attachment in attachments: # 前端上传接口约定附件通过 `file_id` 引用本地已缓存文件。 file_id = attachment.get("file_id", "") if not file_id: continue file_path = get_file_path(workspace, file_id) if file_path: media_paths.append(str(file_path)) return media_paths def _get_auth_file_path() -> Path: """Resolve local auth file path for web login.""" env = os.getenv("NANOBOT_AUTH_FILE", "").strip() if env: return Path(env).expanduser() # Default to project root: /web_auth_users.json return Path(__file__).resolve().parents[2] / "web_auth_users.json" _AUTHZ_RUNTIME_ENV_KEYS = ( "NANOBOT_AUTHZ__ENABLED", "NANOBOT_AUTHZ__BASE_URL", "NANOBOT_AUTHZ__OUTLOOK_MCP_URL", "NANOBOT_BACKEND_IDENTITY__BACKEND_ID", "NANOBOT_BACKEND_IDENTITY__CLIENT_ID", "NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET", "NANOBOT_BACKEND_IDENTITY__NAME", "NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL", ) def _get_runtime_env_file_path(config_path: Path | None = None) -> Path: env = os.getenv("NANOBOT_RUNTIME_ENV_FILE", "").strip() if env: return Path(env).expanduser() base_path = config_path or get_config_path() return base_path.parent / "runtime.env" def _authz_runtime_env_values(config: Config) -> dict[str, str]: return { "NANOBOT_AUTHZ__ENABLED": "1" if config.authz.enabled and config.authz.base_url.strip() else "0", "NANOBOT_AUTHZ__BASE_URL": config.authz.base_url.strip(), "NANOBOT_AUTHZ__OUTLOOK_MCP_URL": config.authz.outlook_mcp_url.strip(), "NANOBOT_BACKEND_IDENTITY__BACKEND_ID": config.backend_identity.backend_id.strip(), "NANOBOT_BACKEND_IDENTITY__CLIENT_ID": config.backend_identity.client_id.strip(), "NANOBOT_BACKEND_IDENTITY__CLIENT_SECRET": config.backend_identity.client_secret.strip(), "NANOBOT_BACKEND_IDENTITY__NAME": config.backend_identity.name.strip(), "NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL": config.backend_identity.public_base_url.strip(), } def _sync_authz_runtime_env(config: Config, target_path: Path) -> None: values = _authz_runtime_env_values(config) target_path.parent.mkdir(parents=True, exist_ok=True) lines: list[str] = [] for key in _AUTHZ_RUNTIME_ENV_KEYS: value = values.get(key, "") if value: os.environ[key] = value lines.append(f"export {key}={shlex.quote(value)}") continue if key == "NANOBOT_AUTHZ__ENABLED": os.environ[key] = "0" lines.append("export NANOBOT_AUTHZ__ENABLED=0") continue os.environ.pop(key, None) lines.append(f"unset {key}") target_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _load_auth_users(path: Path) -> dict[str, str]: """Load users from local JSON file. Supported formats: 1) {"users":[{"username":"admin","password":"123456"}]} 2) {"accounts":[{"username":"admin","password":"123456"}]} 3) {"admin":"123456","alice":"pwd"} 4) [{"username":"admin","password":"123456"}] """ if not path.exists(): raise ValueError(f"Auth file not found: {path}") try: raw = json.loads(path.read_text(encoding="utf-8")) except Exception as e: raise ValueError(f"Failed to parse auth file: {e}") from e users: dict[str, str] = {} def _add_from_list(items: list[Any]) -> None: for item in items: if not isinstance(item, dict): continue username = ( item.get("username") or item.get("user") or item.get("account") ) password = item.get("password") or item.get("pass") or item.get("pwd") if isinstance(username, str) and isinstance(password, str) and username.strip(): users[username.strip()] = password if isinstance(raw, list): _add_from_list(raw) elif isinstance(raw, dict): user_list = raw.get("users") if isinstance(user_list, list): _add_from_list(user_list) account_list = raw.get("accounts") if isinstance(account_list, list): _add_from_list(account_list) for k, v in raw.items(): if k in {"users", "accounts"}: continue if isinstance(k, str) and isinstance(v, str): users[k.strip()] = v if not users: raise ValueError( "No valid users found in auth file. " "Use {'users':[{'username':'admin','password':'123456'}]} or {'admin':'123456'}" ) return users def _save_auth_users(path: Path, users: dict[str, str]) -> None: """Persist web login users in a stable JSON shape.""" path.parent.mkdir(parents=True, exist_ok=True) data = { "users": [ {"username": username, "password": password} for username, password in sorted(users.items()) ] } tmp_path = path.with_suffix(f"{path.suffix}.tmp") tmp_path.write_text( json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8", ) tmp_path.replace(path) def _issue_web_token(app: FastAPI, username: str) -> str: token = secrets.token_urlsafe(32) app.state.auth_tokens[token] = username return token def _handoff_ttl_seconds() -> int: raw = os.getenv("NANOBOT_HANDOFF_CODE_TTL_SECONDS", "90").strip() try: return max(15, int(raw)) except ValueError: return 90 def _handoff_replay_window_seconds() -> int: raw = os.getenv("NANOBOT_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip() try: return max(1, int(raw)) except ValueError: return 15 def _prune_handoff_codes(app: FastAPI) -> None: now = time.time() replay_window = _handoff_replay_window_seconds() expired: list[str] = [] for code, payload in list(app.state.handoff_codes.items()): expires_at = float(payload.get("expires_at") or 0) consumed_at = payload.get("consumed_at") if expires_at <= now: expired.append(code) continue if consumed_at is not None and (now - float(consumed_at)) > replay_window: expired.append(code) for code in expired: app.state.handoff_codes.pop(code, None) def _issue_handoff_code(app: FastAPI, username: str, access_token: str, refresh_token: str = "") -> tuple[str, int]: _prune_handoff_codes(app) code = secrets.token_urlsafe(24) expires_at = int(time.time()) + _handoff_ttl_seconds() app.state.handoff_codes[code] = { "username": username, "access_token": access_token, "refresh_token": refresh_token, "expires_at": expires_at, "consumed_at": None, } return code, expires_at def _consume_handoff_code(app: FastAPI, code: str) -> dict[str, Any]: if not code.strip(): raise HTTPException(status_code=400, detail="Handoff code is required") _prune_handoff_codes(app) payload = app.state.handoff_codes.get(code) if payload is None: raise HTTPException(status_code=401, detail="Invalid or expired handoff code") now = time.time() expires_at = float(payload.get("expires_at") or 0) if expires_at <= now: app.state.handoff_codes.pop(code, None) raise HTTPException(status_code=410, detail="Handoff code expired") consumed_at = payload.get("consumed_at") if consumed_at is None: payload["consumed_at"] = now elif now - float(consumed_at) > _handoff_replay_window_seconds(): app.state.handoff_codes.pop(code, None) raise HTTPException(status_code=410, detail="Handoff code already used") username = str(payload.get("username") or "").strip() access_token = str(payload.get("access_token") or "").strip() refresh_token = str(payload.get("refresh_token") or "") if not username or not access_token: app.state.handoff_codes.pop(code, None) raise HTTPException(status_code=401, detail="Invalid handoff payload") return { "access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer", "user_id": username, "username": username, "role": "owner", } def _require_web_user(app: FastAPI, authorization: str | None) -> str: """Validate bearer token and return username.""" if not authorization: raise HTTPException(status_code=401, detail="Missing Authorization header") prefix = "bearer " if not authorization.lower().startswith(prefix): raise HTTPException(status_code=401, detail="Invalid Authorization header") token = authorization[len(prefix):].strip() if not token: raise HTTPException(status_code=401, detail="Invalid token") username = app.state.auth_tokens.get(token) if not username: raise HTTPException(status_code=401, detail="Invalid or expired token") return username def _register_routes(app: FastAPI) -> None: """Register all API routes.""" def _get_agent_loop(): return app.state.agent def _get_agent_registry(): # 单机 standalone 模式优先复用运行中的 registry,保证与当前 agent 配置一致。 from nanobot.agent.agent_registry import AgentRegistry agent = _get_agent_loop() if agent is not None and hasattr(agent, "agent_registry"): return agent.agent_registry config: Config = app.state.config return AgentRegistry( config.workspace_path, allow_skill_cards=config.tools.a2a.allow_skill_cards, allow_workspace_agents=config.tools.a2a.allow_workspace_agents, ) def _save_app_config(config: Config) -> None: # 同时更新 app.state 和配置文件,保证后续请求读到的是新配置。 app.state.config = config save_config(config, app.state.config_path) agent = _get_agent_loop() if agent is not None and hasattr(agent, "apply_runtime_config"): agent.apply_runtime_config( authz_config=config.authz, backend_identity=config.backend_identity, ) def _jsonrpc_error(payload_id: Any, code: int, message: str) -> JSONResponse: return JSONResponse( status_code=200, content={ "jsonrpc": "2.0", "id": payload_id, "error": {"code": code, "message": message}, }, ) def _extract_subagent_task(params: dict[str, Any]) -> str: message = params.get("message") if not isinstance(message, dict): raise ValueError("Missing 'message' object") parts = message.get("parts") if isinstance(parts, list): for part in parts: if not isinstance(part, dict): continue text = str(part.get("text") or "").strip() if text: return text content = message.get("content") if isinstance(content, list): for item in content: if not isinstance(item, dict): continue text = str(item.get("text") or "").strip() if text: return text raise ValueError("A2A message does not contain text content") async def _run_subagent_task(agent_id: str, task: str) -> str: from nanobot.agent.loop import AgentLoop from nanobot.agent.subagents import LocalSubagentStore config: Config = app.state.config store = LocalSubagentStore(config.workspace_path) spec = store.get_subagent(agent_id) if spec is None or not spec.enabled: raise HTTPException(status_code=404, detail="Sub-agent not found") delegation_mode = (spec.delegation_mode or "remote_a2a_only").strip().lower() allow_spawn = delegation_mode in {"remote_a2a_only", "full"} allow_local = delegation_mode == "full" provider = _make_provider(config) loop = AgentLoop( bus=app.state.bus, provider=provider, workspace=Path(spec.workspace), model=spec.model or config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, temperature=config.agents.defaults.temperature, max_tokens=config.agents.defaults.max_tokens, memory_window=config.agents.defaults.memory_window, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, a2a_config=config.tools.a2a, cron_service=None, restrict_to_workspace=True, session_manager=SessionManager(Path(spec.workspace)), mcp_servers=LocalSubagentStore.coerce_mcp_servers(spec), authz_config=config.authz, backend_identity=config.backend_identity, allow_spawn=allow_spawn, allow_message=False, allow_cron=False, include_local_fallback=allow_local, allow_local_delegation=allow_local, allow_plugin_delegation=allow_local, include_plugin_agents=allow_local, gateway_port=config.gateway.port, ) try: return await loop.process_direct( task, session_key=f"a2a:{spec.id}", channel="system", chat_id=spec.id, ) finally: await loop.close_mcp() def _subagent_task_result(task_id: str) -> dict[str, Any] | None: payload = app.state.subagent_tasks.get(task_id) if not isinstance(payload, dict): return None result = { "id": task_id, "status": payload.get("status", "submitted"), } error = str(payload.get("error") or "").strip() summary = str(payload.get("summary") or "").strip() if summary: result["summary"] = summary if error: result["summary"] = error metadata = payload.get("metadata") if isinstance(metadata, dict) and metadata: result["metadata"] = metadata return result def _cancel_subagent_task(task_id: str) -> dict[str, Any] | None: payload = app.state.subagent_tasks.get(task_id) if not isinstance(payload, dict): return None task = payload.get("asyncio_task") if isinstance(task, asyncio.Task) and not task.done(): task.cancel() payload["status"] = "cancelled" payload["error"] = "" payload.setdefault("summary", "Task cancelled") return _subagent_task_result(task_id) def _start_subagent_task(agent_id: str, task: str) -> dict[str, Any]: task_id = str(uuid.uuid4()) app.state.subagent_tasks[task_id] = { "agent_id": agent_id, "task": task, "status": "submitted", } async def _runner() -> None: app.state.subagent_tasks[task_id]["status"] = "working" try: summary = await _run_subagent_task(agent_id, task) app.state.subagent_tasks[task_id]["status"] = "completed" app.state.subagent_tasks[task_id]["summary"] = summary except asyncio.CancelledError: app.state.subagent_tasks[task_id]["status"] = "cancelled" app.state.subagent_tasks[task_id].setdefault("summary", "Task cancelled") raise except Exception as exc: # noqa: BLE001 app.state.subagent_tasks[task_id]["status"] = "error" app.state.subagent_tasks[task_id]["error"] = str(exc) app.state.subagent_tasks[task_id]["asyncio_task"] = asyncio.create_task(_runner()) return _subagent_task_result(task_id) or {"id": task_id, "status": "submitted"} def _serialize_subagent(spec: Any, config: Config) -> dict[str, Any]: from nanobot.agent.subagents import LocalSubagentStore payload = spec.to_dict() base_url = LocalSubagentStore(config.workspace_path).local_base_url(config, spec.id) payload["base_url"] = base_url payload["endpoint"] = f"{base_url}/rpc" payload["card_url"] = f"{base_url}/.well-known/agent-card" return payload def _require_authenticated_user(authorization: str | None = Header(default=None)) -> str: return _require_web_user(app, authorization) def _normalize_client_base_url(base_url: str, request: Request | None = None) -> str: value = base_url.strip().rstrip("/") if not value: return value parts = urlsplit(value) if parts.hostname not in {"0.0.0.0", "::"} or request is None: return value request_parts = urlsplit(str(request.base_url).rstrip("/")) host = request_parts.hostname or "127.0.0.1" port = parts.port if ":" in host and not host.startswith("["): host = f"[{host}]" netloc = f"{host}:{port}" if port is not None else host scheme = parts.scheme or request_parts.scheme or "http" return urlunsplit((scheme, netloc, parts.path, parts.query, parts.fragment)).rstrip("/") def _resolve_local_backend_base_url(config: Config, request: Request | None = None) -> str: explicit = (config.backend_identity.public_base_url or "").strip() if explicit: return _normalize_client_base_url(explicit, request) if request is not None: return str(request.base_url).rstrip("/") return "http://127.0.0.1:18080" def _resolve_local_frontend_base_url(config: Config, request: Request | None = None) -> str: explicit = _frontend_public_base_url() if explicit: return _normalize_client_base_url(explicit, request) api_base_url = _resolve_local_backend_base_url(config, request) api_parts = urlsplit(api_base_url) frontend_host = api_parts.hostname or "127.0.0.1" frontend_port = _frontend_port() if ":" in frontend_host and not frontend_host.startswith("["): frontend_host = f"[{frontend_host}]" frontend_netloc = f"{frontend_host}:{frontend_port}" if frontend_port else frontend_host return urlunsplit((api_parts.scheme or "http", frontend_netloc, "", "", "")).rstrip("/") @app.get("/subagents/{agent_id}/.well-known/agent-card") @app.get("/subagents/{agent_id}/.well-known/agent-card.json") @app.get("/subagents/{agent_id}/.well-known/agent.json") async def get_subagent_card(agent_id: str): from nanobot.agent.subagents import LocalSubagentStore config: Config = app.state.config store = LocalSubagentStore(config.workspace_path) spec = store.get_subagent(agent_id) if spec is None or not spec.enabled: raise HTTPException(status_code=404, detail="Sub-agent not found") return LocalSubagentStore.build_agent_card(spec, config) @app.post("/subagents/{agent_id}/rpc") async def subagent_rpc(agent_id: str, payload: dict[str, Any]): payload_id = payload.get("id") method = str(payload.get("method") or "").strip() params = payload.get("params") if not isinstance(params, dict): return _jsonrpc_error(payload_id, -32602, "Invalid params") if method == "tasks/get": task_id = str(params.get("id") or "").strip() if not task_id: return _jsonrpc_error(payload_id, -32602, "Missing task id") result = _subagent_task_result(task_id) if result is None: return _jsonrpc_error(payload_id, -32602, "Unknown task id") return { "jsonrpc": "2.0", "id": payload_id, "result": {"task": result}, } if method == "tasks/cancel": task_id = str(params.get("id") or "").strip() if not task_id: return _jsonrpc_error(payload_id, -32602, "Missing task id") result = _cancel_subagent_task(task_id) if result is None: return _jsonrpc_error(payload_id, -32602, "Unknown task id") return { "jsonrpc": "2.0", "id": payload_id, "result": {"task": result}, } if method == "tasks/send": try: task = _extract_subagent_task(params) except ValueError as exc: return _jsonrpc_error(payload_id, -32602, str(exc)) result = _start_subagent_task(agent_id, task) return { "jsonrpc": "2.0", "id": payload_id, "result": {"task": result}, } if method != "message/send": return _jsonrpc_error(payload_id, -32601, f"Method '{method}' not found") try: task = _extract_subagent_task(params) except ValueError as exc: return _jsonrpc_error(payload_id, -32602, str(exc)) try: response = await _run_subagent_task(agent_id, task) except HTTPException: raise except Exception as exc: # noqa: BLE001 logger.exception("Sub-agent RPC failed for {}", agent_id) return _jsonrpc_error(payload_id, -32000, str(exc)) return { "jsonrpc": "2.0", "id": payload_id, "result": { "message": { "role": "agent", "parts": [ { "type": "text", "kind": "text", "text": response, } ], } }, } def _local_backend_view(config: Config) -> dict[str, Any]: return { "backend_id": config.backend_identity.backend_id, "client_id": config.backend_identity.client_id, "name": config.backend_identity.name, "public_base_url": config.backend_identity.public_base_url, "authz": { "enabled": config.authz.enabled, "base_url": config.authz.base_url, }, } def _backend_connection_view(config: Config, request: Request | None = None) -> dict[str, Any]: api_base_url = _resolve_local_backend_base_url(config, request) ws_parts = urlsplit(api_base_url) ws_scheme = "wss" if ws_parts.scheme == "https" else "ws" ws_base_url = urlunsplit((ws_scheme, ws_parts.netloc, ws_parts.path, ws_parts.query, ws_parts.fragment)).rstrip("/") frontend_base_url = _resolve_local_frontend_base_url(config, request) return { "backend_id": config.backend_identity.backend_id or None, "client_id": config.backend_identity.client_id or None, "name": config.backend_identity.name or None, "public_base_url": api_base_url or None, "api_base_url": api_base_url or None, "ws_base_url": ws_base_url or None, "frontend_base_url": frontend_base_url or None, "registered": _has_backend_identity(config), } async def _build_backend_connection_view(config: Config, request: Request | None = None) -> dict[str, Any]: local_view = _backend_connection_view(config, request) if not ( config.authz.enabled and config.authz.base_url.strip() and config.backend_identity.backend_id.strip() ): return local_view backend_id = config.backend_identity.backend_id.strip() desired_name = (config.backend_identity.name or backend_id).strip() or backend_id desired_api_base_url = local_view.get("api_base_url") or None desired_frontend_base_url = local_view.get("frontend_base_url") or None try: client = _authz_client(config) try: await client.update_backend( backend_id, name=desired_name, base_url=str(desired_api_base_url or "").strip() or None, frontend_base_url=str(desired_frontend_base_url or "").strip() or None, ) except httpx.HTTPStatusError as exc: if exc.response.status_code != 404: raise authz_backend = await client.get_backend(backend_id) except httpx.HTTPError as exc: logger.warning("Failed to resolve backend routing from AuthZ: {}", exc) return local_view authz_api_base_url = _normalize_client_base_url( str(authz_backend.get("base_url") or desired_api_base_url or ""), request, ) if not authz_api_base_url: return local_view authz_frontend_base_url = _normalize_client_base_url( str(authz_backend.get("frontend_base_url") or desired_frontend_base_url or ""), request, ) or str(desired_frontend_base_url or "") ws_parts = urlsplit(authz_api_base_url) ws_scheme = "wss" if ws_parts.scheme == "https" else "ws" ws_base_url = urlunsplit((ws_scheme, ws_parts.netloc, ws_parts.path, ws_parts.query, ws_parts.fragment)).rstrip("/") return { **local_view, "name": str(authz_backend.get("name") or desired_name or "") or None, "public_base_url": authz_api_base_url or None, "api_base_url": authz_api_base_url or None, "ws_base_url": ws_base_url or None, "frontend_base_url": authz_frontend_base_url or None, } def _save_local_backend_identity( config: Config, *, backend_id: str, client_id: str, client_secret: str, name: str | None = None, public_base_url: str | None = None, authz_base_url: str | None = None, authz_enabled: bool = True, ) -> dict[str, Any]: config.backend_identity.backend_id = backend_id.strip() config.backend_identity.client_id = client_id.strip() config.backend_identity.client_secret = client_secret config.backend_identity.name = (name or backend_id).strip() or backend_id.strip() if public_base_url is not None: config.backend_identity.public_base_url = public_base_url.strip() if authz_base_url is not None and authz_base_url.strip(): config.authz.base_url = authz_base_url.strip() if authz_enabled: config.authz.enabled = True _save_app_config(config) _sync_authz_runtime_env(config, app.state.runtime_env_path) return _local_backend_view(config) def _authz_client(config: Config): from nanobot.authz.client import AuthzClient if not config.authz.base_url.strip(): raise HTTPException(status_code=400, detail="AuthZ base URL is not configured") return AuthzClient( config.authz.base_url, timeout_seconds=int(config.authz.request_timeout_seconds), ) def _coerce_authz_error(exc: httpx.HTTPError) -> HTTPException: if isinstance(exc, httpx.HTTPStatusError): detail = exc.response.text.strip() or str(exc) return HTTPException(status_code=exc.response.status_code, detail=detail) return HTTPException(status_code=502, detail=f"AuthZ request failed: {exc}") def _require_local_authz_backend(config: Config) -> tuple[Any, str]: if not (config.authz.enabled and config.authz.base_url.strip()): raise HTTPException(status_code=400, detail="AuthZ is not enabled") backend_id = (config.backend_identity.backend_id or "").strip() if not backend_id: raise HTTPException(status_code=400, detail="Local backend is not registered with AuthZ") return _authz_client(config), backend_id def _extract_authz_backend_identity(payload: dict[str, Any]) -> dict[str, str] | None: def _pick_str(candidate: dict[str, Any], *keys: str) -> str: for key in keys: value = candidate.get(key) if isinstance(value, str) and value.strip(): return value.strip() return "" candidates: list[dict[str, Any]] = [payload] for key in ("backend", "local_backend", "localBackend", "agent_sandbox", "agentSandbox", "sandbox"): candidate = payload.get(key) if isinstance(candidate, dict): candidates.append(candidate) for candidate in candidates: backend_id = _pick_str(candidate, "backend_id", "backendId") client_secret = _pick_str(candidate, "client_secret", "clientSecret", "secret") if not backend_id or not client_secret: continue client_id = _pick_str(candidate, "client_id", "clientId") or backend_id created_at = _pick_str(candidate, "created_at", "createdAt") or _pick_str( payload, "created_at", "createdAt", ) return { "backend_id": backend_id, "client_id": client_id, "client_secret": client_secret, "created_at": created_at, } return None def _reject_backend_collection_ui() -> None: raise HTTPException( status_code=410, detail=( "Backend registration moved to /api/auth/register. " "Sensitive MCP settings should be managed from the MCP detail page." ), ) @app.middleware("http") async def _require_api_login(request: Request, call_next): path = request.url.path if ( request.method == "OPTIONS" or not path.startswith("/api/") or path in {"/api/auth/login", "/api/auth/register", "/api/auth/logout", "/api/auth/handoff/consume", "/api/ping"} ): return await call_next(request) try: _require_web_user(app, request.headers.get("Authorization")) except HTTPException as exc: return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, ) return await call_next(request) async def _apply_mcp_runtime_config() -> None: # 只有 standalone 模式才有可热重载的本地 AgentLoop。 agent = _get_agent_loop() if agent is None: return config: Config = app.state.config await agent.reload_mcp_servers(config.tools.mcp_servers) def _mcp_servers_view() -> list[dict[str, Any]]: # 有运行中 agent 时,优先取其运行态视图;否则回退到纯配置视图。 agent = _get_agent_loop() if agent is not None and hasattr(agent, "get_mcp_servers_view"): return agent.get_mcp_servers_view() config: Config = app.state.config result: list[dict[str, Any]] = [] for name in sorted(config.tools.mcp_servers): cfg = config.tools.mcp_servers[name] sensitive = bool(getattr(cfg, "sensitive", False)) result.append({ "id": name, "name": name, "transport": "stdio" if getattr(cfg, "command", "") else "http", "url": getattr(cfg, "url", "") or None, "command": getattr(cfg, "command", "") or None, "args": list(getattr(cfg, "args", []) or []), "auth_mode": getattr(cfg, "auth_mode", "none") or "none", "auth_audience": getattr(cfg, "auth_audience", "") or None, "auth_scopes": [str(item) for item in list(getattr(cfg, "auth_scopes", []) or [])], "headers": ( {key: "***" for key in dict(getattr(cfg, "headers", {}) or {})} if sensitive else dict(getattr(cfg, "headers", {}) or {}) ), "env": ( {key: "***" for key in dict(getattr(cfg, "env", {}) or {})} if sensitive else dict(getattr(cfg, "env", {}) or {}) ), "tool_timeout": int(getattr(cfg, "tool_timeout", 30)), "sensitive": sensitive, "enabled": True, "status": "disconnected", "tool_count": 0, "tool_names": [], "last_error": None, }) return result async def _safe_ws_send_json( websocket: WebSocket, payload: dict[str, Any], send_lock: asyncio.Lock | None = None, ) -> None: # WebSocket 下进度事件和最终消息可能并发发送,因此允许传入 send_lock 做串行化。 try: if send_lock is None: await websocket.send_text(json.dumps(payload)) else: async with send_lock: await websocket.send_text(json.dumps(payload)) except Exception: logger.debug("Skipping websocket payload after disconnect: {}", payload.get("type")) # ------ Auth ------ @app.post("/api/auth/login") async def auth_login(req: LoginRequest, request: Request): username = req.username.strip() if not username: raise HTTPException(status_code=400, detail="Username is required") auth_file: Path = app.state.auth_file try: users = _load_auth_users(auth_file) except ValueError as e: raise HTTPException(status_code=500, detail=str(e)) expected = users.get(username) if expected is None or not secrets.compare_digest(expected, req.password): raise HTTPException(status_code=401, detail="Invalid username or password") token = _issue_web_token(app, username) handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token) config: Config = app.state.config return { "access_token": token, "refresh_token": "", "token_type": "bearer", "user_id": username, "username": username, "role": "owner", "handoff_code": handoff_code, "handoff_expires_at": handoff_expires_at, "backend_connection": await _build_backend_connection_view(config, request), "local_backend": _local_backend_view(config), } @app.get("/api/auth/me") async def auth_me(authorization: str | None = Header(default=None)): username = _require_web_user(app, authorization) return { "id": username, "username": username, "email": "", "role": "owner", "quota_tier": "single-user", } @app.post("/api/auth/handoff/consume") async def auth_handoff_consume(req: HandoffConsumeRequest): return _consume_handoff_code(app, req.code) @app.post("/api/auth/register") async def auth_register(req: RegisterRequest, request: Request): from nanobot.authz.client import AuthzClient username = req.username.strip() if not username: raise HTTPException(status_code=400, detail="Username is required") if not req.password: raise HTTPException(status_code=400, detail="Password is required") auth_file: Path = app.state.auth_file try: users = _load_auth_users(auth_file) if auth_file.exists() else {} except ValueError as e: raise HTTPException(status_code=500, detail=str(e)) user_exists = username in users if user_exists and not secrets.compare_digest(users[username], req.password): raise HTTPException( status_code=409, detail="Username already exists. Use the existing password to finish setup or log in.", ) config: Config = app.state.config authz_base_url = ( req.authz_base_url or (config.authz.base_url if config.authz.enabled else "") ).strip() authz_user_registered = False authz_backend_registered = False local_backend: dict[str, Any] | None = None existing_backend_registered = _has_backend_identity(config) requested_backend_id = (req.backend_id or config.backend_identity.backend_id).strip() or None backend_name = (req.backend_name or config.backend_identity.name or username).strip() or username public_base_url = (req.base_url or _resolve_local_backend_base_url(config, request)).strip() frontend_base_url = (req.frontend_base_url or _resolve_local_frontend_base_url(config, request)).strip() if authz_base_url: client = AuthzClient( authz_base_url, timeout_seconds=int(config.authz.request_timeout_seconds), ) authz_payload: dict[str, Any] = {} try: authz_payload = await client.register_user( username=username, password=req.password, email=req.email, backend_name=backend_name, backend_id=requested_backend_id, base_url=public_base_url, frontend_base_url=frontend_base_url, ) authz_user_registered = bool(authz_payload) except httpx.HTTPStatusError as exc: if exc.response.status_code == 409: # Allow retrying registration to complete backend/AuthZ setup # when the user record already exists upstream. authz_user_registered = True authz_payload = {} elif exc.response.status_code not in {404, 405}: raise _coerce_authz_error(exc) from exc except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc if existing_backend_registered: local_backend = _local_backend_view(config) authz_backend_registered = True else: backend_identity = _extract_authz_backend_identity(authz_payload) if backend_identity is None: try: registered_backend = await client.register_backend( name=backend_name, base_url=public_base_url, frontend_base_url=frontend_base_url, backend_id=requested_backend_id, ) except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc backend_identity = { "backend_id": registered_backend.backend_id, "client_id": registered_backend.client_id, "client_secret": registered_backend.client_secret, "created_at": registered_backend.created_at, } local_backend = _save_local_backend_identity( config, backend_id=backend_identity["backend_id"], client_id=backend_identity["client_id"], client_secret=backend_identity["client_secret"], name=backend_name, public_base_url=public_base_url, authz_base_url=authz_base_url, authz_enabled=True, ) authz_backend_registered = True if _uses_managed_outlook_mcp(config) and _has_backend_identity(config): try: config_changed = await _reconcile_managed_outlook_mcp(config) except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc if config_changed: _save_app_config(config) await _apply_mcp_runtime_config() if not user_exists: users[username] = req.password _save_auth_users(auth_file, users) token = _issue_web_token(app, username) handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token) response: dict[str, Any] = { "access_token": token, "refresh_token": "", "token_type": "bearer", "user_id": username, "username": username, "email": req.email or "", "role": "owner", "handoff_code": handoff_code, "handoff_expires_at": handoff_expires_at, "existing_user": user_exists, "authz": { "enabled": bool(authz_base_url), "base_url": authz_base_url or None, "user_registered": authz_user_registered, "backend_registered": authz_backend_registered, }, "backend_connection": await _build_backend_connection_view(config, request), } if local_backend is not None: response["local_backend"] = local_backend return response @app.post("/api/auth/logout") async def auth_logout(authorization: str | None = Header(default=None)): if authorization and authorization.lower().startswith("bearer "): token = authorization[7:].strip() if token: app.state.auth_tokens.pop(token, None) return {"ok": True} @app.post("/api/system/restart", status_code=202) async def restart_system( background_tasks: BackgroundTasks, authorization: str | None = Header(default=None), ): username = _require_web_user(app, authorization) logger.warning("Restart requested by user {}", username) background_tasks.add_task(_terminate_process_after_delay, 1.0, 1) return { "ok": True, "restarting": True, "detail": "Restart scheduled", } # ------ Chat ------ @app.post("/api/chat") async def chat(req: ChatRequest): """Send a message. Gateway mode: publishes to the bus and returns immediately. Standalone mode: processes synchronously and returns the response. """ session_key = req.session_id config_ref: Config = app.state.config media_paths = _resolve_attachment_paths(config_ref.workspace_path, req.attachments) chat_id = session_key.split(":", 1)[-1] if ":" in session_key else session_key web_channel: "WebChannel | None" = app.state.web_channel if web_channel is not None: # Gateway mode – async via bus await web_channel._handle_message( sender_id="web_user", chat_id=chat_id, content=req.message, media=media_paths or None, metadata={"attachments": req.attachments} if req.attachments else None, ) # Notify connected clients that processing started await web_channel.notify_thinking(chat_id) return {"status": "accepted", "session_id": session_key} else: # Standalone fallback from nanobot.agent.loop import AgentLoop agent: AgentLoop = app.state.agent response = await agent.process_direct( content=_with_attachment_hints(req.message, media_paths), session_key=session_key, channel="web", chat_id=chat_id, ) return ChatResponse(response=response, session_id=session_key) @app.post("/api/chat/stream") async def chat_stream(req: ChatRequest): """Send a message and stream the response via SSE (standalone mode only).""" from nanobot.agent.loop import AgentLoop agent: AgentLoop | None = app.state.agent if agent is None: raise HTTPException( status_code=400, detail="Streaming not available in gateway mode. Use WebSocket.", ) session_key = req.session_id config_ref: Config = app.state.config media_paths = _resolve_attachment_paths(config_ref.workspace_path, req.attachments) async def event_generator(): yield f"data: {json.dumps({'type': 'start'})}\n\n" try: response = await agent.process_direct( content=_with_attachment_hints(req.message, media_paths), session_key=session_key, channel="web", chat_id=session_key.split(":", 1)[-1] if ":" in session_key else session_key, ) chunk_size = 20 for i in range(0, len(response), chunk_size): chunk = response[i : i + chunk_size] yield f"data: {json.dumps({'type': 'content', 'content': chunk})}\n\n" await asyncio.sleep(0.02) yield f"data: {json.dumps({'type': 'done'})}\n\n" except Exception as e: yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n" return StreamingResponse(event_generator(), media_type="text/event-stream") # ------ WebSocket ------ @app.websocket("/ws/{session_id}") async def websocket_endpoint(websocket: WebSocket, session_id: str): """WebSocket endpoint for real-time chat. Clients send: {"type":"message","content":"..."} Server sends: {"type":"message","role":"assistant","content":"..."} {"type":"status","status":"thinking"} """ web_channel: "WebChannel | None" = app.state.web_channel ws_token = (websocket.query_params.get("token") or "").strip() if not ws_token or ws_token not in app.state.auth_tokens: await websocket.close(code=4401) return await websocket.accept() send_lock = asyncio.Lock() broadcaster: WebSocketBroadcaster = app.state.websocket_broadcaster await broadcaster.register(websocket, send_lock) if web_channel is not None: web_channel.register_connection(session_id, websocket) try: while True: raw = await websocket.receive_text() try: data = json.loads(raw) except json.JSONDecodeError: continue if data.get("type") == "ping": await _safe_ws_send_json(websocket, {"type": "pong"}, send_lock) continue if data.get("type") == "cancel_process": # 取消请求走委派层 run_id 取消;非委派流程会返回 ok=false。 run_id = str(data.get("run_id") or "").strip() agent = _get_agent_loop() cancelled = bool(agent and run_id and await agent.delegation.cancel(run_id)) await _safe_ws_send_json( websocket, {"type": "process_cancel_ack", "run_id": run_id, "ok": cancelled}, send_lock, ) continue if data.get("type") == "message": content = data.get("content", "").strip() if not content: continue # Extract file attachments if present attachments = data.get("attachments") or [] config_ref: Config = app.state.config media_paths = _resolve_attachment_paths(config_ref.workspace_path, attachments) if web_channel is not None: # Gateway mode – publish via bus await web_channel._handle_message( sender_id="web_user", chat_id=session_id, content=content, media=media_paths or None, metadata={"attachments": attachments} if attachments else None, ) await web_channel.notify_thinking(session_id) else: # Standalone fallback – process directly from nanobot.agent.loop import AgentLoop agent: AgentLoop = app.state.agent session_key = f"web:{session_id}" await _safe_ws_send_json( websocket, {"type": "status", "status": "thinking"}, send_lock, ) async def _process_sink(event: dict[str, Any]) -> None: # 给直连 WebSocket 模式补上 session_id,前端可按会话归档过程事件。 payload = {"session_id": session_key, **event} await _safe_ws_send_json(websocket, payload, send_lock) response = await agent.process_direct( content=_with_attachment_hints(content, media_paths), session_key=session_key, channel="web", chat_id=session_id, process_event_callback=_process_sink, ) await _safe_ws_send_json( websocket, { "type": "message", "role": "assistant", "content": response, }, send_lock, ) except WebSocketDisconnect: logger.debug(f"WebSocket disconnected for session {session_id}") except Exception as e: logger.error(f"WebSocket error for session {session_id}: {e}") finally: if web_channel is not None: web_channel.unregister_connection(session_id, websocket) await broadcaster.unregister(websocket) # ------ Sessions ------ @app.get("/api/sessions") async def list_sessions(): """List all conversation sessions.""" sm: SessionManager = app.state.session_manager return sm.list_sessions() def _serialize_session_detail(session: Session) -> dict[str, Any]: """Build the filtered session payload returned to the web UI.""" # Filter out tool messages and assistant messages with tool_calls # (intermediate steps), only keep user messages and final assistant replies visible_messages = [] for m in session.messages: role = m.get("role", "") # Skip tool result messages (e.g. SKILL.md content, file reads, etc.) if role == "tool": continue # Skip assistant messages that are just tool call requests (not final replies) if role == "assistant" and m.get("tool_calls"): continue msg_data: dict[str, Any] = { "role": role, "content": m.get("content", ""), "timestamp": m.get("timestamp"), } # Include attachments if stored in metadata meta = m.get("metadata") if isinstance(meta, dict): attachments = meta.get("attachments") if attachments: msg_data["attachments"] = attachments visible_messages.append(msg_data) return { "key": session.key, "messages": visible_messages, "created_at": session.created_at.isoformat(), "updated_at": session.updated_at.isoformat(), } @app.post("/api/sessions/{key:path}") async def create_session(key: str): """Create or persist a session immediately.""" sm: SessionManager = app.state.session_manager session = sm.get_or_create(key) sm.save(session) return _serialize_session_detail(session) @app.get("/api/sessions/{key:path}") async def get_session(key: str): """Get a session's message history.""" sm: SessionManager = app.state.session_manager session = sm.get_or_create(key) return _serialize_session_detail(session) @app.delete("/api/sessions/{key:path}") async def delete_session(key: str): """Delete a session.""" sm: SessionManager = app.state.session_manager if sm.delete(key): return {"ok": True} raise HTTPException(status_code=404, detail="Session not found") # ------ Status ------ @app.get("/api/status") async def get_status(): """Get system status.""" config: Config = app.state.config config_path = get_config_path() providers_status = [] for spec in PROVIDERS: p = getattr(config.providers, spec.name, None) if p is None: continue if spec.is_local: providers_status.append({ "name": spec.label, "has_key": bool(p.api_base), "detail": p.api_base or "", }) else: providers_status.append({ "name": spec.label, "has_key": bool(p.api_key), }) channels_status = [] for ch_name in ["whatsapp", "telegram", "discord", "feishu", "dingtalk", "email", "slack", "qq", "matrix"]: ch_cfg = getattr(config.channels, ch_name, None) if ch_cfg: channels_status.append({ "name": ch_name, "enabled": getattr(ch_cfg, "enabled", False), }) channels_status.append({"name": "web", "enabled": True}) cron: CronService = app.state.cron_service cron_status = cron.status() return { "config_path": str(config_path), "config_exists": config_path.exists(), "workspace": str(config.workspace_path), "workspace_exists": config.workspace_path.exists(), "model": config.agents.defaults.model, "max_tokens": config.agents.defaults.max_tokens, "temperature": config.agents.defaults.temperature, "max_tool_iterations": config.agents.defaults.max_tool_iterations, "providers": providers_status, "channels": channels_status, "cron": cron_status, "authz": { "enabled": config.authz.enabled, "base_url": config.authz.base_url, "outlook_mcp_url": config.authz.outlook_mcp_url, "backend_id": config.backend_identity.backend_id, "client_id": config.backend_identity.client_id, "registered": bool( config.backend_identity.backend_id and config.backend_identity.client_id and config.backend_identity.client_secret ), }, } # ------ Cron Jobs ------ @app.get("/api/authz/status") async def get_authz_status(): config: Config = app.state.config registered = bool( config.backend_identity.backend_id and config.backend_identity.client_id and config.backend_identity.client_secret ) response: dict[str, Any] = { "enabled": config.authz.enabled, "base_url": config.authz.base_url, "outlook_mcp_url": config.authz.outlook_mcp_url, "local_backend": { "backend_id": config.backend_identity.backend_id or None, "client_id": config.backend_identity.client_id or None, "name": config.backend_identity.name or None, "public_base_url": config.backend_identity.public_base_url or None, "registered": registered, }, } if not (config.authz.enabled and config.authz.base_url.strip() and config.backend_identity.backend_id.strip()): return response try: client, backend_id = _require_local_authz_backend(config) response["backend"] = await client.get_backend(backend_id) response["permissions"] = await client.get_permissions(backend_id) response["outlook"] = await client.get_outlook_settings(backend_id) response["channel_settings"] = await client.list_channel_settings(backend_id) except Exception as exc: # noqa: BLE001 response["error"] = str(exc) return response @app.post("/api/authz/local-backend/bind") async def bind_local_backend_identity(payload: LocalBackendIdentityRequest): config: Config = app.state.config return _save_local_backend_identity( config, backend_id=payload.backend_id, client_id=payload.client_id, client_secret=payload.client_secret, name=payload.name, public_base_url=payload.public_base_url, authz_base_url=payload.authz_base_url, authz_enabled=payload.authz_enabled, ) @app.get("/api/authz/backends") async def list_authz_backends(): _reject_backend_collection_ui() @app.post("/api/authz/backends/register") async def register_authz_backend(payload: AuthzRegisterBackendRequest, request: Request): _reject_backend_collection_ui() @app.get("/api/authz/backends/{backend_id}") async def get_authz_backend(backend_id: str): _reject_backend_collection_ui() @app.post("/api/authz/backends/{backend_id}/enable") async def enable_authz_backend(backend_id: str): _reject_backend_collection_ui() @app.post("/api/authz/backends/{backend_id}/disable") async def disable_authz_backend(backend_id: str): _reject_backend_collection_ui() @app.post("/api/authz/backends/{backend_id}/rotate-secret") async def rotate_authz_backend_secret(backend_id: str): _reject_backend_collection_ui() @app.get("/api/authz/backends/{backend_id}/permissions") async def get_authz_backend_permissions(backend_id: str): _reject_backend_collection_ui() @app.post("/api/authz/backends/{backend_id}/permissions") async def save_authz_backend_permissions(backend_id: str, payload: dict[str, Any]): _reject_backend_collection_ui() @app.get("/api/authz/backends/{backend_id}/settings/outlook") async def get_authz_backend_outlook_settings(backend_id: str): _reject_backend_collection_ui() @app.post("/api/authz/backends/{backend_id}/settings/outlook") async def save_authz_backend_outlook_settings(backend_id: str, payload: dict[str, Any]): _reject_backend_collection_ui() @app.delete("/api/authz/backends/{backend_id}/settings/outlook") async def delete_authz_backend_outlook_settings(backend_id: str): _reject_backend_collection_ui() @app.get("/api/authz/channel-settings") async def list_authz_channel_settings(): config: Config = app.state.config try: client, backend_id = _require_local_authz_backend(config) return await client.list_channel_settings(backend_id) except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc @app.get("/api/authz/channel-settings/{channel_id}") async def get_authz_channel_settings(channel_id: str): config: Config = app.state.config try: client, backend_id = _require_local_authz_backend(config) return await client.get_channel_settings(backend_id, channel_id) except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc @app.post("/api/authz/channel-settings/{channel_id}") async def save_authz_channel_settings(channel_id: str, payload: dict[str, Any]): config: Config = app.state.config try: client, backend_id = _require_local_authz_backend(config) return await client.set_channel_settings(backend_id, channel_id, payload) except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc @app.delete("/api/authz/channel-settings/{channel_id}") async def delete_authz_channel_settings(channel_id: str): config: Config = app.state.config try: client, backend_id = _require_local_authz_backend(config) return await client.delete_channel_settings(backend_id, channel_id) except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc @app.get("/api/cron/jobs") async def list_cron_jobs(include_disabled: bool = False): """List cron jobs.""" cron: CronService = app.state.cron_service jobs = cron.list_jobs(include_disabled=include_disabled) return [_serialize_job(j) for j in jobs] @app.post("/api/cron/jobs") async def add_cron_job(req: AddCronJobRequest): """Add a new cron job.""" cron: CronService = app.state.cron_service normalized_mode = (req.mode or "").strip().lower() normalized_session_key = (req.session_key or "").strip() or None normalized_channel = (req.channel or "").strip() or None normalized_to = (req.to or "").strip() or None if normalized_session_key and (not normalized_channel or not normalized_to): inferred_channel, inferred_to = _infer_cron_route_from_session_key(normalized_session_key) normalized_channel = normalized_channel or inferred_channel normalized_to = normalized_to or inferred_to if normalized_mode and normalized_mode not in {"reminder", "task"}: raise HTTPException(status_code=400, detail="mode must be 'reminder' or 'task'") # reminder 直接发消息,task 则进入 agent 自动执行。 payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn" if req.every_seconds: schedule = CronSchedule(kind="every", every_ms=req.every_seconds * 1000) elif req.cron_expr: schedule = CronSchedule(kind="cron", expr=req.cron_expr) elif req.at_iso: import datetime dt = datetime.datetime.fromisoformat(req.at_iso) schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) else: raise HTTPException(status_code=400, detail="Must specify every_seconds, cron_expr, or at_iso") job = cron.add_job( name=req.name, schedule=schedule, message=req.message, payload_kind=payload_kind, session_key=normalized_session_key, deliver=req.deliver, channel=normalized_channel, to=normalized_to, ) return _serialize_job(job) @app.delete("/api/cron/jobs/{job_id}") async def remove_cron_job(job_id: str): """Remove a cron job.""" cron: CronService = app.state.cron_service if cron.remove_job(job_id): return {"ok": True} raise HTTPException(status_code=404, detail="Job not found") @app.put("/api/cron/jobs/{job_id}/toggle") async def toggle_cron_job(job_id: str, req: ToggleCronJobRequest): """Enable or disable a cron job.""" cron: CronService = app.state.cron_service job = cron.enable_job(job_id, enabled=req.enabled) if job: return _serialize_job(job) raise HTTPException(status_code=404, detail="Job not found") @app.post("/api/cron/jobs/{job_id}/run") async def run_cron_job(job_id: str): """Manually run a cron job.""" cron: CronService = app.state.cron_service if await cron.run_job(job_id, force=True): return {"ok": True} raise HTTPException(status_code=404, detail="Job not found") # ------ Skills ------ @app.get("/api/skills") async def list_skills(): """List all skills (builtin + workspace).""" from nanobot.agent.skills import SkillsLoader config: Config = app.state.config loader = SkillsLoader(config.workspace_path) raw = loader.list_skills(filter_unavailable=False) result = [] for s in raw: meta = loader.get_skill_metadata(s["name"]) or {} available = loader._check_requirements(loader._get_skill_meta(s["name"])) result.append({ "name": s["name"], "description": meta.get("description", s["name"]), "source": s["source"], "available": available, "path": s["path"], "agent_cards": loader.get_skill_agent_cards(s["name"]), }) return result @app.delete("/api/skills/{name}") async def delete_skill(name: str): """Delete a workspace skill.""" from nanobot.agent.skills import SkillsLoader config: Config = app.state.config loader = SkillsLoader(config.workspace_path) # Check the skill exists and is a workspace skill all_skills = loader.list_skills(filter_unavailable=False) skill = next((s for s in all_skills if s["name"] == name), None) if not skill: raise HTTPException(status_code=404, detail="Skill not found") if skill["source"] != "workspace": raise HTTPException(status_code=400, detail="Cannot delete builtin skills") skill_dir = loader.workspace_skills / name if skill_dir.exists(): shutil.rmtree(skill_dir) return {"ok": True} @app.get("/api/skills/reviews") async def list_skill_reviews(): """List staged skill installs awaiting review.""" from nanobot.agent.skill_reviews import SkillReviewManager config: Config = app.state.config return SkillReviewManager(config.workspace_path).list_reviews() @app.get("/api/skills/reviews/{review_id}") async def get_skill_review(review_id: str): """Get a staged skill install preview.""" from nanobot.agent.skill_reviews import SkillReviewManager config: Config = app.state.config manager = SkillReviewManager(config.workspace_path) try: return manager.get_review(review_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e @app.post("/api/skills/reviews/{review_id}/approve") async def approve_skill_review( review_id: str, req: ApproveSkillReviewRequest | None = None, ): """Approve a staged skill install and copy it into workspace skills.""" from nanobot.agent.skill_reviews import SkillReviewManager from nanobot.agent.skills import SkillsLoader config: Config = app.state.config manager = SkillReviewManager(config.workspace_path) overwrite = bool(req.overwrite) if req else False try: review = manager.approve_review(review_id, overwrite=overwrite) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e except FileExistsError as e: raise HTTPException(status_code=409, detail=str(e)) from e except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e loader = SkillsLoader(config.workspace_path) meta = loader.get_skill_metadata(review["skill_name"]) or {} available = loader._check_requirements(loader._get_skill_meta(review["skill_name"])) return { "status": review["status"], "review_id": review["id"], "name": review["skill_name"], "description": meta.get("description", review["skill_name"]), "source": "workspace", "available": available, "path": review["installed_path"], "approved_at": review.get("approved_at"), "overwrite": review.get("overwrite", False), } @app.delete("/api/skills/reviews/{review_id}") async def discard_skill_review(review_id: str): """Discard a staged skill install without activating it.""" from nanobot.agent.skill_reviews import SkillReviewManager config: Config = app.state.config manager = SkillReviewManager(config.workspace_path) try: manager.discard_review(review_id) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) from e return {"ok": True} @app.get("/api/skills/{name}/download") async def download_skill(name: str): """Download a skill as a zip file.""" import io from nanobot.agent.skills import SkillsLoader config: Config = app.state.config loader = SkillsLoader(config.workspace_path) all_skills = loader.list_skills(filter_unavailable=False) skill = next((s for s in all_skills if s["name"] == name), None) if not skill: raise HTTPException(status_code=404, detail="Skill not found") # Resolve the skill directory from the SKILL.md path skill_dir = Path(skill["path"]).parent buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: for file_path in skill_dir.rglob("*"): if file_path.is_file(): arcname = f"{name}/{file_path.relative_to(skill_dir)}" zf.write(file_path, arcname) from fastapi.responses import Response from nanobot.web.files import content_disposition return Response( content=buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": content_disposition("attachment", f"{name}.zip")}, ) @app.post("/api/skills/upload") async def upload_skill(file: UploadFile = File(...)): """Upload a skill archive into the review queue without activating it.""" from nanobot.agent.skill_reviews import SkillReviewManager config: Config = app.state.config manager = SkillReviewManager(config.workspace_path) if not file.filename or not file.filename.endswith(".zip"): raise HTTPException(status_code=400, detail="File must be a .zip archive") try: content = await file.read() return manager.create_review_from_zip(file.filename, content) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e # ------ Files ------ max_file_size = 50 * 1024 * 1024 # 50MB @app.post("/api/files/upload") async def upload_file( file: UploadFile = File(...), session_id: str = Form("web:default"), ): """Upload a file for chat attachment or analysis.""" from nanobot.web.files import generate_file_id, save_file if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") content = await file.read() if len(content) > max_file_size: raise HTTPException(status_code=413, detail="File too large (max 50MB)") file_id = generate_file_id() ct = file.content_type or "application/octet-stream" config: Config = app.state.config metadata = save_file( workspace=config.workspace_path, file_id=file_id, filename=file.filename, content=content, content_type=ct, session_id=session_id, ) metadata["url"] = f"/api/files/{file_id}" return metadata @app.get("/api/files") async def list_uploaded_files(session_id: str | None = None): """List uploaded files, optionally filtered by session.""" from nanobot.web.files import list_files config: Config = app.state.config return list_files(config.workspace_path, session_id=session_id) @app.get("/api/files/{file_id}") async def download_file(file_id: str): """Download a file by ID.""" from nanobot.web.files import get_file_metadata, get_file_path config: Config = app.state.config meta = get_file_metadata(config.workspace_path, file_id) if meta is None: raise HTTPException(status_code=404, detail="File not found") file_path = get_file_path(config.workspace_path, file_id) if file_path is None: raise HTTPException(status_code=404, detail="File data missing") ct = meta.get("content_type", "application/octet-stream") disposition = "inline" if ct.startswith("image/") else "attachment" filename = meta["name"] from fastapi.responses import Response from nanobot.web.files import content_disposition return Response( content=file_path.read_bytes(), media_type=ct, headers={"Content-Disposition": content_disposition(disposition, filename)}, ) @app.delete("/api/files/{file_id}") async def remove_file(file_id: str): """Delete a file.""" from nanobot.web.files import delete_file config: Config = app.state.config if delete_file(config.workspace_path, file_id): return {"ok": True} raise HTTPException(status_code=404, detail="File not found") # ------ Workspace Browser ------ @app.get("/api/workspace/browse") async def browse_workspace_dir(path: str = ""): """Browse workspace directory contents.""" from nanobot.web.files import browse_workspace config: Config = app.state.config try: return browse_workspace(config.workspace_path, path) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.get("/api/workspace/download") async def download_workspace_file(path: str): """Download a file from workspace by relative path.""" from nanobot.web.files import workspace_file_path config: Config = app.state.config file_path = workspace_file_path(config.workspace_path, path) if file_path is None: raise HTTPException(status_code=404, detail="File not found") import mimetypes from fastapi.responses import Response from nanobot.web.files import content_disposition ct, _ = mimetypes.guess_type(file_path.name) ct = ct or "application/octet-stream" disposition = "inline" if ct.startswith("image/") else "attachment" return Response( content=file_path.read_bytes(), media_type=ct, headers={"Content-Disposition": content_disposition(disposition, file_path.name)}, ) @app.post("/api/workspace/upload") async def upload_to_workspace( file: UploadFile = File(...), path: str = Form(""), ): """Upload a file to a specific workspace directory.""" from nanobot.web.files import save_to_workspace if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") content = await file.read() if len(content) > max_file_size: raise HTTPException(status_code=413, detail="File too large (max 50MB)") config: Config = app.state.config try: return save_to_workspace(config.workspace_path, path, file.filename, content) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/workspace/delete") async def delete_workspace_item(path: str): """Delete a file or directory from workspace.""" from nanobot.web.files import delete_workspace_path config: Config = app.state.config if delete_workspace_path(config.workspace_path, path): return {"ok": True} raise HTTPException(status_code=404, detail="Path not found") @app.post("/api/workspace/mkdir") async def create_workspace_directory(path: str): """Create a directory in workspace.""" from nanobot.web.files import create_workspace_dir config: Config = app.state.config try: return create_workspace_dir(config.workspace_path, path) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) # ------ Plugins ------ @app.get("/api/plugins") async def list_plugins(): """List all loaded plugins with their agents, commands, and skills.""" from nanobot.agent.plugins import PluginLoader config: Config = app.state.config loader = PluginLoader(config.workspace_path) result = [] for plugin in loader.plugins.values(): result.append({ "name": plugin.name, "description": plugin.description, "source": plugin.source, "agents": [ { "name": a.name, "description": a.description, "model": a.model, } for a in plugin.agents.values() ], "commands": [ { "name": c.name, "description": c.description, "argument_hint": c.argument_hint, } for c in plugin.commands.values() ], "skills": [ skill_dir.name for skill_dir_root in plugin.skill_dirs for skill_dir in sorted(skill_dir_root.iterdir()) if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists() ], }) return result @app.get("/api/subagents") async def list_subagents(): """List persistent local sub-agents.""" from nanobot.agent.subagents import LocalSubagentStore config: Config = app.state.config store = LocalSubagentStore(config.workspace_path) return [_serialize_subagent(spec, config) for spec in store.list_subagents()] @app.get("/api/subagents/{agent_id}") async def get_subagent(agent_id: str): """Get one persistent local sub-agent.""" from nanobot.agent.subagents import LocalSubagentStore config: Config = app.state.config store = LocalSubagentStore(config.workspace_path) spec = store.get_subagent(agent_id) if spec is None: raise HTTPException(status_code=404, detail="Sub-agent not found") return _serialize_subagent(spec, config) @app.post("/api/subagents") async def create_subagent(req: SubagentRequest): """Create or replace a persistent local sub-agent.""" from nanobot.agent.subagents import LocalSubagentStore config: Config = app.state.config store = LocalSubagentStore(config.workspace_path) spec = store.upsert_subagent(req.model_dump(), config) return _serialize_subagent(spec, config) @app.put("/api/subagents/{agent_id}") async def update_subagent(agent_id: str, req: SubagentRequest): """Update a persistent local sub-agent.""" if agent_id != req.id: raise HTTPException(status_code=400, detail="Path id must match body id") return await create_subagent(req) @app.delete("/api/subagents/{agent_id}") async def delete_subagent(agent_id: str): """Delete a persistent local sub-agent.""" from nanobot.agent.subagents import LocalSubagentStore config: Config = app.state.config store = LocalSubagentStore(config.workspace_path) if store.delete_subagent(agent_id): return {"ok": True, "id": agent_id} raise HTTPException(status_code=404, detail="Sub-agent not found") @app.get("/api/agents") async def list_agents(): """List unified agents from workspace, plugins, skills, and local fallback.""" registry = _get_agent_registry() return registry.list_public_agents() @app.post("/api/agents") async def add_agent(req: AddAgentRequest): """Add or update a workspace agent entry.""" from nanobot.agent.agent_registry import WorkspaceAgentStore config: Config = app.state.config store = WorkspaceAgentStore(config.workspace_path) if _should_auto_discover_agent(req): try: payload = await _discover_agent_payload(req, config) except Exception as exc: if not _first_text(req.id): raise HTTPException(status_code=400, detail=f"自动读取 A2A card 失败: {exc}") from exc logger.warning("Failed to auto-discover agent '{}': {}", req.id, exc) payload = _manual_agent_payload(req) else: payload = _manual_agent_payload(req) return store.upsert_agent(payload) @app.delete("/api/agents/{agent_id}") async def delete_agent(agent_id: str): """Delete a workspace agent entry.""" from nanobot.agent.agent_registry import WorkspaceAgentStore config: Config = app.state.config store = WorkspaceAgentStore(config.workspace_path) if store.delete_agent(agent_id): return {"ok": True} raise HTTPException(status_code=404, detail="Agent not found") @app.post("/api/agents/refresh") async def refresh_agents(): """Refresh unified agent view.""" # 当前 registry 不做强缓存,这里本质上是重新拉一遍视图给前端刷新。 registry = _get_agent_registry() return {"agents": registry.list_public_agents()} @app.post("/api/delegations/{run_id}/cancel") async def cancel_delegation(run_id: str): """Cancel a running delegation, if present.""" agent = _get_agent_loop() if agent is None: raise HTTPException(status_code=400, detail="Delegation control requires standalone mode") cancelled = await agent.delegation.cancel(run_id) if not cancelled: raise HTTPException(status_code=404, detail="Delegation not found") return {"ok": True, "run_id": run_id} @app.get("/api/mcp/servers") async def list_mcp_servers(): """List MCP server configuration merged with runtime state.""" return _mcp_servers_view() @app.post("/api/mcp/servers") async def add_mcp_server(req: MCPServerRequest): """Create or replace an MCP server config entry.""" from nanobot.config.schema import MCPServerConfig config: Config = app.state.config server_id = req.id.strip() if not server_id: raise HTTPException(status_code=400, detail="Server id is required") auth_mode = (req.auth_mode or "none").strip().lower() or "none" auth_audience = (req.auth_audience or "").strip() auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()] if auth_mode == "oauth_backend_token" and not auth_audience: auth_audience = f"mcp:{server_id}" config.tools.mcp_servers[server_id] = MCPServerConfig( command=req.command, args=req.args, env=req.env, url=req.url, headers=req.headers, auth_mode=auth_mode, auth_audience=auth_audience, auth_scopes=auth_scopes, tool_timeout=req.tool_timeout, sensitive=req.sensitive, ) _save_app_config(config) # 配置落盘后立刻把运行中的 MCP 连接重载一遍,保证 UI 与运行态一致。 await _apply_mcp_runtime_config() return next((item for item in _mcp_servers_view() if item["id"] == server_id), {"id": server_id}) @app.put("/api/mcp/servers/{server_id}") async def update_mcp_server(server_id: str, req: MCPServerRequest): """Update an MCP server config entry.""" if server_id != req.id: raise HTTPException(status_code=400, detail="Path id must match body id") return await add_mcp_server(req) @app.delete("/api/mcp/servers/{server_id}") async def delete_mcp_server(server_id: str): """Delete an MCP server config entry.""" config: Config = app.state.config if server_id not in config.tools.mcp_servers: raise HTTPException(status_code=404, detail="MCP server not found") config.tools.mcp_servers.pop(server_id, None) _save_app_config(config) await _apply_mcp_runtime_config() return {"ok": True, "id": server_id} @app.post("/api/mcp/servers/{server_id}/test") async def test_mcp_server(server_id: str): """Attempt a fresh connection to one MCP server config.""" from contextlib import AsyncExitStack from nanobot.agent.tools.mcp import connect_mcp_servers from nanobot.agent.tools.registry import ToolRegistry from nanobot.web.outlook import OUTLOOK_SERVER_ID config: Config = app.state.config if server_id == OUTLOOK_SERVER_ID and _uses_managed_outlook_mcp(config) and _has_backend_identity(config): try: config_changed = await _reconcile_managed_outlook_mcp(config) except httpx.HTTPError as exc: raise _coerce_authz_error(exc) from exc if config_changed: _save_app_config(config) await _apply_mcp_runtime_config() config = app.state.config cfg = config.tools.mcp_servers.get(server_id) if cfg is None: raise HTTPException(status_code=404, detail="MCP server not found") registry = ToolRegistry() async with AsyncExitStack() as stack: # 用临时 registry + 临时连接做探测,不污染当前正式运行中的工具集合。 report = await connect_mcp_servers( {server_id: cfg}, registry, stack, authz_config=config.authz, backend_identity=config.backend_identity, ) item = report.get(server_id, {}) return { "ok": item.get("status") == "connected", "server": server_id, **item, } @app.get("/api/integrations/outlook/status") async def get_outlook_status(): from nanobot.web.outlook import OutlookIntegrationError, outlook_status config: Config = app.state.config try: return await outlook_status(config) except OutlookIntegrationError as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc @app.post("/api/integrations/outlook/test-connection") async def test_outlook_connection(req: OutlookConnectionRequest): from nanobot.web.outlook import ( OutlookConnectionInput, OutlookIntegrationError, test_connection, ) config: Config = app.state.config try: return await test_connection(OutlookConnectionInput(**req.model_dump()), config) except OutlookIntegrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/integrations/outlook/connect") async def connect_outlook(req: OutlookConnectionRequest): from nanobot.web.outlook import ( OutlookConnectionInput, OutlookIntegrationError, connect_workspace, ) config: Config = app.state.config try: result = await connect_workspace(config, OutlookConnectionInput(**req.model_dump())) except OutlookIntegrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=str(exc)) from exc _save_app_config(config) await _apply_mcp_runtime_config() return result @app.post("/api/integrations/outlook/disconnect") async def disconnect_outlook(): from nanobot.web.outlook import OutlookIntegrationError, disconnect_workspace config: Config = app.state.config try: result = await disconnect_workspace(config) except OutlookIntegrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc _save_app_config(config) await _apply_mcp_runtime_config() return result @app.get("/api/integrations/outlook/overview") async def get_outlook_overview(): from nanobot.web.outlook import OutlookIntegrationError, get_overview config: Config = app.state.config try: return await get_overview(config) except OutlookIntegrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get("/api/integrations/outlook/messages") async def get_outlook_messages( folder: str = "inbox", top: int = 20, skip: int = 0, unread_only: bool = False, ): from nanobot.web.outlook import OutlookIntegrationError, list_messages config: Config = app.state.config if not folder.strip(): raise HTTPException(status_code=400, detail="folder is required") try: return await list_messages( config, folder=folder.strip(), top=top, skip=skip, unread_only=unread_only, ) except OutlookIntegrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get("/api/integrations/outlook/events") async def get_outlook_events( start_time: str, end_time: str, top: int = 20, skip: int = 0, ): from nanobot.web.outlook import OutlookIntegrationError, list_events config: Config = app.state.config if not start_time.strip() or not end_time.strip(): raise HTTPException(status_code=400, detail="start_time and end_time are required") try: return await list_events( config, start_time=start_time.strip(), end_time=end_time.strip(), top=top, skip=skip, ) except OutlookIntegrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get("/api/integrations/outlook/message-detail") async def get_outlook_message_detail(message_id: str, changekey: str | None = None): from nanobot.web.outlook import OutlookIntegrationError, get_message_detail config: Config = app.state.config if not message_id.strip(): raise HTTPException(status_code=400, detail="message_id is required") try: return await get_message_detail( config, message_id.strip(), changekey=changekey.strip() if changekey else None, ) except OutlookIntegrationError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=str(exc)) from exc @app.get("/api/mcp/tools") async def list_mcp_tools(): """List discovered MCP tools grouped by server.""" grouped: dict[str, list[dict[str, Any]]] = {} agent = _get_agent_loop() if agent is not None: # 先按 server_id 长度倒序,避免前缀相近时被短 id 误匹配。 server_ids = sorted(agent._mcp_servers.keys(), key=len, reverse=True) if hasattr(agent, "_mcp_servers") else [] for tool_name in agent.tools.tool_names: if not tool_name.startswith("mcp_"): continue server_name = None public_name = tool_name for candidate in server_ids: prefix = f"mcp_{candidate}_" if tool_name.startswith(prefix): server_name = candidate public_name = tool_name[len(prefix):] break if server_name is None: _, remainder = tool_name.split("mcp_", 1) server_name, _, public_name = remainder.partition("_") tool_obj = agent.tools.get(tool_name) grouped.setdefault(server_name, []).append({ "server_id": server_name, "tool_name": public_name, "name": tool_name, "description": getattr(tool_obj, "description", ""), "parameters": getattr(tool_obj, "parameters", {}), }) result = [] for server_id in sorted(grouped): result.append({ "server_id": server_id, "tools": sorted(grouped[server_id], key=lambda item: item["tool_name"]), }) return result # ------ Commands (plugin slash commands) ------ @app.get("/api/commands") async def list_commands(): """List slash commands supported by the current single-user loop.""" return [ {"name": "new", "description": "Start a new conversation", "argument_hint": None, "plugin_name": "builtin"}, {"name": "help", "description": "Show available commands", "argument_hint": None, "plugin_name": "builtin"}, ] # ------ Marketplace ------ @app.get("/api/marketplaces") async def list_marketplaces(): """List all registered marketplaces.""" from nanobot.agent.marketplace import MarketplaceManager mgr = MarketplaceManager() return [ {"name": m.name, "source": m.source, "type": m.type} for m in mgr.list_marketplaces() ] @app.post("/api/marketplaces") async def add_marketplace(req: AddMarketplaceRequest): """Register a new marketplace from local path or Git URL.""" from nanobot.agent.marketplace import MarketplaceManager mgr = MarketplaceManager() try: entry = mgr.add_marketplace(req.source) return {"name": entry.name, "source": entry.source, "type": entry.type} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/marketplaces/{name}") async def remove_marketplace(name: str): """Remove a registered marketplace.""" from nanobot.agent.marketplace import MarketplaceManager mgr = MarketplaceManager() try: mgr.remove_marketplace(name) return {"ok": True} except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @app.post("/api/marketplaces/{name}/update") async def update_marketplace(name: str): """Update (clone or pull) a marketplace's cached data.""" from nanobot.agent.marketplace import MarketplaceManager mgr = MarketplaceManager() try: entry = mgr.update_marketplace(name) return {"name": entry.name, "source": entry.source, "type": entry.type} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.get("/api/marketplaces/{name}/plugins") async def list_marketplace_plugins(name: str): """List available plugins in a marketplace.""" from nanobot.agent.marketplace import MarketplaceManager mgr = MarketplaceManager() try: plugins = mgr.list_available_plugins(name) return [ { "name": p.name, "description": p.description, "marketplace_name": p.marketplace_name, "installed": p.installed, } for p in plugins ] except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @app.post("/api/marketplaces/{name}/plugins/{plugin_name}/install") async def install_marketplace_plugin(name: str, plugin_name: str): """Install a plugin from a marketplace.""" from nanobot.agent.marketplace import MarketplaceManager mgr = MarketplaceManager() try: dest = mgr.install_plugin(name, plugin_name) return {"ok": True, "path": str(dest)} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/plugins/{plugin_name}") async def uninstall_plugin(plugin_name: str): """Uninstall a plugin.""" from nanobot.agent.marketplace import MarketplaceManager mgr = MarketplaceManager() try: mgr.uninstall_plugin(plugin_name) return {"ok": True} except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) # ------ Health ------ @app.get("/api/ping") async def ping(): return {"message": "pong"} def _serialize_job(job: CronJob) -> dict[str, Any]: """Serialize a CronJob to a JSON-friendly dict.""" sched_str = "" if job.schedule.kind == "every": secs = (job.schedule.every_ms or 0) // 1000 if secs >= 3600: sched_str = f"every {secs // 3600}h" elif secs >= 60: sched_str = f"every {secs // 60}m" else: sched_str = f"every {secs}s" elif job.schedule.kind == "cron": sched_str = job.schedule.expr or "" else: sched_str = "one-time" next_run = None if job.state.next_run_at_ms: next_run = job.state.next_run_at_ms last_run = None if job.state.last_run_at_ms: last_run = job.state.last_run_at_ms return { "id": job.id, "name": job.name, "enabled": job.enabled, "payload_kind": job.payload.kind, "mode": "reminder" if job.payload.kind == "system_event" else "task", "session_key": job.payload.session_key, "schedule_kind": job.schedule.kind, "schedule_display": sched_str, "schedule_expr": job.schedule.expr, "schedule_every_ms": job.schedule.every_ms, "message": job.payload.message, "deliver": job.payload.deliver, "channel": job.payload.channel, "to": job.payload.to, "next_run_at_ms": next_run, "last_run_at_ms": last_run, "last_status": job.state.last_status, "last_error": job.state.last_error, "created_at_ms": job.created_at_ms, }