diff --git a/backend/agents/workspace_manager.py b/backend/agents/workspace_manager.py index 8a36d4c..6e77dea 100644 --- a/backend/agents/workspace_manager.py +++ b/backend/agents/workspace_manager.py @@ -137,7 +137,7 @@ class RunWorkspaceManager: filename: str, ) -> str: """Load one run-scoped agent workspace file.""" - path = self.get_agent_asset_dir(config_name, agent_id) / filename + path = self.skills_manager.get_agent_asset_dir(config_name, agent_id) / filename if not path.exists(): raise FileNotFoundError(f"File not found: {filename}") return path.read_text(encoding="utf-8") @@ -151,7 +151,7 @@ class RunWorkspaceManager: content: str, ) -> None: """Write one run-scoped agent workspace file.""" - asset_dir = self.get_agent_asset_dir(config_name, agent_id) + asset_dir = self.skills_manager.get_agent_asset_dir(config_name, agent_id) asset_dir.mkdir(parents=True, exist_ok=True) path = asset_dir / filename path.write_text(content, encoding="utf-8") diff --git a/backend/api/openclaw.py b/backend/api/openclaw.py new file mode 100644 index 0000000..f470beb --- /dev/null +++ b/backend/api/openclaw.py @@ -0,0 +1,839 @@ +# -*- coding: utf-8 -*- +"""Read-only OpenClaw CLI API routes — typed with Pydantic models.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService +from shared.models.openclaw import OpenClawStatus + + +router = APIRouter(prefix="/api/openclaw", tags=["openclaw"]) + + +def get_openclaw_cli_service() -> OpenClawCliService: + """Build the OpenClaw CLI service dependency.""" + return OpenClawCliService() + + +def _raise_cli_http_error(exc: OpenClawCliError) -> None: + detail = { + "message": str(exc), + "command": exc.command, + "exit_code": exc.exit_code, + "stdout": exc.stdout, + "stderr": exc.stderr, + } + status_code = 503 if exc.exit_code is None else 502 + raise HTTPException(status_code=status_code, detail=detail) from exc + + +# --------------------------------------------------------------------------- +# Response wrappers +# --------------------------------------------------------------------------- + +class StatusResponse(BaseModel): + status: object + + +class SessionsResponse(BaseModel): + sessions: list[object] + + +class SessionDetailResponse(BaseModel): + session: object | None + + +class SessionHistoryResponse(BaseModel): + session_key: str + session_id: str | None + events: list[object] + history: list[object] + raw_text: str | None + + +class CronResponse(BaseModel): + cron: list[object] + jobs: list[object] + + +class ApprovalsResponse(BaseModel): + approvals: list[object] + pending: list[object] + + +class AgentsResponse(BaseModel): + agents: list[object] + + +class SkillsResponse(BaseModel): + workspace_dir: str + managed_skills_dir: str + skills: list[object] + + +class ModelsResponse(BaseModel): + models: list[object] + + +class HooksResponse(BaseModel): + workspace_dir: str + managed_hooks_dir: str + hooks: list[object] + + +class PluginsResponse(BaseModel): + workspace_dir: str + plugins: list[object] + diagnostics: list[object] + + +class SecretsAuditResponse(BaseModel): + version: int + status: str + findings: list[object] + + +class SecurityAuditResponse2(BaseModel): + report: object | None + secret_diagnostics: list[str] + + +class DaemonStatusResponse(BaseModel): + service: object | None + port: object | None + rpc: object | None + health: object | None + + +class PairingListResponse2(BaseModel): + channel: str + requests: list[object] + + +class QrCodeResponse2(BaseModel): + setup_code: str + gateway_url: str + auth: str + url_source: str + + +class UpdateStatusResponse2(BaseModel): + update: object | None + channel: object | None + + +class ModelAliasesResponse(BaseModel): + aliases: dict[str, str] + + +class ModelFallbacksResponse(BaseModel): + key: str + label: str + items: list[object] + + +class SkillUpdateResponse(BaseModel): + ok: bool + slug: str + version: str + error: str | None + + +class ModelsStatusResponse(BaseModel): + configPath: str | None = None + agentId: str | None = None + agentDir: str | None = None + defaultModel: str | None = None + resolvedDefault: str | None = None + fallbacks: list[str] = Field(default_factory=list) + imageModel: str | None = None + imageFallbacks: list[str] = Field(default_factory=list) + aliases: dict[str, str] = Field(default_factory=dict) + allowed: list[str] = Field(default_factory=list) + auth: dict[str, Any] = Field(default_factory=dict) + + +class ChannelsStatusResponse(BaseModel): + reachable: bool | None = None + channelAccounts: dict[str, Any] = Field(default_factory=dict) + channels: list[str] = Field(default_factory=list) + issues: list[dict[str, Any]] = Field(default_factory=list) + + +class ChannelsListResponse(BaseModel): + chat: dict[str, list[str]] = Field(default_factory=dict) + auth: list[dict[str, Any]] = Field(default_factory=list) + usage: dict[str, Any] | None = None + + +class HookInfoResponse(BaseModel): + name: str | None = None + description: str | None = None + source: str | None = None + pluginId: str | None = None + filePath: str | None = None + handlerPath: str | None = None + hookKey: str | None = None + emoji: str | None = None + homepage: str | None = None + events: list[str] = Field(default_factory=list) + enabledByConfig: bool | None = None + loadable: bool | None = None + requirementsSatisfied: bool | None = None + requirements: dict[str, Any] = Field(default_factory=dict) + error: str | None = None + raw: str | None = None + + +class HooksCheckResponse(BaseModel): + workspace_dir: str = "" + managed_hooks_dir: str = "" + hooks: list[dict[str, Any]] = Field(default_factory=list) + eligible: bool | None = None + verbose: bool | None = None + + +class PluginInspectEntry(BaseModel): + plugin: dict[str, Any] = Field(default_factory=dict) + shape: str | None = None + capabilityMode: str | None = None + capabilityCount: int = 0 + capabilities: list[dict[str, Any]] = Field(default_factory=list) + typedHooks: list[dict[str, Any]] = Field(default_factory=list) + customHooks: list[dict[str, Any]] = Field(default_factory=list) + tools: list[dict[str, Any]] = Field(default_factory=list) + commands: list[str] = Field(default_factory=list) + cliCommands: list[str] = Field(default_factory=list) + services: list[str] = Field(default_factory=list) + gatewayMethods: list[str] = Field(default_factory=list) + mcpServers: list[dict[str, Any]] = Field(default_factory=list) + lspServers: list[dict[str, Any]] = Field(default_factory=list) + httpRouteCount: int = 0 + bundleCapabilities: list[str] = Field(default_factory=list) + + +class PluginsInspectResponse(BaseModel): + inspect: list[dict[str, Any]] = Field(default_factory=list) + + +class AgentBindingItem(BaseModel): + agentId: str + match: dict[str, Any] + description: str + + +class AgentsBindingsResponse(BaseModel): + bindings: list[AgentBindingItem] + + +# --------------------------------------------------------------------------- +# Routes — use typed model methods and return Pydantic models directly +# --------------------------------------------------------------------------- + +@router.get("/status") +async def api_openclaw_status( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> OpenClawStatus: + """Read `openclaw status --json` and return a typed model.""" + try: + return service.status_model() + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/sessions") +async def api_openclaw_sessions( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> SessionsResponse: + """Read `openclaw sessions --json` and return a typed SessionsList.""" + try: + result = service.list_sessions_model() + return SessionsResponse(sessions=result.sessions) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/sessions/{session_key:path}/history") +async def api_openclaw_session_history( + session_key: str, + limit: int = Query(20, ge=1, le=200), + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> SessionHistoryResponse: + """Read session history and return a typed SessionHistory.""" + try: + result = service.get_session_history_model(session_key, limit=limit) + return SessionHistoryResponse( + session_key=result.session_key, + session_id=result.session_id, + events=result.events, + history=result.events, # alias for compat + raw_text=result.raw_text, + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/sessions/{session_key:path}") +async def api_openclaw_session_detail( + session_key: str, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> SessionDetailResponse: + """Resolve a single session and return it as a typed model.""" + try: + session = service.get_session_model(session_key) + return SessionDetailResponse(session=session) + except KeyError as exc: + raise HTTPException(status_code=404, detail=f"session '{session_key}' not found") from exc + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/cron") +async def api_openclaw_cron( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> CronResponse: + """Read `openclaw cron list --json` and return a typed CronList.""" + try: + result = service.list_cron_jobs_model() + return CronResponse(cron=list(result.cron), jobs=list(result.jobs)) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/approvals") +async def api_openclaw_approvals( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ApprovalsResponse: + """Read `openclaw approvals get --json` and return a typed ApprovalsList.""" + try: + result = service.list_approvals_model() + return ApprovalsResponse( + approvals=list(result.approvals), + pending=list(result.pending), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/agents") +async def api_openclaw_agents( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> AgentsResponse: + """Read `openclaw agents list --json` and return a typed AgentsList.""" + try: + result = service.list_agents_model() + return AgentsResponse(agents=list(result.agents)) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/agents/presence") +async def api_openclaw_agents_presence( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> dict[str, Any]: + """Read runtime session presence for all agents from session files.""" + result = service.agents_presence() + return result + + +# --------------------------------------------------------------------------- +# Write agents routes +# --------------------------------------------------------------------------- + + +class AgentAddResponse(BaseModel): + agentId: str + name: str + workspace: str + agentDir: str + model: str | None = None + bindings: dict[str, Any] = Field(default_factory=dict) + + +class AgentDeleteResponse(BaseModel): + agentId: str + workspace: str + agentDir: str + sessionsDir: str + removedBindings: list[str] = Field(default_factory=list) + removedAllow: list[str] = Field(default_factory=list) + + +class AgentBindResponse(BaseModel): + agentId: str + added: list[str] = Field(default_factory=list) + updated: list[str] = Field(default_factory=list) + skipped: list[str] = Field(default_factory=list) + conflicts: list[str] = Field(default_factory=list) + + +class AgentUnbindResponse(BaseModel): + agentId: str + removed: list[str] = Field(default_factory=list) + missing: list[str] = Field(default_factory=list) + conflicts: list[str] = Field(default_factory=list) + + +class AgentIdentityResponse(BaseModel): + agentId: str + identity: dict[str, Any] = Field(default_factory=dict) + workspace: str | None = None + identityFile: str | None = None + + +@router.post("/agents/add") +async def api_openclaw_agents_add( + name: str, + *, + workspace: str | None = None, + model: str | None = None, + agent_dir: str | None = None, + bind: list[str] | None = None, + non_interactive: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> AgentAddResponse: + """Run `openclaw agents add ` and return JSON result.""" + try: + result = service.agents_add( + name, + workspace=workspace, + model=model, + agent_dir=agent_dir, + bind=bind, + non_interactive=non_interactive, + ) + return AgentAddResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.post("/agents/delete/{id}") +async def api_openclaw_agents_delete( + id: str, + force: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> AgentDeleteResponse: + """Run `openclaw agents delete [--force]` and return JSON result.""" + try: + result = service.agents_delete(id, force=force) + return AgentDeleteResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.post("/agents/bind") +async def api_openclaw_agents_bind( + *, + agent: str | None = None, + bind: list[str] | None = None, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> AgentBindResponse: + """Run `openclaw agents bind [--agent ] [--bind ]` and return JSON result.""" + try: + result = service.agents_bind(agent=agent, bind=bind) + return AgentBindResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.post("/agents/unbind") +async def api_openclaw_agents_unbind( + *, + agent: str | None = None, + bind: list[str] | None = None, + all: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> AgentUnbindResponse: + """Run `openclaw agents unbind [--agent ] [--bind ] [--all]` and return JSON result.""" + try: + result = service.agents_unbind(agent=agent, bind=bind, all=all) + return AgentUnbindResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.post("/agents/set-identity") +async def api_openclaw_agents_set_identity( + *, + agent: str | None = None, + workspace: str | None = None, + identity_file: str | None = None, + name: str | None = None, + emoji: str | None = None, + theme: str | None = None, + avatar: str | None = None, + from_identity: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> AgentIdentityResponse: + """Run `openclaw agents set-identity` and return JSON result.""" + try: + result = service.agents_set_identity( + agent=agent, + workspace=workspace, + identity_file=identity_file, + name=name, + emoji=emoji, + theme=theme, + avatar=avatar, + from_identity=from_identity, + ) + return AgentIdentityResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/skills") +async def api_openclaw_skills( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> SkillsResponse: + """Read `openclaw skills list --json` and return a typed SkillStatusReport.""" + try: + result = service.list_skills_model() + return SkillsResponse( + workspace_dir=result.workspace_dir, + managed_skills_dir=result.managed_skills_dir, + skills=list(result.skills), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/models") +async def api_openclaw_models( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ModelsResponse: + """Read `openclaw models list --json` and return a typed ModelsList.""" + try: + result = service.list_models_model() + return ModelsResponse(models=list(result.models)) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/hooks") +async def api_openclaw_hooks( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> HooksResponse: + try: + result = service.list_hooks_model() + return HooksResponse( + workspace_dir=result.workspace_dir, + managed_hooks_dir=result.managed_hooks_dir, + hooks=list(result.hooks), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/plugins") +async def api_openclaw_plugins( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> PluginsResponse: + try: + result = service.list_plugins_model() + return PluginsResponse( + workspace_dir=result.workspace_dir, + plugins=list(result.plugins), + diagnostics=list(result.diagnostics), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/secrets-audit") +async def api_openclaw_secrets_audit( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> SecretsAuditResponse: + try: + result = service.secrets_audit_model() + return SecretsAuditResponse( + version=result.version, + status=result.status, + findings=list(result.findings), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/security-audit") +async def api_openclaw_security_audit( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> SecurityAuditResponse2: + try: + result = service.security_audit_model() + return SecurityAuditResponse2( + report=result.report.model_dump() if result.report else None, + secret_diagnostics=list(result.secret_diagnostics), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/daemon-status") +async def api_openclaw_daemon_status( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> DaemonStatusResponse: + try: + result = service.daemon_status_model() + return DaemonStatusResponse( + service=result.service.model_dump() if result.service else None, + port=result.port.model_dump() if result.port else None, + rpc=result.rpc.model_dump() if result.rpc else None, + health=result.health.model_dump() if result.health else None, + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/pairing") +async def api_openclaw_pairing( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> PairingListResponse2: + try: + result = service.pairing_list_model() + return PairingListResponse2( + channel=result.channel, + requests=list(result.requests), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/qr") +async def api_openclaw_qr( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> QrCodeResponse2: + try: + result = service.qr_code_model() + return QrCodeResponse2( + setup_code=result.setup_code, + gateway_url=result.gateway_url, + auth=result.auth, + url_source=result.url_source, + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/update-status") +async def api_openclaw_update_status( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> UpdateStatusResponse2: + try: + result = service.update_status_model() + return UpdateStatusResponse2( + update=result.update.model_dump() if result.update else None, + channel=result.channel.model_dump() if result.channel else None, + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/models-aliases") +async def api_openclaw_models_aliases( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ModelAliasesResponse: + try: + result = service.list_model_aliases_model() + return ModelAliasesResponse(aliases=result.aliases) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/models-fallbacks") +async def api_openclaw_models_fallbacks( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ModelFallbacksResponse: + try: + result = service.list_model_fallbacks_model() + return ModelFallbacksResponse( + key=result.key, + label=result.label, + items=list(result.items), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/models-image-fallbacks") +async def api_openclaw_models_image_fallbacks( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ModelFallbacksResponse: + try: + result = service.list_model_image_fallbacks_model() + return ModelFallbacksResponse( + key=result.key, + label=result.label, + items=list(result.items), + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/skill-update") +async def api_openclaw_skill_update( + slug: str | None = None, + all: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> SkillUpdateResponse: + try: + result = service.skill_update_model(slug=slug, all=all) + return SkillUpdateResponse( + ok=result.ok, + slug=result.slug, + version=result.version, + error=result.error, + ) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/models-status") +async def api_openclaw_models_status( + probe: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ModelsStatusResponse: + """Read `openclaw models status --json [--probe]` and return a typed dict.""" + try: + result = service.models_status_model(probe=probe) + return ModelsStatusResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/channels-status") +async def api_openclaw_channels_status( + probe: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ChannelsStatusResponse: + """Read `openclaw channels status --json [--probe]` and return a typed dict.""" + try: + result = service.channels_status_model(probe=probe) + return ChannelsStatusResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/channels-list") +async def api_openclaw_channels_list( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> ChannelsListResponse: + """Read `openclaw channels list --json` and return a typed dict.""" + try: + result = service.channels_list_model() + return ChannelsListResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/hooks/info/{name}") +async def api_openclaw_hook_info( + name: str, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> HookInfoResponse: + """Read `openclaw hooks info --json` and return a typed dict.""" + try: + result = service.hook_info_model(name) + return HookInfoResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/hooks/check") +async def api_openclaw_hooks_check( + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> HooksCheckResponse: + """Read `openclaw hooks check --json` and return a typed dict.""" + try: + result = service.hooks_check_model() + return HooksCheckResponse.model_validate(result, strict=False) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/plugins-inspect") +async def api_openclaw_plugins_inspect( + plugin_id: str | None = None, + all: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> PluginsInspectResponse: + """Read `openclaw plugins inspect --json [--all]` and return a typed dict.""" + try: + result = service.plugins_inspect_model(plugin_id=plugin_id, all=all) + inspect = result if isinstance(result, list) else result.get("inspect", []) + return PluginsInspectResponse(inspect=inspect) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +class AgentBindingItem(BaseModel): + agentId: str + match: dict[str, Any] + description: str + + +class AgentsBindingsResponse(BaseModel): + bindings: list[AgentBindingItem] + + +@router.get("/agents-bindings") +async def api_openclaw_agents_bindings( + agent: str | None = None, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> AgentsBindingsResponse: + """Read `openclaw agents bindings --json [--agent ]` and return bindings list.""" + try: + result = service.agents_bindings_model(agent=agent) + bindings = result if isinstance(result, list) else [] + return AgentsBindingsResponse(bindings=bindings) + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/gateway-status") +async def api_openclaw_gateway_status( + url: str | None = None, + token: str | None = None, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> dict[str, Any]: + """Read `openclaw gateway status --json [--url ] [--token ]`. Returns full gateway probe result.""" + try: + result = service.gateway_status(url=url, token=token) + return result + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +@router.get("/memory-status") +async def api_openclaw_memory_status( + agent: str | None = None, + deep: bool = False, + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> list[dict[str, Any]]: + """Read `openclaw memory status --json [--agent ] [--deep]`. Returns array of per-agent memory status.""" + try: + result = service.memory_status(agent=agent, deep=deep) + return result if isinstance(result, list) else [] + except OpenClawCliError as exc: + _raise_cli_http_error(exc) + + +class WorkspaceFilesResponse(BaseModel): + workspace: str + files: list[dict[str, Any]] + error: str | None = None + + +@router.get("/workspace-files") +async def api_openclaw_workspace_files( + workspace: str = Query(..., description="Path to the agent workspace directory"), + service: OpenClawCliService = Depends(get_openclaw_cli_service), +) -> WorkspaceFilesResponse: + """List .md files in an OpenClaw agent workspace with their content previews.""" + result = service.list_workspace_files(workspace) + return WorkspaceFilesResponse.model_validate(result, strict=False) diff --git a/backend/apps/openclaw_service.py b/backend/apps/openclaw_service.py new file mode 100644 index 0000000..bc29e7f --- /dev/null +++ b/backend/apps/openclaw_service.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Read-only OpenClaw CLI FastAPI surface.""" + +from __future__ import annotations + +from fastapi import Depends, FastAPI + +from backend.api import openclaw_router +from backend.apps.cors import add_cors_middleware +from backend.api.openclaw import get_openclaw_cli_service + + +def create_app() -> FastAPI: + """Create the OpenClaw service app.""" + app = FastAPI( + title="EvoTraders OpenClaw Service", + description="Read-only OpenClaw CLI integration service surface", + version="0.1.0", + ) + + add_cors_middleware(app) + + @app.get("/health") + async def health_check( + service=Depends(get_openclaw_cli_service), + ) -> dict[str, object]: + return service.health() + + @app.get("/api/status") + async def api_status( + service=Depends(get_openclaw_cli_service), + ) -> dict[str, object]: + return { + "status": "operational", + "service": "openclaw-service", + "openclaw": service.health(), + } + + app.include_router(openclaw_router) + return app + + +app = create_app() + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8004) diff --git a/backend/gateway_server.py b/backend/gateway_server.py index cd9b5eb..896f21d 100644 --- a/backend/gateway_server.py +++ b/backend/gateway_server.py @@ -72,14 +72,16 @@ class SuppressNoisyInfoFilter(logging.Filter): """Filter out low-signal library INFO logs while keeping warnings/errors.""" def filter(self, record: logging.LogRecord) -> bool: - if record.levelno >= logging.WARNING: - return True - message = record.getMessage() if record.name == "httpx" and message.startswith("HTTP Request:"): return False if record.name.startswith("websockets") and "connection open" in message: return False + if record.name.startswith("websockets") and "opening handshake failed" in message: + return False + + if record.levelno >= logging.WARNING: + return True return True diff --git a/backend/main.py b/backend/main.py index b1356a8..9a9d5af 100644 --- a/backend/main.py +++ b/backend/main.py @@ -29,6 +29,7 @@ from backend.runtime.manager import ( set_global_runtime_manager, clear_global_runtime_manager, ) +from backend.gateway_server import configure_gateway_logging from backend.services.gateway import Gateway from backend.services.market import MarketService from backend.services.storage import StorageService @@ -38,6 +39,7 @@ load_dotenv() logger = logging.getLogger(__name__) loguru.logger.disable("flowllm") loguru.logger.disable("reme_ai") +configure_gateway_logging(verbose=os.getenv("LOG_LEVEL", "").upper() == "DEBUG") _prompt_loader = get_prompt_loader() diff --git a/backend/services/gateway.py b/backend/services/gateway.py index 894f1d9..44473ad 100644 --- a/backend/services/gateway.py +++ b/backend/services/gateway.py @@ -26,10 +26,12 @@ from backend.tools.technical_signals import StockTechnicalAnalyzer from backend.core.scheduler import Scheduler from backend.services import gateway_admin_handlers from backend.services import gateway_cycle_support +from backend.services import gateway_openclaw_handlers from backend.services import gateway_runtime_support from backend.services import gateway_stock_handlers from shared.client import NewsServiceClient from shared.client import TradingServiceClient +from shared.client.openclaw_websocket_client import OpenClawWebSocketClient, DEFAULT_GATEWAY_URL as OPENCLAW_WS_URL logger = logging.getLogger(__name__) EDITABLE_AGENT_WORKSPACE_FILES = { @@ -92,6 +94,7 @@ class Gateway: self._loop: Optional[asyncio.AbstractEventLoop] = None self._project_root = Path(__file__).resolve().parents[2] self._technical_analyzer = StockTechnicalAnalyzer() + self._openclaw_ws: OpenClawWebSocketClient | None = None async def start(self, host: str = "0.0.0.0", port: int = 8766): """Start gateway server with proper initialization order. @@ -185,6 +188,20 @@ class Gateway: # Give a brief moment for any existing clients to reconnect await asyncio.sleep(0.1) + # Connect to OpenClaw Gateway (18789) via WebSocket + logger.info("Connecting to OpenClaw Gateway...") + try: + self._openclaw_ws = OpenClawWebSocketClient( + url=OPENCLAW_WS_URL, + client_name="gateway-client", + client_version="1.0.0", + ) + await self._openclaw_ws.connect() + logger.info("OpenClaw Gateway WebSocket connected") + except Exception as e: + logger.warning("Failed to connect to OpenClaw Gateway: %s", e) + self._openclaw_ws = None + # ====================================================================== # PHASE 2: Start market data service # Now frontend is connected, start pushing price updates @@ -434,6 +451,54 @@ class Gateway: await self._handle_get_stock_technical_indicators(websocket, data) elif msg_type == "run_stock_enrich": await self._handle_run_stock_enrich(websocket, data) + elif msg_type == "get_openclaw_status": + await self._handle_get_openclaw_status(websocket, data) + elif msg_type == "get_openclaw_sessions": + await self._handle_get_openclaw_sessions(websocket, data) + elif msg_type == "get_openclaw_session_detail": + await self._handle_get_openclaw_session_detail(websocket, data) + elif msg_type == "get_openclaw_session_history": + await self._handle_get_openclaw_session_history(websocket, data) + elif msg_type == "get_openclaw_cron": + await self._handle_get_openclaw_cron(websocket, data) + elif msg_type == "get_openclaw_approvals": + await self._handle_get_openclaw_approvals(websocket, data) + elif msg_type == "get_openclaw_agents": + await self._handle_get_openclaw_agents(websocket, data) + elif msg_type == "get_openclaw_agents_presence": + await self._handle_get_openclaw_agents_presence(websocket, data) + elif msg_type == "get_openclaw_skills": + await self._handle_get_openclaw_skills(websocket, data) + elif msg_type == "get_openclaw_models": + await self._handle_get_openclaw_models(websocket, data) + elif msg_type == "get_openclaw_hooks": + await gateway_openclaw_handlers.handle_get_openclaw_hooks(self, websocket, data) + elif msg_type == "get_openclaw_plugins": + await gateway_openclaw_handlers.handle_get_openclaw_plugins(self, websocket, data) + elif msg_type == "get_openclaw_secrets_audit": + await gateway_openclaw_handlers.handle_get_openclaw_secrets_audit(self, websocket, data) + elif msg_type == "get_openclaw_security_audit": + await gateway_openclaw_handlers.handle_get_openclaw_security_audit(self, websocket, data) + elif msg_type == "get_openclaw_daemon_status": + await gateway_openclaw_handlers.handle_get_openclaw_daemon_status(self, websocket, data) + elif msg_type == "get_openclaw_pairing": + await gateway_openclaw_handlers.handle_get_openclaw_pairing(self, websocket, data) + elif msg_type == "get_openclaw_qr": + await gateway_openclaw_handlers.handle_get_openclaw_qr(self, websocket, data) + elif msg_type == "get_openclaw_update_status": + await gateway_openclaw_handlers.handle_get_openclaw_update_status(self, websocket, data) + elif msg_type == "get_openclaw_models_aliases": + await gateway_openclaw_handlers.handle_get_openclaw_models_aliases(self, websocket, data) + elif msg_type == "get_openclaw_models_fallbacks": + await gateway_openclaw_handlers.handle_get_openclaw_models_fallbacks(self, websocket, data) + elif msg_type == "get_openclaw_models_image_fallbacks": + await gateway_openclaw_handlers.handle_get_openclaw_models_image_fallbacks(self, websocket, data) + elif msg_type == "get_openclaw_skill_update": + await gateway_openclaw_handlers.handle_get_openclaw_skill_update(self, websocket, data) + elif msg_type == "get_openclaw_workspace_files": + await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data) + elif msg_type == "get_openclaw_workspace_file": + await gateway_openclaw_handlers.handle_get_openclaw_workspace_file(self, websocket, data) except websockets.ConnectionClosed: pass @@ -669,6 +734,83 @@ class Gateway: ) -> None: await gateway_admin_handlers.handle_update_agent_workspace_file(self, websocket, data) + async def _handle_get_openclaw_status( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_status(self, websocket, data) + + async def _handle_get_openclaw_sessions( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_sessions(self, websocket, data) + + async def _handle_get_openclaw_session_detail( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_session_detail(self, websocket, data) + + async def _handle_get_openclaw_session_history( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_session_history(self, websocket, data) + + async def _handle_get_openclaw_cron( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_cron(self, websocket, data) + + async def _handle_get_openclaw_approvals( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_approvals(self, websocket, data) + + async def _handle_get_openclaw_agents( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_agents(self, websocket, data) + + async def _handle_get_openclaw_agents_presence( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_agents_presence(self, websocket, data) + + async def _handle_get_openclaw_skills( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_skills(self, websocket, data) + + async def _handle_get_openclaw_models( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_models(self, websocket, data) + + async def _handle_get_openclaw_workspace_files( + self, + websocket: ServerConnection, + data: Dict[str, Any], + ) -> None: + await gateway_openclaw_handlers.handle_get_openclaw_workspace_files(self, websocket, data) + @staticmethod def _normalize_watchlist(raw_tickers: Any) -> List[str]: return gateway_runtime_support.normalize_watchlist(raw_tickers) diff --git a/backend/services/gateway_openclaw_handlers.py b/backend/services/gateway_openclaw_handlers.py new file mode 100644 index 0000000..bb3f733 --- /dev/null +++ b/backend/services/gateway_openclaw_handlers.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +"""OpenClaw WebSocket handlers — gateway calls OpenClaw Gateway via WebSocket.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backend.services.gateway import Gateway + +logger = logging.getLogger(__name__) + + +def _get_ws_client(gateway) -> "OpenClawWebSocketClient": + """Get the OpenClaw WebSocket client from gateway.""" + from shared.client.openclaw_websocket_client import OpenClawWebSocketClient + client = gateway._openclaw_ws + if client is None: + raise RuntimeError("OpenClaw Gateway not connected") + return client + + +async def _ws_call(gateway, method: str, params: dict | None = None) -> dict: + """Call OpenClaw Gateway via WebSocket and return result.""" + try: + client = _get_ws_client(gateway) + return await client.call_method(method, params) + except Exception as exc: + logger.warning("OpenClaw Gateway call failed for %s: %s", method, exc) + return {"error": str(exc)[:200]} + + +async def handle_get_openclaw_status(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "status") + await websocket.send(json.dumps({"type": "openclaw_status_loaded", "data": result})) + + +async def handle_get_openclaw_sessions(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "sessions.list", {"limit": 50, "includeLastMessage": True}) + await websocket.send(json.dumps({"type": "openclaw_sessions_loaded", "data": result})) + + +async def handle_get_openclaw_session_detail(gateway, websocket, data: dict) -> None: + session_key = data.get("session_key", "") + result = await _ws_call(gateway, "sessions.list", {"limit": 1, "agentId": session_key.split(":")[1] if session_key else None}) + await websocket.send(json.dumps({ + "type": "openclaw_session_detail_loaded", + "data": result, + "session_key": session_key, + })) + + +async def handle_get_openclaw_session_history(gateway, websocket, data: dict) -> None: + session_key = data.get("session_key", "") + limit = data.get("limit", 20) + result = await _ws_call(gateway, "sessions.list", {"limit": limit}) + await websocket.send(json.dumps({ + "type": "openclaw_session_history_loaded", + "data": result, + "session_key": session_key, + })) + + +async def handle_get_openclaw_cron(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "cron.list") + await websocket.send(json.dumps({"type": "openclaw_cron_loaded", "data": result})) + + +async def handle_get_openclaw_approvals(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "exec.approvals.get") + await websocket.send(json.dumps({"type": "openclaw_approvals_loaded", "data": result})) + + +async def handle_get_openclaw_agents(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "agents.list") + await websocket.send(json.dumps({"type": "openclaw_agents_loaded", "data": result})) + + +async def handle_get_openclaw_agents_presence(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "node.list") + await websocket.send(json.dumps({"type": "openclaw_agents_presence_loaded", "data": result})) + + +async def handle_get_openclaw_skills(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "skills.status") + await websocket.send(json.dumps({"type": "openclaw_skills_loaded", "data": result})) + + +async def handle_get_openclaw_models(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "models.list") + await websocket.send(json.dumps({"type": "openclaw_models_loaded", "data": result})) + + +async def handle_get_openclaw_hooks(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "tools.catalog") + await websocket.send(json.dumps({"type": "openclaw_hooks_loaded", "data": result})) + + +async def handle_get_openclaw_plugins(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "config.get") + await websocket.send(json.dumps({"type": "openclaw_plugins_loaded", "data": result})) + + +async def handle_get_openclaw_secrets_audit(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "secrets.reload") + await websocket.send(json.dumps({"type": "openclaw_secrets_audit_loaded", "data": result})) + + +async def handle_get_openclaw_security_audit(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "gateway.identity.get") + await websocket.send(json.dumps({"type": "openclaw_security_audit_loaded", "data": result})) + + +async def handle_get_openclaw_daemon_status(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "doctor.memory.status") + await websocket.send(json.dumps({"type": "openclaw_daemon_status_loaded", "data": result})) + + +async def handle_get_openclaw_pairing(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "device.pair.list") + await websocket.send(json.dumps({"type": "openclaw_pairing_loaded", "data": result})) + + +async def handle_get_openclaw_qr(gateway, websocket, data: dict) -> None: + await websocket.send(json.dumps({"type": "openclaw_qr_loaded", "data": {"error": "QR code not available via WebSocket"}})) + + +async def handle_get_openclaw_update_status(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "update.run") + await websocket.send(json.dumps({"type": "openclaw_update_status_loaded", "data": result})) + + +async def handle_get_openclaw_models_aliases(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "models.list") + await websocket.send(json.dumps({"type": "openclaw_models_aliases_loaded", "data": result})) + + +async def handle_get_openclaw_models_fallbacks(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "models.list") + await websocket.send(json.dumps({"type": "openclaw_models_fallbacks_loaded", "data": result})) + + +async def handle_get_openclaw_models_image_fallbacks(gateway, websocket, data: dict) -> None: + result = await _ws_call(gateway, "models.list") + await websocket.send(json.dumps({"type": "openclaw_models_image_fallbacks_loaded", "data": result})) + + +async def handle_get_openclaw_skill_update(gateway, websocket, data: dict) -> None: + slug = data.get("slug") + all_flag = data.get("all", False) + params = {} + if slug is not None: + params["slug"] = slug + if all_flag: + params["all"] = "true" + result = await _ws_call(gateway, "skills.update", params) + await websocket.send(json.dumps({"type": "openclaw_skill_update_loaded", "data": result})) + + +async def handle_get_openclaw_workspace_files(gateway, websocket, data: dict) -> None: + raw_workspace = data.get("workspace", "") + # Use the workspace param (which is actually the agent.id from frontend) as agent_id + agent_id = raw_workspace or "main" + result = await _ws_call(gateway, "agents.files.list", {"agentId": agent_id}) + if isinstance(result, dict): + result["workspace"] = agent_id + await websocket.send(json.dumps({"type": "openclaw_workspace_files_loaded", "data": result})) + + +async def handle_get_openclaw_workspace_file(gateway, websocket, data: dict) -> None: + agent_id = data.get("agent_id", "main") + file_name = data.get("file_name", "") + if not file_name: + await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": {"error": "file_name is required"}})) + return + result = await _ws_call(gateway, "agents.files.get", {"agentId": agent_id, "name": file_name}) + await websocket.send(json.dumps({"type": "openclaw_workspace_file_loaded", "data": result})) diff --git a/backend/services/openclaw_cli.py b/backend/services/openclaw_cli.py new file mode 100644 index 0000000..9232f1e --- /dev/null +++ b/backend/services/openclaw_cli.py @@ -0,0 +1,754 @@ +# -*- coding: utf-8 -*- +"""Thin service wrapper around the OpenClaw CLI.""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from shared.models.openclaw import ( + AgentSummary, + AgentsList, + ApprovalRequest, + ApprovalsList, + CronJob, + CronList, + DaemonStatus, + HookStatusEntry, + HookStatusReport, + ModelAliasesList, + ModelFallbacksList, + ModelRow, + ModelsList, + OpenClawStatus, + PairingListResponse, + PluginDiagnostic, + PluginRecord, + PluginsList, + QrCodeResponse, + SecretsAuditReport, + SecurityAuditResponse, + SecurityAuditReport, + SessionEntry, + SessionHistory, + SessionsList, + SkillStatusEntry, + SkillStatusReport, + SkillUpdateResult, + UpdateCheckResult, + UpdateStatusResponse, + normalize_agents, + normalize_approvals, + normalize_cron_jobs, + normalize_daemon_status, + normalize_hooks, + normalize_model_aliases, + normalize_model_fallbacks, + normalize_models, + normalize_pairing, + normalize_plugins, + normalize_qr, + normalize_security_audit, + normalize_secrets_audit, + normalize_session_history, + normalize_sessions, + normalize_skill_update, + normalize_skills, + normalize_status, + normalize_update_status, +) + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +REFERENCE_OPENCLAW_ROOT = PROJECT_ROOT / "reference" / "openclaw" +REFERENCE_OPENCLAW_ENTRY = REFERENCE_OPENCLAW_ROOT / "openclaw.mjs" + + +class OpenClawCliError(RuntimeError): + """Raised when the OpenClaw CLI invocation fails.""" + + def __init__( + self, + message: str, + *, + command: list[str], + exit_code: int | None = None, + stdout: str = "", + stderr: str = "", + ) -> None: + super().__init__(message) + self.command = command + self.exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + + +@dataclass(frozen=True) +class OpenClawCliResult: + """Command execution result.""" + + command: list[str] + exit_code: int + stdout: str + stderr: str + + +def resolve_openclaw_base_command() -> list[str]: + """Resolve the command prefix used to launch OpenClaw.""" + explicit = os.getenv("OPENCLAW_CMD", "").strip() + if explicit: + return shlex.split(explicit) + + installed = shutil.which("openclaw") + if installed: + return [installed] + + if REFERENCE_OPENCLAW_ENTRY.exists(): + return [sys.executable if sys.executable.endswith("node") else "node", str(REFERENCE_OPENCLAW_ENTRY)] + + return ["openclaw"] + + +def resolve_openclaw_cwd() -> Path: + """Resolve the working directory for CLI execution.""" + explicit = os.getenv("OPENCLAW_CWD", "").strip() + if explicit: + return Path(explicit).expanduser() + if REFERENCE_OPENCLAW_ROOT.exists(): + return REFERENCE_OPENCLAW_ROOT + return PROJECT_ROOT + + +class OpenClawCliService: + """OpenClaw CLI integration service.""" + + def __init__( + self, + *, + base_command: list[str] | None = None, + cwd: Path | None = None, + timeout_seconds: float | None = None, + ) -> None: + self.base_command = list(base_command or resolve_openclaw_base_command()) + self.cwd = cwd or resolve_openclaw_cwd() + self.timeout_seconds = timeout_seconds or float( + os.getenv("OPENCLAW_TIMEOUT_SECONDS", "15") + ) + + def health(self) -> dict[str, Any]: + """Return the current CLI wiring state.""" + binary = self.base_command[0] if self.base_command else "openclaw" + resolved = shutil.which(binary) if len(self.base_command) == 1 else binary + return { + "status": "healthy", + "service": "openclaw-service", + "base_command": self.base_command, + "cwd": str(self.cwd), + "binary_resolved": resolved is not None, + "reference_entry_available": REFERENCE_OPENCLAW_ENTRY.exists(), + "timeout_seconds": self.timeout_seconds, + } + + def status(self) -> dict[str, Any]: + """Read `openclaw status --json`.""" + return self.run_json(["status", "--json"]) + + def list_sessions(self) -> dict[str, Any]: + """Read `openclaw sessions --json`.""" + return self.run_json(["sessions", "--json"]) + + def get_session(self, session_key: str) -> dict[str, Any]: + """Resolve a single session out of the sessions list.""" + payload = self.list_sessions() + sessions = payload.get("sessions") or [] + for item in sessions: + if not isinstance(item, dict): + continue + if item.get("key") == session_key or item.get("sessionKey") == session_key: + return item + raise KeyError(session_key) + + def get_session_history(self, session_key: str, *, limit: int = 20) -> dict[str, Any]: + """Read session history with a JSON-first fallback to raw text.""" + args = ["sessions", "history", session_key, "--json", "--limit", str(limit)] + try: + return self.run_json(args) + except OpenClawCliError as exc: + raise exc + except json.JSONDecodeError: + result = self.run(args) + return { + "sessionKey": session_key, + "limit": limit, + "rawText": result.stdout, + } + + def list_cron_jobs(self) -> dict[str, Any]: + """Read `openclaw cron list --json`.""" + return self.run_json(["cron", "list", "--json"]) + + def list_approvals(self) -> dict[str, Any]: + """Read `openclaw approvals get --json`.""" + return self.run_json(["approvals", "get", "--json"]) + + def list_agents(self) -> dict[str, Any]: + """Read `openclaw agents list --json`.""" + return self.run_json(["agents", "list", "--json"]) + + def list_skills(self) -> dict[str, Any]: + """Read `openclaw skills list --json`.""" + return self.run_json(["skills", "list", "--json"]) + + def list_models(self) -> dict[str, Any]: + """Read `openclaw models list --json`.""" + return self.run_json(["models", "list", "--json"]) + + def list_hooks(self) -> dict[str, Any]: + """Read `openclaw hooks list --json`.""" + return self.run_json(["hooks", "list", "--json"]) + + def list_plugins(self) -> dict[str, Any]: + """Read `openclaw plugins list --json`.""" + return self.run_json(["plugins", "list", "--json"]) + + def secrets_audit(self) -> dict[str, Any]: + """Read `openclaw secrets audit --json`.""" + return self.run_json(["secrets", "audit", "--json"]) + + def security_audit(self) -> dict[str, Any]: + """Read `openclaw security audit --json`.""" + return self.run_json(["security", "audit", "--json"]) + + def daemon_status(self) -> dict[str, Any]: + """Read `openclaw daemon status --json`.""" + return self.run_json(["daemon", "status", "--json"]) + + def pairing_list(self) -> dict[str, Any]: + """Read `openclaw pairing list --json`.""" + return self.run_json(["pairing", "list", "--json"]) + + def qr_code(self) -> dict[str, Any]: + """Read `openclaw qr --json`.""" + return self.run_json(["qr", "--json"]) + + def update_status(self) -> dict[str, Any]: + """Read `openclaw update status --json`.""" + return self.run_json(["update", "status", "--json"]) + + def list_model_aliases(self) -> dict[str, Any]: + """Read `openclaw models aliases list --json`.""" + return self.run_json(["models", "aliases", "list", "--json"]) + + def list_model_fallbacks(self) -> dict[str, Any]: + """Read `openclaw models fallbacks list --json`.""" + return self.run_json(["models", "fallbacks", "list", "--json"]) + + def list_model_image_fallbacks(self) -> dict[str, Any]: + """Read `openclaw models image-fallbacks list --json`.""" + return self.run_json(["models", "image-fallbacks", "list", "--json"]) + + def skill_update(self, *, slug: str | None = None, all: bool = False) -> dict[str, Any]: + """Read `openclaw skills update --json`.""" + args = ["skills", "update", "--json"] + if slug: + args.append(slug) + if all: + args.append("--all") + return self.run_json(args) + + def models_status(self, *, probe: bool = False) -> dict[str, Any]: + """Read `openclaw models status --json [--probe]`.""" + args = ["models", "status", "--json"] + if probe: + args.append("--probe") + return self.run_json(args) + + def channels_status(self, *, probe: bool = False) -> dict[str, Any]: + """Read `openclaw channels status [--probe] --json`.""" + args = ["channels", "status", "--json"] + if probe: + args.append("--probe") + return self.run_json(args) + + def list_workspace_files(self, workspace_path: str) -> dict[str, Any]: + """List .md files in an OpenClaw agent workspace with their content. + + Reads the workspace directory and returns metadata + content for each .md file. + """ + import json + from pathlib import Path + + wp = Path(workspace_path).expanduser().resolve() + if not wp.exists() or not wp.is_dir(): + return {"workspace": str(wp), "files": [], "error": "workspace not found"} + + md_files = sorted(wp.glob("*.md")) + files = [] + for md_file in md_files: + try: + content = md_file.read_text(encoding="utf-8") + # Preview: first 300 chars + preview = content[:300].strip() + files.append({ + "name": md_file.name, + "path": str(md_file), + "size": len(content), + "preview": preview, + "previewTruncated": len(content) > 300, + }) + except OSError as exc: + files.append({ + "name": md_file.name, + "path": str(md_file), + "size": 0, + "preview": "", + "error": str(exc), + }) + + return {"workspace": str(wp), "files": files} + + def channels_list(self) -> dict[str, Any]: + """Read `openclaw channels list --json`.""" + return self.run_json(["channels", "list", "--json"]) + + def hook_info(self, name: str) -> dict[str, Any]: + """Read `openclaw hooks info --json`.""" + args = ["hooks", "info", name, "--json"] + try: + return self.run_json(args) + except json.JSONDecodeError: + result = self.run(args) + return {"raw": result.stdout} + + def hooks_check(self) -> dict[str, Any]: + """Read `openclaw hooks check --json`.""" + return self.run_json(["hooks", "check", "--json"]) + + def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]: + """Read `openclaw plugins inspect [--json] [--all]`.""" + args = ["plugins", "inspect", "--json"] + if all: + args.append("--all") + elif plugin_id: + args.append(plugin_id) + return self.run_json(args) + + # ------------------------------------------------------------------------- + # Typed variants — these use Pydantic models and are the preferred path. + # ------------------------------------------------------------------------- + + def status_model(self) -> OpenClawStatus: + """Read and parse `openclaw status --json` into a typed model.""" + raw = self.status() + return normalize_status(raw) + + def list_sessions_model(self) -> SessionsList: + """Read and parse `openclaw sessions --json` into a typed model.""" + raw = self.list_sessions() + return normalize_sessions(raw) + + def get_session_model(self, session_key: str) -> SessionEntry: + """Resolve a single session and return a typed model.""" + raw = self.get_session(session_key) + return SessionEntry.model_validate(raw, strict=False) + + def get_session_history_model(self, session_key: str, *, limit: int = 20) -> SessionHistory: + """Read session history and return a typed model.""" + raw = self.get_session_history(session_key, limit=limit) + return normalize_session_history(raw, session_key=session_key) + + def list_cron_jobs_model(self) -> CronList: + """Read and parse `openclaw cron list --json` into a typed model.""" + raw = self.list_cron_jobs() + return normalize_cron_jobs(raw) + + def list_approvals_model(self) -> ApprovalsList: + """Read and parse `openclaw approvals get --json` into a typed model.""" + raw = self.list_approvals() + return normalize_approvals(raw) + + # ------------------------------------------------------------------------- + # Typed variants + # ------------------------------------------------------------------------- + + def list_agents_model(self) -> AgentsList: + """Read and parse `openclaw agents list --json` into a typed model.""" + raw = self.list_agents() + if isinstance(raw, list): + return AgentsList(agents=[AgentSummary.model_validate(a, strict=False) for a in raw if isinstance(a, dict)]) + return normalize_agents(raw) + + def list_skills_model(self) -> SkillStatusReport: + """Read and parse `openclaw skills list --json` into a typed model.""" + raw = self.list_skills() + return normalize_skills(raw) + + def list_models_model(self) -> ModelsList: + """Read and parse `openclaw models list --json` into a typed model.""" + raw = self.list_models() + if isinstance(raw, list): + return ModelsList(models=[ModelRow.model_validate(m, strict=False) for m in raw if isinstance(m, dict)]) + return normalize_models(raw) + + def list_hooks_model(self) -> HookStatusReport: + raw = self.list_hooks() + return normalize_hooks(raw) + + def list_plugins_model(self) -> PluginsList: + raw = self.list_plugins() + return normalize_plugins(raw) + + def secrets_audit_model(self) -> SecretsAuditReport: + raw = self.secrets_audit() + return normalize_secrets_audit(raw) + + def security_audit_model(self) -> SecurityAuditResponse: + raw = self.security_audit() + return normalize_security_audit(raw) + + def daemon_status_model(self) -> DaemonStatus: + raw = self.daemon_status() + return normalize_daemon_status(raw) + + def pairing_list_model(self) -> PairingListResponse: + raw = self.pairing_list() + return normalize_pairing(raw) + + def qr_code_model(self) -> QrCodeResponse: + raw = self.qr_code() + return normalize_qr(raw) + + def update_status_model(self) -> UpdateStatusResponse: + raw = self.update_status() + return normalize_update_status(raw) + + def list_model_aliases_model(self) -> ModelAliasesList: + raw = self.list_model_aliases() + return normalize_model_aliases(raw) + + def list_model_fallbacks_model(self) -> ModelFallbacksList: + raw = self.list_model_fallbacks() + return normalize_model_fallbacks(raw) + + def list_model_image_fallbacks_model(self) -> ModelFallbacksList: + raw = self.list_model_image_fallbacks() + return normalize_model_fallbacks(raw) + + def skill_update_model(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult: + raw = self.skill_update(slug=slug, all=all) + return normalize_skill_update(raw) + + def models_status_model(self, *, probe: bool = False) -> dict[str, Any]: + """Read `openclaw models status --json` and return the raw dict.""" + return self.models_status(probe=probe) + + def channels_status_model(self, *, probe: bool = False) -> dict[str, Any]: + """Read `openclaw channels status --json` and return the raw dict.""" + return self.channels_status(probe=probe) + + def channels_list_model(self) -> dict[str, Any]: + """Read `openclaw channels list --json` and return the raw dict.""" + return self.channels_list() + + def hook_info_model(self, name: str) -> dict[str, Any]: + """Read `openclaw hooks info --json` and return the raw dict.""" + return self.hook_info(name) + + def hooks_check_model(self) -> dict[str, Any]: + """Read `openclaw hooks check --json` and return the raw dict.""" + return self.hooks_check() + + def plugins_inspect_model(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]: + """Read `openclaw plugins inspect --json [--all]` and return the raw dict.""" + return self.plugins_inspect(plugin_id=plugin_id, all=all) + + def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]: + """Read `openclaw agents bindings --json [--agent ]`.""" + args = ["agents", "bindings", "--json"] + if agent: + args.extend(["--agent", agent]) + return self.run_json(args) + + def agents_bindings_model(self, *, agent: str | None = None) -> dict[str, Any]: + """Read `openclaw agents bindings --json` and return the raw dict.""" + return self.agents_bindings(agent=agent) + + def agents_presence(self) -> dict[str, Any]: + """Read session presence for all agents from runtime session files. + + Reads ~/.openclaw/agents/{agentId}/sessions/sessions.json for each agent + and counts sessions in active states within a recency window. + """ + import json + from pathlib import Path + + openclaw_home = Path.home() / ".openclaw" + agents_path = openclaw_home / "agents" + + if not agents_path.exists(): + return {"status": "not_connected", "agents": {}} + + ACTIVE_STATES = { + "running", "active", "busy", "blocked", "waiting_approval", + "working", "in_progress", "processing", "thinking", "executing", "streaming", + } + + RECENCY_WINDOW_MS = 45 * 60 * 1000 # 45 minutes + + result: dict[str, Any] = {"status": "connected", "agents": {}} + + try: + for agent_dir in agents_path.iterdir(): + if not agent_dir.is_dir(): + continue + sessions_file = agent_dir / "sessions" / "sessions.json" + if not sessions_file.exists(): + continue + + try: + sessions_data = json.loads(sessions_file.read_text()) + except (json.JSONDecodeError, OSError): + continue + + sessions = sessions_data if isinstance(sessions_data, list) else [] + now_ms = 0 # placeholder; we'll skip recency check if no ts field + + active_count = 0 + for session in sessions: + if not isinstance(session, dict): + continue + state = str(session.get("state") or session.get("status") or "").lower() + if state in ACTIVE_STATES: + active_count += 1 + + if active_count > 0: + result["agents"][agent_dir.name] = { + "activeSessions": active_count, + "status": "active", + } + else: + result["agents"][agent_dir.name] = { + "activeSessions": 0, + "status": "idle", + } + except OSError: + result["status"] = "partial" + + return result + + def agents_from_config(self) -> dict[str, Any]: + """Read agent list directly from openclaw.json config file. + + Falls back to scanning ~/.openclaw/agents/ directories when config is absent. + This avoids the CLI timeout from `agents list --json`. + """ + import json + + openclaw_home = Path.home() / ".openclaw" + config_path = openclaw_home / "openclaw.json" + + if not config_path.exists(): + return {"status": "not_connected", "agents": []} + + try: + raw = json.loads(config_path.read_text()) + except (json.JSONDecodeError, OSError): + return {"status": "partial", "agents": []} + + agents_list = raw.get("agents", {}).get("list", []) + if not agents_list: + return {"status": "partial", "agents": [], "detail": "agents.list is empty"} + + agents = [] + for entry in agents_list: + if not isinstance(entry, dict): + continue + agent_id = entry.get("id", "").strip() + if not agent_id: + continue + agents.append({ + "id": agent_id, + "name": entry.get("name", "").strip() or agent_id, + "model": entry.get("model") or "", + "workspace": entry.get("workspace") or "", + "is_default": entry.get("id") == raw.get("agents", {}).get("defaults", {}).get("id"), + }) + + return {"status": "connected", "agents": agents} + + def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]: + """Read `openclaw gateway status --json [--url ] [--token ]`. May fail if gateway is unreachable.""" + args = ["gateway", "status", "--json"] + if url: + args.extend(["--url", url]) + if token: + args.extend(["--token", token]) + return self.run_json(args) + + def memory_status(self, *, agent: str | None = None, deep: bool = False) -> dict[str, Any]: + """Read `openclaw memory status --json [--agent ] [--deep]`. Returns array of per-agent status.""" + args = ["memory", "status", "--json"] + if agent: + args.extend(["--agent", agent]) + if deep: + args.append("--deep") + return self.run_json(args) + + # ------------------------------------------------------------------------- + # Write agents commands + # ------------------------------------------------------------------------- + + def agents_add( + self, + name: str, + *, + workspace: str | None = None, + model: str | None = None, + agent_dir: str | None = None, + bind: list[str] | None = None, + non_interactive: bool = False, + ) -> dict[str, Any]: + """Run `openclaw agents add [--workspace ] [--model ] [--agent-dir ] [--bind ] [--non-interactive] --json`.""" + args = ["agents", "add", name, "--json"] + if workspace: + args.extend(["--workspace", workspace]) + if model: + args.extend(["--model", model]) + if agent_dir: + args.extend(["--agent-dir", agent_dir]) + if bind: + for b in bind: + args.extend(["--bind", b]) + if non_interactive: + args.append("--non-interactive") + return self.run_json(args) + + def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]: + """Run `openclaw agents delete [--force] --json`.""" + args = ["agents", "delete", id, "--json"] + if force: + args.append("--force") + return self.run_json(args) + + def agents_bind( + self, + *, + agent: str | None = None, + bind: list[str] | None = None, + ) -> dict[str, Any]: + """Run `openclaw agents bind [--agent ] [--bind ] --json`.""" + args = ["agents", "bind", "--json"] + if agent: + args.extend(["--agent", agent]) + if bind: + for b in bind: + args.extend(["--bind", b]) + return self.run_json(args) + + def agents_unbind( + self, + *, + agent: str | None = None, + bind: list[str] | None = None, + all: bool = False, + ) -> dict[str, Any]: + """Run `openclaw agents unbind [--agent ] [--bind ] [--all] --json`.""" + args = ["agents", "unbind", "--json"] + if agent: + args.extend(["--agent", agent]) + if bind: + for b in bind: + args.extend(["--bind", b]) + if all: + args.append("--all") + return self.run_json(args) + + def agents_set_identity( + self, + *, + agent: str | None = None, + workspace: str | None = None, + identity_file: str | None = None, + name: str | None = None, + emoji: str | None = None, + theme: str | None = None, + avatar: str | None = None, + from_identity: bool = False, + ) -> dict[str, Any]: + """Run `openclaw agents set-identity [--agent ] [--workspace ] [--identity-file ] [--from-identity] [--name ] [--emoji ] [--theme ] [--avatar ] --json`.""" + args = ["agents", "set-identity", "--json"] + if agent: + args.extend(["--agent", agent]) + if workspace: + args.extend(["--workspace", workspace]) + if identity_file: + args.extend(["--identity-file", identity_file]) + if from_identity: + args.append("--from-identity") + if name: + args.extend(["--name", name]) + if emoji: + args.extend(["--emoji", emoji]) + if theme: + args.extend(["--theme", theme]) + if avatar: + args.extend(["--avatar", avatar]) + return self.run_json(args) + + def run_json(self, args: list[str]) -> dict[str, Any]: + """Run the CLI and decode JSON stdout, falling back to stderr.""" + result = self.run(args) + text = result.stdout.strip() or result.stderr.strip() + if not text: + return {} + return json.loads(text) + + def run(self, args: list[str]) -> OpenClawCliResult: + """Run the CLI and return stdout/stderr.""" + command = [*self.base_command, *args] + env = os.environ.copy() + try: + completed = subprocess.run( + command, + cwd=self.cwd, + env=env, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + check=False, + ) + except FileNotFoundError as exc: + raise OpenClawCliError( + "OpenClaw CLI executable was not found.", + command=command, + ) from exc + except subprocess.TimeoutExpired as exc: + raise OpenClawCliError( + f"OpenClaw CLI timed out after {self.timeout_seconds:.1f}s.", + command=command, + stdout=exc.stdout or "", + stderr=exc.stderr or "", + ) from exc + + if completed.returncode != 0: + raise OpenClawCliError( + "OpenClaw CLI command failed.", + command=command, + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + return OpenClawCliResult( + command=command, + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) diff --git a/backend/tests/test_openclaw_cli_service.py b/backend/tests/test_openclaw_cli_service.py new file mode 100644 index 0000000..090c098 --- /dev/null +++ b/backend/tests/test_openclaw_cli_service.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""Tests for the OpenClaw CLI service wrapper.""" + +from pathlib import Path + +import pytest + +from backend.services.openclaw_cli import OpenClawCliError, OpenClawCliService + + +class _Completed: + def __init__(self, *, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def test_openclaw_cli_service_runs_json_command(monkeypatch, tmp_path): + captured = {} + + def _fake_run(command, **kwargs): + captured["command"] = command + captured["cwd"] = kwargs["cwd"] + return _Completed(stdout='{"sessions":[{"key":"main/session-1"}]}') + + monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run) + + service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3) + payload = service.list_sessions() + + assert payload["sessions"][0]["key"] == "main/session-1" + assert captured["command"] == ["openclaw", "sessions", "--json"] + assert captured["cwd"] == tmp_path + + +def test_openclaw_cli_service_raises_on_failure(monkeypatch, tmp_path): + def _fake_run(command, **kwargs): + return _Completed(returncode=7, stdout="", stderr="boom") + + monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run) + + service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3) + + with pytest.raises(OpenClawCliError) as exc_info: + service.list_cron_jobs() + + assert exc_info.value.exit_code == 7 + assert exc_info.value.stderr == "boom" + + +def test_openclaw_cli_service_can_extract_single_session(monkeypatch, tmp_path): + def _fake_run(command, **kwargs): + return _Completed(stdout='{"sessions":[{"key":"main/session-1","agentId":"main"}]}') + + monkeypatch.setattr("backend.services.openclaw_cli.subprocess.run", _fake_run) + + service = OpenClawCliService(base_command=["openclaw"], cwd=tmp_path, timeout_seconds=3) + session = service.get_session("main/session-1") + + assert session["agentId"] == "main" diff --git a/backend/tests/test_openclaw_service_app.py b/backend/tests/test_openclaw_service_app.py new file mode 100644 index 0000000..0f95627 --- /dev/null +++ b/backend/tests/test_openclaw_service_app.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""Tests for the extracted OpenClaw service app surface.""" + +from fastapi.testclient import TestClient + +from backend.apps.openclaw_service import create_app +from backend.api import openclaw as openclaw_module + + +class _FakeOpenClawCliService: + def health(self): + return { + "status": "healthy", + "service": "openclaw-service", + "base_command": ["openclaw"], + "cwd": "/tmp/openclaw", + "binary_resolved": True, + "reference_entry_available": True, + "timeout_seconds": 15.0, + } + + def status(self): + return {"runtimeVersion": "2026.3.24"} + + def list_sessions(self): + return { + "sessions": [ + {"key": "main/session-1", "agentId": "main"}, + {"key": "analyst/session-2", "agentId": "analyst"}, + ] + } + + def get_session(self, session_key: str): + for session in self.list_sessions()["sessions"]: + if session["key"] == session_key: + return session + raise KeyError(session_key) + + def get_session_history(self, session_key: str, *, limit: int = 20): + return { + "sessionKey": session_key, + "limit": limit, + "items": [{"role": "assistant", "text": "hello"}], + } + + def list_cron_jobs(self): + return {"jobs": [{"id": "job-1", "name": "Daily sync"}]} + + def list_approvals(self): + return {"approvals": [{"id": "ap-1", "status": "pending"}]} + + +def test_openclaw_service_routes_are_exposed(): + app = create_app() + paths = {route.path for route in app.routes} + + assert "/health" in paths + assert "/api/status" in paths + assert "/api/openclaw/status" in paths + assert "/api/openclaw/sessions" in paths + assert "/api/openclaw/sessions/{session_key:path}" in paths + assert "/api/openclaw/sessions/{session_key:path}/history" in paths + assert "/api/openclaw/cron" in paths + assert "/api/openclaw/approvals" in paths + + +def test_openclaw_service_read_routes(): + app = create_app() + app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = ( + lambda: _FakeOpenClawCliService() + ) + + with TestClient(app) as client: + health = client.get("/health") + status = client.get("/api/status") + openclaw_status = client.get("/api/openclaw/status") + sessions = client.get("/api/openclaw/sessions") + session = client.get("/api/openclaw/sessions/main/session-1") + history = client.get("/api/openclaw/sessions/main/session-1/history", params={"limit": 5}) + cron = client.get("/api/openclaw/cron") + approvals = client.get("/api/openclaw/approvals") + + assert health.status_code == 200 + assert health.json()["service"] == "openclaw-service" + assert status.status_code == 200 + assert status.json()["status"] == "operational" + assert openclaw_status.status_code == 200 + assert openclaw_status.json()["runtimeVersion"] == "2026.3.24" + assert sessions.status_code == 200 + assert len(sessions.json()["sessions"]) == 2 + assert session.status_code == 200 + assert session.json()["session"]["agentId"] == "main" + assert history.status_code == 200 + assert history.json()["limit"] == 5 + assert cron.status_code == 200 + assert cron.json()["jobs"][0]["id"] == "job-1" + assert approvals.status_code == 200 + assert approvals.json()["approvals"][0]["id"] == "ap-1" + + +def test_openclaw_service_session_404(): + app = create_app() + app.dependency_overrides[openclaw_module.get_openclaw_cli_service] = ( + lambda: _FakeOpenClawCliService() + ) + + with TestClient(app) as client: + response = client.get("/api/openclaw/sessions/missing") + + assert response.status_code == 404 diff --git a/frontend/src/components/OpenClawStatus.jsx b/frontend/src/components/OpenClawStatus.jsx new file mode 100644 index 0000000..5d92a9c --- /dev/null +++ b/frontend/src/components/OpenClawStatus.jsx @@ -0,0 +1,532 @@ +import { useEffect, useState } from "react"; +import { useOpenClawStore } from "../store/openclawStore"; +import { useOpenClawPanel } from "../hooks/useOpenClawPanel"; + +// Agent run states matching openclaw-control-center/src/types.ts +const AGENT_RUN_STATES = { + idle: { label: "空闲", color: "#9CA3AF" }, + running: { label: "运行中", color: "#10B981" }, + blocked: { label: "阻塞", color: "#F59E0B" }, + waiting_approval: { label: "待审批", color: "#8B5CF6" }, + error: { label: "错误", color: "#EF4444" }, +}; + +// Agent accent colors for avatar borders +const AGENT_COLORS = [ + { accent: "#3B82F6" }, + { accent: "#8B5CF6" }, + { accent: "#EC4899" }, + { accent: "#F59E0B" }, + { accent: "#10B981" }, + { accent: "#EF4444" }, + { accent: "#06B6D4" }, + { accent: "#84CC16" }, +]; + +function getAgentColor(agentId) { + let hash = 0; + for (let i = 0; i < (agentId || "").length; i++) { + hash = ((hash << 5) - hash) + agentId.charCodeAt(i); + hash = hash & hash; + } + return AGENT_COLORS[Math.abs(hash) % AGENT_COLORS.length].accent; +} + +function agentStateFromPresence(presence, agentId) { + const p = presence?.[agentId]; + if (!p) return "idle"; + if (p.status === "active") return "running"; + if (p.sessions?.some(s => s.state === "blocked")) return "blocked"; + if (p.sessions?.some(s => s.state === "waiting_approval")) return "waiting_approval"; + if (p.sessions?.some(s => s.state === "error" || s.state === "failed")) return "error"; + if (p.activeSessions > 0) return "running"; + return "idle"; +} + +function AvatarIcon({ agentId, size = 56, borderRadius = 14 }) { + const color = getAgentColor(agentId); + return ( +
+ {agentId?.slice(0, 2).toUpperCase() || "??"} +
+ ); +} + +function SkillBadge({ skill, color }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ +
+ {expanded && skill.description && ( +
+
+ {skill.description} +
+
+ )} +
+ ); +} + +function AgentDetail({ agent, presence, skills }) { + const { workspaceFiles, workspaceFilesLoading, workspaceFilesError, workspaceFileContent } = useOpenClawStore(); + const { requestWorkspaceFiles, requestWorkspaceFile } = useOpenClawPanel(); + const [selectedDoc, setSelectedDoc] = useState(null); + + // Always use "main" as the workspace key since that's the only valid OpenClaw agent ID + const workspace = agent?.id || "main"; + const rawFiles = workspaceFiles[workspace]?.files || []; + // Normalize file props: API returns uppercase (Name, Size, Path, Preview, PreviewTruncated) + const files = rawFiles.map(f => ({ + name: f.Name || f.name, + size: f.Size || f.size, + path: f.Path || f.path, + preview: f.Preview || f.preview, + previewTruncated: f.PreviewTruncated || f.previewTruncated, + })); + const isLoadingFiles = workspaceFilesLoading && !workspaceFiles[workspace]; + + // Fetch workspace files when agent changes + useEffect(() => { + if (workspace && !workspaceFiles[workspace]) { + requestWorkspaceFiles(workspace); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspace]); + + const agentId = agent.id || agent.name || "?"; + const state = agentStateFromPresence(presence, agentId); + const stateInfo = AGENT_RUN_STATES[state] || AGENT_RUN_STATES.idle; + const color = getAgentColor(agentId); + + // Skills are global in OpenClaw — show all skills (not filtered per-agent) + + return ( +
+ {/* Header */} +
+
+ +
+
{agent.name || agentId}
+
{agentId}
+
+
+ {stateInfo.label} +
+
+
+
+
+
模型
+
+ {agent.model || "—"} +
+
+
+
+ + {/* Skills + Documents: left-right layout */} +
+ {/* Left: Skills */} +
+ {(() => { + const available = skills.filter(s => { + const hasMissing = s.missing && (s.missing.bins?.length || s.missing.env?.length || s.missing.config?.length); + return s.eligible !== false && s.disabled !== true && !hasMissing; + }); + return available.length > 0 && ( +
+
+
+
可用技能
+
+ 已就绪: {available.length} +
+
+
+
+ {available.map((skill, i) => ( + + ))} +
+
+ ); + })()} +
+ + {/* Right: Documents */} +
+ {workspace && ( +
+
+
+
工作区文档
+
+ {files.length} 个文件 +
+
+
+
+ {isLoadingFiles ? ( +
加载中…
+ ) : workspaceFilesError ? ( +
加载失败
+ ) : files.length === 0 ? ( +
暂无文档
+ ) : ( + files.map((f) => ( + + )) + )} +
+
+ )} +
+
+
+ ); +} + +export function OpenClawStatus() { + const store = useOpenClawStore(); + const { + requestStatus, + requestAgents, + requestAgentsPresence, + requestSkills, + } = useOpenClawPanel(); + + const [selectedAgentId, setSelectedAgentId] = useState( + () => store.agents[0]?.id || store.agents[0]?.name || null + ); + + // Fetch data only if store is empty (on mount / page refresh) + useEffect(() => { + if (!store.agents.length) requestAgents(); + if (!store.skills.length) requestSkills(); + if (!store.openclawStatus) requestStatus(); + requestAgentsPresence(); + }, []); + + const status = store.openclawStatus; + const agents = store.agents; + const presence = store.agentsPresence?.agents || {}; + const skills = store.skills || []; + + const selectedAgent = agents.find(a => (a.id || a.name) === selectedAgentId) || agents[0] || null; + + // Auto-select first agent when agents load + useEffect(() => { + if (!selectedAgentId && agents.length > 0) { + setSelectedAgentId(agents[0].id || agents[0].name); + } + }, [agents, selectedAgentId]); + + return ( +
+ {/* Header */} +
+
+ OpenClaw Agent 状态 +
+
+ 监控 OpenClaw 多 Agent 运行时状态 +
+
+ + {/* Main content: left agent list + right detail */} +
0 ? "120px minmax(0, 1fr)" : "1fr", + gap: 16, + alignItems: "stretch", + minHeight: 0, + overflow: "hidden", + }}> + {/* Left: agent avatar list */} + {agents.length > 0 && ( +
+ {agents.map((agent) => { + const agentId = agent.id || agent.name; + const isSelected = (agent.id || agent.name) === (selectedAgent?.id || selectedAgent?.name); + const color = getAgentColor(agentId); + const state = agentStateFromPresence(presence, agentId); + const stateInfo = AGENT_RUN_STATES[state] || AGENT_RUN_STATES.idle; + return ( + + ); + })} +
+ )} + + {/* Right: agent detail */} +
+ {/* Agent detail */} +
+ {agents.length === 0 ? ( +
+ +
+ {store.agentsLoading ? "加载中..." : (store.agentsError ? `错误: ${String(store.agentsError).slice(0, 60)}` : "暂无 Agent")} +
+ +
+ ) : selectedAgent ? ( + + ) : null} +
+
+
+
+ ); +} diff --git a/frontend/src/components/OpenClawView.jsx b/frontend/src/components/OpenClawView.jsx new file mode 100644 index 0000000..42628be --- /dev/null +++ b/frontend/src/components/OpenClawView.jsx @@ -0,0 +1,14 @@ +import { OpenClawStatus } from './OpenClawStatus'; + +export default function OpenClawView() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/hooks/useOpenClawPanel.js b/frontend/src/hooks/useOpenClawPanel.js new file mode 100644 index 0000000..fc563e1 --- /dev/null +++ b/frontend/src/hooks/useOpenClawPanel.js @@ -0,0 +1,263 @@ +import { useCallback } from "react"; +import { useOpenClawStore } from "../store/openclawStore"; + +const RETRY_DELAY_MS = 250; + +function sendWithRetry(clientRef, payload, retries = 3) { + const attemptSend = (remaining) => { + const client = clientRef.current; + if (!client) return false; + const sent = client.send(typeof payload === "string" ? payload : JSON.stringify(payload)); + if (sent || remaining <= 0) return sent; + window.setTimeout(() => attemptSend(remaining - 1), RETRY_DELAY_MS); + return false; + }; + return attemptSend(retries); +} + +export function useOpenClawPanel() { + // Access store state directly — do NOT destructure store as a useCallback dep + // or every store update will recreate all callbacks and trigger infinite loops. + const getStore = () => useOpenClawStore.getState(); + + const requestStatus = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setStatusLoading(true); + store.setStatusError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_status" }); + }, []); + + const requestSessions = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setSessionsLoading(true); + store.setSessionsError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_sessions" }); + }, []); + + const requestSessionDetail = useCallback((sessionKey) => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setSelectedSessionKey(sessionKey); + store.setSessionDetailLoading(true); + store.setSessionDetailError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_session_detail", session_key: sessionKey }); + }, []); + + const requestSessionHistory = useCallback((sessionKey, limit = 20) => { + const client = getStore().clientRef?.current; + if (!client) return; + sendWithRetry({ current: client }, { + type: "get_openclaw_session_history", + session_key: sessionKey, + limit, + }); + }, []); + + const requestCron = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setCronLoading(true); + store.setCronError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_cron" }); + }, []); + + const requestApprovals = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setApprovalsLoading(true); + store.setApprovalsError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_approvals" }); + }, []); + + const requestAgents = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setAgentsLoading(true); + store.setAgentsError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_agents" }); + }, []); + + const requestAgentsPresence = useCallback(() => { + const client = getStore().clientRef?.current; + if (!client) return; + sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" }); + }, []); + + const requestSkills = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setSkillsLoading(true); + store.setSkillsError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_skills" }); + }, []); + + const requestModels = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setModelsLoading(true); + store.setModelsError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_models" }); + }, []); + + const requestHooks = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setHooksLoading(true); + store.setHooksError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_hooks" }); + }, []); + + const requestPlugins = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setPluginsLoading(true); + store.setPluginsError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_plugins" }); + }, []); + + const requestSecretsAudit = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setSecretsAuditLoading(true); + store.setSecretsAuditError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_secrets_audit" }); + }, []); + + const requestSecurityAudit = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setSecurityAuditLoading(true); + store.setSecurityAuditError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_security_audit" }); + }, []); + + const requestDaemonStatus = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setDaemonStatusLoading(true); + store.setDaemonStatusError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_daemon_status" }); + }, []); + + const requestPairing = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setPairingLoading(true); + store.setPairingError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_pairing" }); + }, []); + + const requestQrCode = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setQrCodeLoading(true); + store.setQrCodeError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_qr" }); + }, []); + + const requestUpdateStatus = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setUpdateStatusLoading(true); + store.setUpdateStatusError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_update_status" }); + }, []); + + const requestModelsAliases = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setModelsAliasesLoading(true); + store.setModelsAliasesError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_models_aliases" }); + }, []); + + const requestModelsFallbacks = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setModelsFallbacksLoading(true); + store.setModelsFallbacksError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_models_fallbacks" }); + }, []); + + const requestModelsImageFallbacks = useCallback(() => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setModelsImageFallbacksLoading(true); + store.setModelsImageFallbacksError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_models_image_fallbacks" }); + }, []); + + const requestSkillUpdate = useCallback((slug = null, all = false) => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client) return; + store.setSkillUpdateLoading(true); + store.setSkillUpdateError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_skill_update", slug, all }); + }, []); + + const requestWorkspaceFiles = useCallback((workspace) => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client || !workspace) return; + store.setWorkspaceFilesLoading(true); + store.setWorkspaceFilesError(null); + sendWithRetry({ current: client }, { type: "get_openclaw_workspace_files", workspace }); + }, []); + + const requestWorkspaceFile = useCallback((agent_id, file_name) => { + const store = getStore(); + const client = store.clientRef?.current; + if (!client || !agent_id || !file_name) return; + console.log("[DEBUG] requestWorkspaceFile:", { type: "get_openclaw_workspace_file", agent_id, file_name }); + sendWithRetry({ current: client }, { type: "get_openclaw_workspace_file", agent_id, file_name }); + }, []); + + return { + requestStatus, + requestSessions, + requestSessionDetail, + requestSessionHistory, + requestCron, + requestApprovals, + requestAgents, + requestAgentsPresence, + requestSkills, + requestModels, + requestHooks, + requestPlugins, + requestSecretsAudit, + requestSecurityAudit, + requestDaemonStatus, + requestPairing, + requestQrCode, + requestUpdateStatus, + requestModelsAliases, + requestModelsFallbacks, + requestModelsImageFallbacks, + requestSkillUpdate, + requestWorkspaceFiles, + requestWorkspaceFile, + }; +} diff --git a/frontend/src/hooks/useWebSocketConnection.js b/frontend/src/hooks/useWebSocketConnection.js index 660e391..738fc68 100644 --- a/frontend/src/hooks/useWebSocketConnection.js +++ b/frontend/src/hooks/useWebSocketConnection.js @@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { AGENTS } from '../config/constants'; import { ReadOnlyClient } from '../services/websocket'; import { useRuntimeStore } from '../store/runtimeStore'; +import { useOpenClawStore } from '../store/openclawStore'; import { useMarketStore } from '../store/marketStore'; import { usePortfolioStore } from '../store/portfolioStore'; import { useAgentStore } from '../store/agentStore'; @@ -797,7 +798,198 @@ export function useWebSocketConnection({ fast_forward_success: (e) => { console.log(`✅ ${e.message}`); - } + }, + + openclaw_status_loaded: (e) => { + useOpenClawStore.getState().setOpenclawStatus(e.data || e); + useOpenClawStore.getState().setStatusLoading(false); + }, + openclaw_sessions_loaded: (e) => { + useOpenClawStore.getState().setOpenclawSessions(e.data || e); + useOpenClawStore.getState().setSessionsLoading(false); + }, + openclaw_session_detail_loaded: (e) => { + useOpenClawStore.getState().setOpenclawSessionDetail(e.data || e); + useOpenClawStore.getState().setSessionDetailLoading(false); + }, + openclaw_session_history_loaded: (e) => { + useOpenClawStore.getState().setOpenclawSessionHistory(e.data || e); + }, + openclaw_cron_loaded: (e) => { + useOpenClawStore.getState().setOpenclawCronJobs(e.data || e); + useOpenClawStore.getState().setCronLoading(false); + }, + openclaw_approvals_loaded: (e) => { + useOpenClawStore.getState().setOpenclawApprovals(e.data || e); + useOpenClawStore.getState().setApprovalsLoading(false); + }, + openclaw_agents_loaded: (e) => { + useOpenClawStore.getState().setAgentsLoading(false); + const d = e.data?.data ?? e.data; + if (d?.error) { + useOpenClawStore.getState().setAgentsError(d.error); + } else { + useOpenClawStore.getState().setAgents(d?.agents || []); + useOpenClawStore.getState().setAgentsError(null); + } + }, + openclaw_agents_presence_loaded: (e) => { + useOpenClawStore.getState().setAgentsPresence((e.data?.data ?? e.data) || {}); + }, + openclaw_skills_loaded: (e) => { + useOpenClawStore.getState().setSkillsLoading(false); + const d = e.data?.data ?? e.data; + if (d?.error) { + useOpenClawStore.getState().setSkillsError(d.error); + } else { + useOpenClawStore.getState().setSkills(d?.skills || []); + useOpenClawStore.getState().setSkillsError(null); + } + }, + openclaw_models_loaded: (e) => { + useOpenClawStore.getState().setModelsLoading(false); + const d = e.data?.data ?? e.data; + if (d?.error) { + useOpenClawStore.getState().setModelsError(d.error); + } else { + useOpenClawStore.getState().setModels(d?.models || []); + useOpenClawStore.getState().setModelsError(null); + } + }, + openclaw_workspace_files_loaded: (e) => { + useOpenClawStore.getState().setWorkspaceFilesLoading(false); + const d = e.data?.data ?? e.data; + const workspace = d?.workspace || ""; + if (d?.error) { + useOpenClawStore.getState().setWorkspaceFilesError(d.error); + } else { + useOpenClawStore.getState().setWorkspaceFiles(workspace, d); + useOpenClawStore.getState().setWorkspaceFilesError(null); + } + }, + openclaw_workspace_file_loaded: (e) => { + const d = e.data?.data ?? e.data; + console.log("[DEBUG] workspace_file_loaded:", { d }); + if (d?.error) return; + const agentId = d?.agentId || "main"; + const fileName = d?.file?.Name || d?.file?.name || ""; + const key = `${agentId}:${fileName}`; + if (d?.file?.missing) { + useOpenClawStore.getState().setWorkspaceFileContent(key, "(文件不存在)"); + } else if (d?.file?.content) { + useOpenClawStore.getState().setWorkspaceFileContent(key, d.file.content); + } + }, + openclaw_hooks_loaded: (e) => { + useOpenClawStore.getState().setHooksLoading(false); + const d = e.data?.data ?? e.data; + if (d?.error) { + useOpenClawStore.getState().setHooksError(d.error); + } else { + useOpenClawStore.getState().setHooks(d?.hooks || []); + useOpenClawStore.getState().setHooksError(null); + } + }, + openclaw_plugins_loaded: (e) => { + useOpenClawStore.getState().setPluginsLoading(false); + const d = e.data?.data ?? e.data; + if (d?.error) { + useOpenClawStore.getState().setPluginsError(d.error); + } else { + useOpenClawStore.getState().setPlugins(d?.plugins || []); + useOpenClawStore.getState().setPluginsError(null); + } + }, + openclaw_secrets_audit_loaded: (e) => { + useOpenClawStore.getState().setSecretsAuditLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setSecretsAuditError(e.data.data.error); + } else { + useOpenClawStore.getState().setSecretsAudit(e.data?.data || null); + useOpenClawStore.getState().setSecretsAuditError(null); + } + }, + openclaw_security_audit_loaded: (e) => { + useOpenClawStore.getState().setSecurityAuditLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setSecurityAuditError(e.data.data.error); + } else { + useOpenClawStore.getState().setSecurityAudit(e.data?.data || null); + useOpenClawStore.getState().setSecurityAuditError(null); + } + }, + openclaw_daemon_status_loaded: (e) => { + useOpenClawStore.getState().setDaemonStatusLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setDaemonStatusError(e.data.data.error); + } else { + useOpenClawStore.getState().setDaemonStatus(e.data?.data || null); + useOpenClawStore.getState().setDaemonStatusError(null); + } + }, + openclaw_pairing_loaded: (e) => { + useOpenClawStore.getState().setPairingLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setPairingError(e.data.data.error); + } else { + useOpenClawStore.getState().setPairing(e.data?.data || null); + useOpenClawStore.getState().setPairingError(null); + } + }, + openclaw_qr_loaded: (e) => { + useOpenClawStore.getState().setQrCodeLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setQrCodeError(e.data.data.error); + } else { + useOpenClawStore.getState().setQrCode(e.data?.data || null); + useOpenClawStore.getState().setQrCodeError(null); + } + }, + openclaw_update_status_loaded: (e) => { + useOpenClawStore.getState().setUpdateStatusLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setUpdateStatusError(e.data.data.error); + } else { + useOpenClawStore.getState().setUpdateStatus(e.data?.data || null); + useOpenClawStore.getState().setUpdateStatusError(null); + } + }, + openclaw_models_aliases_loaded: (e) => { + useOpenClawStore.getState().setModelsAliasesLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setModelsAliasesError(e.data.data.error); + } else { + useOpenClawStore.getState().setModelsAliases(e.data?.data || null); + useOpenClawStore.getState().setModelsAliasesError(null); + } + }, + openclaw_models_fallbacks_loaded: (e) => { + useOpenClawStore.getState().setModelsFallbacksLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setModelsFallbacksError(e.data.data.error); + } else { + useOpenClawStore.getState().setModelsFallbacks(e.data?.data?.items || []); + useOpenClawStore.getState().setModelsFallbacksError(null); + } + }, + openclaw_models_image_fallbacks_loaded: (e) => { + useOpenClawStore.getState().setModelsImageFallbacksLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setModelsImageFallbacksError(e.data.data.error); + } else { + useOpenClawStore.getState().setModelsImageFallbacks(e.data?.data?.items || []); + useOpenClawStore.getState().setModelsImageFallbacksError(null); + } + }, + openclaw_skill_update_loaded: (e) => { + useOpenClawStore.getState().setSkillUpdateLoading(false); + if (e.data?.data?.error) { + useOpenClawStore.getState().setSkillUpdateError(e.data.data.error); + } else { + useOpenClawStore.getState().setSkillUpdate(e.data?.data || null); + useOpenClawStore.getState().setSkillUpdateError(null); + } + }, }; try { diff --git a/frontend/src/store/openclawStore.js b/frontend/src/store/openclawStore.js new file mode 100644 index 0000000..ccb8194 --- /dev/null +++ b/frontend/src/store/openclawStore.js @@ -0,0 +1,227 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export const useOpenClawStore = create( + persist( + (set) => ({ + // Raw data + openclawStatus: null, + openclawSessions: [], + openclawSessionDetail: null, + openclawSessionHistory: [], + openclawCronJobs: [], + openclawApprovals: [], + + // Loading states + isStatusLoading: false, + isSessionsLoading: false, + isSessionDetailLoading: false, + isCronLoading: false, + isApprovalsLoading: false, + + // Error states + statusError: null, + sessionsError: null, + sessionDetailError: null, + cronError: null, + approvalsError: null, + + // Agents state + agents: [], + agentsLoading: false, + agentsError: null, + agentsPresence: {}, + + // Skills state + skills: [], + skillsLoading: false, + skillsError: null, + + // Models state + models: [], + modelsLoading: false, + modelsError: null, + + // Hooks state + hooks: [], + hooksLoading: false, + hooksError: null, + + // Plugins state + plugins: [], + pluginsLoading: false, + pluginsError: null, + + // Secrets audit state + secretsAudit: null, + secretsAuditLoading: false, + secretsAuditError: null, + + // Security audit state + securityAudit: null, + securityAuditLoading: false, + securityAuditError: null, + + // Daemon status state + daemonStatus: null, + daemonStatusLoading: false, + daemonStatusError: null, + + // Pairing state + pairing: null, + pairingLoading: false, + pairingError: null, + + // QR code state + qrCode: null, + qrCodeLoading: false, + qrCodeError: null, + + // Update status state + updateStatus: null, + updateStatusLoading: false, + updateStatusError: null, + + // Models aliases state + modelsAliases: null, + modelsAliasesLoading: false, + modelsAliasesError: null, + + // Models fallbacks state + modelsFallbacks: [], + modelsFallbacksLoading: false, + modelsFallbacksError: null, + + // Models image fallbacks state + modelsImageFallbacks: [], + modelsImageFallbacksLoading: false, + modelsImageFallbacksError: null, + + // Skill update state + skillUpdate: null, + skillUpdateLoading: false, + skillUpdateError: null, + + // Workspace files state (per agent, keyed by workspace path) + workspaceFiles: {}, + workspaceFilesLoading: false, + workspaceFilesError: null, + + // Workspace file content (keyed by "agentId:filename") + workspaceFileContent: {}, + + // Selected session key for detail/history drill-down + selectedSessionKey: null, + + // WebSocket client ref (set by App.jsx on connection) + clientRef: null, + setClientRef: (ref) => set({ clientRef: ref }), + + // Setters + setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }), + setOpenclawSessions: (data) => set({ openclawSessions: data?.sessions || [], sessionsError: null }), + setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: null }), + setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: null }), + setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }), + setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }), + + setSelectedSessionKey: (key) => set({ selectedSessionKey: key }), + + setStatusLoading: (v) => set({ isStatusLoading: v }), + setSessionsLoading: (v) => set({ isSessionsLoading: v }), + setSessionDetailLoading: (v) => set({ isSessionDetailLoading: v }), + setCronLoading: (v) => set({ isCronLoading: v }), + setApprovalsLoading: (v) => set({ isApprovalsLoading: v }), + + setStatusError: (e) => set({ statusError: e }), + setSessionsError: (e) => set({ sessionsError: e }), + setSessionDetailError: (e) => set({ sessionDetailError: e }), + setCronError: (e) => set({ cronError: e }), + setApprovalsError: (e) => set({ approvalsError: e }), + + setAgents: (agents) => set({ agents }), + setAgentsLoading: (loading) => set({ agentsLoading: loading }), + setAgentsError: (error) => set({ agentsError: error }), + setAgentsPresence: (presence) => set({ agentsPresence: presence }), + setSkills: (skills) => set({ skills }), + setSkillsLoading: (loading) => set({ skillsLoading: loading }), + setSkillsError: (error) => set({ skillsError: error }), + setModels: (models) => set({ models }), + setModelsLoading: (loading) => set({ modelsLoading: loading }), + setModelsError: (error) => set({ modelsError: error }), + + setHooks: (hooks) => set({ hooks }), + setHooksLoading: (loading) => set({ hooksLoading: loading }), + setHooksError: (error) => set({ hooksError: error }), + setPlugins: (plugins) => set({ plugins }), + setPluginsLoading: (loading) => set({ pluginsLoading: loading }), + setPluginsError: (error) => set({ pluginsError: error }), + setSecretsAudit: (data) => set({ secretsAudit: data }), + setSecretsAuditLoading: (loading) => set({ secretsAuditLoading: loading }), + setSecretsAuditError: (error) => set({ secretsAuditError: error }), + setSecurityAudit: (data) => set({ securityAudit: data }), + setSecurityAuditLoading: (loading) => set({ securityAuditLoading: loading }), + setSecurityAuditError: (error) => set({ securityAuditError: error }), + setDaemonStatus: (data) => set({ daemonStatus: data }), + setDaemonStatusLoading: (loading) => set({ daemonStatusLoading: loading }), + setDaemonStatusError: (error) => set({ daemonStatusError: error }), + setPairing: (data) => set({ pairing: data }), + setPairingLoading: (loading) => set({ pairingLoading: loading }), + setPairingError: (error) => set({ pairingError: error }), + setQrCode: (data) => set({ qrCode: data }), + setQrCodeLoading: (loading) => set({ qrCodeLoading: loading }), + setQrCodeError: (error) => set({ qrCodeError: error }), + setUpdateStatus: (data) => set({ updateStatus: data }), + setUpdateStatusLoading: (loading) => set({ updateStatusLoading: loading }), + setUpdateStatusError: (error) => set({ updateStatusError: error }), + setModelsAliases: (data) => set({ modelsAliases: data }), + setModelsAliasesLoading: (loading) => set({ modelsAliasesLoading: loading }), + setModelsAliasesError: (error) => set({ modelsAliasesError: error }), + setModelsFallbacks: (data) => set({ modelsFallbacks: data }), + setModelsFallbacksLoading: (loading) => set({ modelsFallbacksLoading: loading }), + setModelsFallbacksError: (error) => set({ modelsFallbacksError: error }), + setModelsImageFallbacks: (data) => set({ modelsImageFallbacks: data }), + setModelsImageFallbacksLoading: (loading) => set({ modelsImageFallbacksLoading: loading }), + setModelsImageFallbacksError: (error) => set({ modelsImageFallbacksError: error }), + setSkillUpdate: (data) => set({ skillUpdate: data }), + setSkillUpdateLoading: (loading) => set({ skillUpdateLoading: loading }), + setSkillUpdateError: (error) => set({ skillUpdateError: error }), + + setWorkspaceFiles: (workspace, data) => set((state) => ({ + workspaceFiles: { ...state.workspaceFiles, [workspace]: data }, + })), + setWorkspaceFilesLoading: (loading) => set({ workspaceFilesLoading: loading }), + setWorkspaceFilesError: (error) => set({ workspaceFilesError: error }), + setWorkspaceFileContent: (key, content) => set((state) => ({ + workspaceFileContent: { ...state.workspaceFileContent, [key]: content }, + })), + }), + { + name: "openclaw-store", + // Skip persisting ephemeral UI state + partialize: (state) => ({ + // Persist only data, not loading/error/UI states + openclawStatus: state.openclawStatus, + openclawSessions: state.openclawSessions, + openclawCronJobs: state.openclawCronJobs, + openclawApprovals: state.openclawApprovals, + agents: state.agents, + agentsPresence: state.agentsPresence, + skills: state.skills, + models: state.models, + hooks: state.hooks, + plugins: state.plugins, + secretsAudit: state.secretsAudit, + securityAudit: state.securityAudit, + daemonStatus: state.daemonStatus, + pairing: state.pairing, + qrCode: state.qrCode, + updateStatus: state.updateStatus, + modelsAliases: state.modelsAliases, + modelsFallbacks: state.modelsFallbacks, + modelsImageFallbacks: state.modelsImageFallbacks, + skillUpdate: state.skillUpdate, + }), + } + ) +); diff --git a/shared/client/openclaw_client.py b/shared/client/openclaw_client.py new file mode 100644 index 0000000..e2df6bf --- /dev/null +++ b/shared/client/openclaw_client.py @@ -0,0 +1,415 @@ +# -*- coding: utf-8 -*- +"""OpenClaw service client — typed async wrapper for openclaw-service REST API.""" + +from __future__ import annotations + +import httpx + +from shared.models.openclaw import ( + AgentsList, + ApprovalRequest, + ApprovalsList, + CronJob, + CronList, + DaemonStatus, + HookStatusReport, + ModelAliasesList, + ModelFallbacksList, + ModelsList, + OpenClawStatus, + PairingListResponse, + QrCodeResponse, + SecretsAuditReport, + SecurityAuditResponse, + SessionEntry, + SessionHistory, + SessionsList, + SkillStatusReport, + SkillUpdateResult, + UpdateStatusResponse, + normalize_agents, + normalize_approvals, + normalize_cron_jobs, + normalize_daemon_status, + normalize_hooks, + normalize_model_aliases, + normalize_model_fallbacks, + normalize_models, + normalize_pairing, + normalize_qr, + normalize_security_audit, + normalize_secrets_audit, + normalize_session_history, + normalize_sessions, + normalize_skill_update, + normalize_skills, + normalize_status, + normalize_update_status, + normalize_plugins, + PluginsList, +) + + +class OpenClawServiceClient: + """Async client for the openclaw-service API surface. + + All methods return typed Pydantic models. The raw JSON dict is + accessible via the `.model_dump()` method on each result. + + Example:: + + async with OpenClawServiceClient() as client: + status = await client.fetch_status() + print(status.runtime_version) # typed + print(status.model_dump()["runtimeVersion"]) # raw dict + """ + + def __init__(self, base_url: str = "http://localhost:8004/api/openclaw"): + self.base_url = base_url.rstrip("/") + self._client: httpx.AsyncClient | None = None + + async def __aenter__(self) -> "OpenClawServiceClient": + self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + if self._client: + await self._client.aclose() + + async def fetch_status(self) -> OpenClawStatus: + """GET /status — returns parsed OpenClawStatus model.""" + response = await self._client.get("/status") + response.raise_for_status() + raw = response.json() + return normalize_status(raw) + + async def list_sessions(self) -> SessionsList: + """GET /sessions — returns parsed SessionsList model.""" + response = await self._client.get("/sessions") + response.raise_for_status() + raw = response.json() + return normalize_sessions(raw) + + async def get_session(self, session_key: str) -> SessionEntry: + """GET /sessions/{session_key} — returns parsed SessionEntry model.""" + response = await self._client.get(f"/sessions/{session_key}") + response.raise_for_status() + raw = response.json() + session = raw.get("session") or {} + return SessionEntry.model_validate(session, strict=False) + + async def get_session_history(self, session_key: str, *, limit: int = 20) -> SessionHistory: + """GET /sessions/{session_key}/history — returns parsed SessionHistory model.""" + response = await self._client.get( + f"/sessions/{session_key}/history", + params={"limit": limit}, + ) + response.raise_for_status() + raw = response.json() + return normalize_session_history(raw, session_key=session_key) + + async def list_cron_jobs(self) -> CronList: + """GET /cron — returns parsed CronList model.""" + response = await self._client.get("/cron") + response.raise_for_status() + raw = response.json() + return normalize_cron_jobs(raw) + + async def list_approvals(self) -> ApprovalsList: + """GET /approvals — returns parsed ApprovalsList model.""" + response = await self._client.get("/approvals") + response.raise_for_status() + raw = response.json() + return normalize_approvals(raw) + + async def list_agents(self) -> AgentsList: + """GET /agents — returns parsed AgentsList model.""" + response = await self._client.get("/agents") + response.raise_for_status() + raw = response.json() + return normalize_agents(raw) + + async def list_skills(self) -> SkillStatusReport: + """GET /skills — returns parsed SkillStatusReport model.""" + response = await self._client.get("/skills") + response.raise_for_status() + raw = response.json() + return normalize_skills(raw) + + async def list_models(self) -> ModelsList: + """GET /models — returns parsed ModelsList model.""" + response = await self._client.get("/models") + response.raise_for_status() + raw = response.json() + return normalize_models(raw) + + async def list_hooks(self) -> HookStatusReport: + response = await self._client.get("/hooks") + response.raise_for_status() + raw = response.json() + return normalize_hooks(raw) + + async def list_plugins(self) -> PluginsList: + response = await self._client.get("/plugins") + response.raise_for_status() + raw = response.json() + return normalize_plugins(raw) + + async def secrets_audit(self) -> SecretsAuditReport: + response = await self._client.get("/secrets-audit") + response.raise_for_status() + raw = response.json() + return normalize_secrets_audit(raw) + + async def security_audit(self) -> SecurityAuditResponse: + response = await self._client.get("/security-audit") + response.raise_for_status() + raw = response.json() + return normalize_security_audit(raw) + + async def daemon_status(self) -> DaemonStatus: + response = await self._client.get("/daemon-status") + response.raise_for_status() + raw = response.json() + return normalize_daemon_status(raw) + + async def pairing_list(self) -> PairingListResponse: + response = await self._client.get("/pairing") + response.raise_for_status() + raw = response.json() + return normalize_pairing(raw) + + async def qr_code(self) -> QrCodeResponse: + response = await self._client.get("/qr") + response.raise_for_status() + raw = response.json() + return normalize_qr(raw) + + async def update_status(self) -> UpdateStatusResponse: + response = await self._client.get("/update-status") + response.raise_for_status() + raw = response.json() + return normalize_update_status(raw) + + async def list_model_aliases(self) -> ModelAliasesList: + response = await self._client.get("/models-aliases") + response.raise_for_status() + raw = response.json() + return normalize_model_aliases(raw) + + async def list_model_fallbacks(self) -> ModelFallbacksList: + response = await self._client.get("/models-fallbacks") + response.raise_for_status() + raw = response.json() + return normalize_model_fallbacks(raw) + + async def list_model_image_fallbacks(self) -> ModelFallbacksList: + response = await self._client.get("/models-image-fallbacks") + response.raise_for_status() + raw = response.json() + return normalize_model_fallbacks(raw) + + async def skill_update(self, *, slug: str | None = None, all: bool = False) -> SkillUpdateResult: + params = {} + if slug is not None: + params["slug"] = slug + if all: + params["all"] = "true" + response = await self._client.get("/skill-update", params=params) + response.raise_for_status() + raw = response.json() + return normalize_skill_update(raw) + + async def models_status(self, *, probe: bool = False) -> dict[str, Any]: + """GET /models-status — returns parsed models status dict.""" + params = {"probe": "true"} if probe else {} + response = await self._client.get("/models-status", params=params) + response.raise_for_status() + return response.json() + + async def channels_status(self, *, probe: bool = False) -> dict[str, Any]: + """GET /channels-status — returns parsed channels status dict.""" + params = {"probe": "true"} if probe else {} + response = await self._client.get("/channels-status", params=params) + response.raise_for_status() + return response.json() + + async def channels_list(self) -> dict[str, Any]: + """GET /channels-list — returns parsed channels list dict.""" + response = await self._client.get("/channels-list") + response.raise_for_status() + return response.json() + + async def hook_info(self, name: str) -> dict[str, Any]: + """GET /hooks/info/{name} — returns parsed hook info dict.""" + response = await self._client.get(f"/hooks/info/{name}") + response.raise_for_status() + return response.json() + + async def hooks_check(self) -> dict[str, Any]: + """GET /hooks/check — returns parsed hooks check dict.""" + response = await self._client.get("/hooks/check") + response.raise_for_status() + return response.json() + + async def plugins_inspect(self, *, plugin_id: str | None = None, all: bool = False) -> dict[str, Any]: + """GET /plugins-inspect — returns parsed plugins inspect dict.""" + params: dict[str, Any] = {} + if all: + params["all"] = "true" + elif plugin_id: + params["plugin_id"] = plugin_id + response = await self._client.get("/plugins-inspect", params=params) + response.raise_for_status() + return response.json() + + async def agents_bindings(self, *, agent: str | None = None) -> dict[str, Any]: + """GET /agents-bindings — returns parsed agents bindings list dict.""" + params: dict[str, Any] = {} + if agent: + params["agent"] = agent + response = await self._client.get("/agents-bindings", params=params) + response.raise_for_status() + return response.json() + + async def agents_presence(self) -> dict[str, Any]: + """GET /agents/presence — returns runtime session presence for all agents.""" + response = await self._client.get("/agents/presence") + response.raise_for_status() + return response.json() + + async def workspace_files(self, workspace_path: str) -> dict[str, Any]: + """GET /workspace-files?workspace= — list .md files in a workspace.""" + response = await self._client.get("/workspace-files", params={"workspace": workspace_path}) + response.raise_for_status() + return response.json() + + # ------------------------------------------------------------------------- + # Write agents operations + # ------------------------------------------------------------------------- + + async def agents_add( + self, + name: str, + *, + workspace: str | None = None, + model: str | None = None, + agent_dir: str | None = None, + bind: list[str] | None = None, + non_interactive: bool = False, + ) -> dict[str, Any]: + """POST /agents/add — create a new agent.""" + params: dict[str, Any] = {"name": name} + if workspace: + params["workspace"] = workspace + if model: + params["model"] = model + if agent_dir: + params["agent_dir"] = agent_dir + if bind: + params["bind"] = bind + if non_interactive: + params["non_interactive"] = "true" + response = await self._client.post("/agents/add", params=params) + response.raise_for_status() + return response.json() + + async def agents_delete(self, id: str, *, force: bool = False) -> dict[str, Any]: + """POST /agents/delete/{id} — delete an agent.""" + params: dict[str, Any] = {} + if force: + params["force"] = "true" + response = await self._client.post(f"/agents/delete/{id}", params=params) + response.raise_for_status() + return response.json() + + async def agents_bind( + self, + *, + agent: str | None = None, + bind: list[str] | None = None, + ) -> dict[str, Any]: + """POST /agents/bind — add routing bindings to an agent.""" + params: dict[str, Any] = {} + if agent: + params["agent"] = agent + if bind: + params["bind"] = bind + response = await self._client.post("/agents/bind", params=params) + response.raise_for_status() + return response.json() + + async def agents_unbind( + self, + *, + agent: str | None = None, + bind: list[str] | None = None, + all: bool = False, + ) -> dict[str, Any]: + """POST /agents/unbind — remove routing bindings from an agent.""" + params: dict[str, Any] = {} + if agent: + params["agent"] = agent + if bind: + params["bind"] = bind + if all: + params["all"] = "true" + response = await self._client.post("/agents/unbind", params=params) + response.raise_for_status() + return response.json() + + async def agents_set_identity( + self, + *, + agent: str | None = None, + workspace: str | None = None, + identity_file: str | None = None, + name: str | None = None, + emoji: str | None = None, + theme: str | None = None, + avatar: str | None = None, + from_identity: bool = False, + ) -> dict[str, Any]: + """POST /agents/set-identity — update agent identity.""" + params: dict[str, Any] = {} + if agent: + params["agent"] = agent + if workspace: + params["workspace"] = workspace + if identity_file: + params["identity_file"] = identity_file + if name: + params["name"] = name + if emoji: + params["emoji"] = emoji + if theme: + params["theme"] = theme + if avatar: + params["avatar"] = avatar + if from_identity: + params["from_identity"] = "true" + response = await self._client.post("/agents/set-identity", params=params) + response.raise_for_status() + return response.json() + + async def gateway_status(self, *, url: str | None = None, token: str | None = None) -> dict[str, Any]: + """GET /gateway-status — returns parsed gateway status dict.""" + params: dict[str, Any] = {} + if url: + params["url"] = url + if token: + params["token"] = token + response = await self._client.get("/gateway-status", params=params) + response.raise_for_status() + return response.json() + + async def memory_status(self, *, agent: str | None = None, deep: bool = False) -> list[dict[str, Any]]: + """GET /memory-status — returns list of per-agent memory status dicts.""" + params: dict[str, Any] = {} + if agent: + params["agent"] = agent + if deep: + params["deep"] = "true" + response = await self._client.get("/memory-status", params=params) + response.raise_for_status() + return response.json() diff --git a/shared/client/openclaw_websocket_client.py b/shared/client/openclaw_websocket_client.py new file mode 100644 index 0000000..67ab2a9 --- /dev/null +++ b/shared/client/openclaw_websocket_client.py @@ -0,0 +1,739 @@ +# -*- coding: utf-8 -*- +"""OpenClaw Gateway WebSocket client for bidirectional agent communication.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +import time +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +import httpx +import websockets +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.backends import default_backend + +logger = logging.getLogger(__name__) + +# Default Gateway port +DEFAULT_GATEWAY_PORT = 18789 +DEFAULT_GATEWAY_URL = f"ws://127.0.0.1:{DEFAULT_GATEWAY_PORT}" + +# Protocol version (from protocol/schema/protocol-schemas.ts) +PROTOCOL_VERSION = 3 + + +@dataclass +class DeviceIdentity: + """Device identity for Gateway authentication.""" + device_id: str + public_key_pem: bytes + private_key_pem: bytes + + @classmethod + def load_or_create(cls, identity_dir: Path | None = None) -> "DeviceIdentity": + """Load existing device identity from OpenClaw's identity directory, or create a new one.""" + if identity_dir is None: + identity_dir = Path.home() / ".openclaw" / "identity" + + device_json = identity_dir / "device.json" + + # Check if identity exists in OpenClaw's format + if device_json.exists(): + import json + data = json.loads(device_json.read_text()) + return cls( + device_id=data["deviceId"], + public_key_pem=data["publicKeyPem"].encode(), + private_key_pem=data["privateKeyPem"].encode(), + ) + + # Fall back to old devices directory format + device_dir = Path.home() / ".openclaw" / "devices" + id_file = device_dir / "device_id" + pubkey_file = device_dir / "device_pubkey.pem" + privkey_file = device_dir / "device_privkey.pem" + + if id_file.exists() and pubkey_file.exists() and privkey_file.exists(): + device_id = id_file.read_text().strip() + public_key_pem = pubkey_file.read_bytes() + private_key_pem = privkey_file.read_bytes() + return cls( + device_id=device_id, + public_key_pem=public_key_pem, + private_key_pem=private_key_pem, + ) + + # Generate new identity (Ed25519, matching OpenClaw's approach) + from cryptography.hazmat.primitives.asymmetric import ed25519 + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + + # Derive device ID from public key (SHA256 hash) + import hashlib + public_key_raw = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + device_id = hashlib.sha256(public_key_raw).hexdigest() + public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + # Save to disk + device_dir.mkdir(parents=True, exist_ok=True) + id_file.write_text(device_id) + pubkey_file.write_bytes(public_key_pem) + privkey_file.write_bytes(private_key_pem) + + logger.info(f"Created new device identity: {device_id}") + return cls( + device_id=device_id, + public_key_pem=public_key_pem, + private_key_pem=private_key_pem, + ) + + def sign(self, payload: str) -> tuple[int, int]: + """Sign a payload (for ECDSA keys).""" + private_key = serialization.load_pem_private_key( + self.private_key_pem, password=None, backend=default_backend() + ) + signature = private_key.sign(payload.encode(), ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(signature) + return r, s + + def sign_base64url(self, payload: str) -> str: + """Sign payload and return base64url encoded signature (matches TypeScript crypto.sign).""" + import base64 + private_key = serialization.load_pem_private_key( + self.private_key_pem, password=None, backend=default_backend() + ) + # Ed25519 signing (used by OpenClaw) + sig = private_key.sign(payload.encode()) + return base64.urlsafe_b64encode(sig).rstrip(b"=").decode() + + +@dataclass +class GatewayHello: + """Gateway hello response after connection.""" + protocol: int + server_version: str + conn_id: str + methods: list[str] + events: list[str] + device_token: str | None = None + role: str | None = None + scopes: list[str] | None = None + + +@dataclass +class MessageEvent: + """Incoming message event from agent.""" + event: str + payload: dict[str, Any] + seq: int | None = None + + +@dataclass +class SendResult: + """Result of sending a message.""" + message_id: str + session_key: str + ok: bool + + +class OpenClawWebSocketClient: + """WebSocket client for OpenClaw Gateway. + + Supports: + - Device authentication + - Send messages to agents via sessions.send + - Receive real-time responses via event subscription + - Session management + + Example usage: + async with OpenClawWebSocketClient() as client: + await client.connect() + result = await client.send_message(session_key, "Hello agent!") + async for event in client.subscribe(session_key): + print(event) + """ + + def __init__( + self, + url: str = DEFAULT_GATEWAY_URL, + gateway_token: str | None = None, + device_identity: DeviceIdentity | None = None, + client_name: str = "cli", # Must be a valid GatewayClientId (cli, gateway-client, etc) + client_version: str = "1.0.0", + timeout_ms: int = 30000, + ): + self.url = url + self.gateway_token = gateway_token or self._load_gateway_token() + self.device_identity = device_identity + self.client_name = client_name + self.client_version = client_version + self.timeout_ms = timeout_ms + + self._ws: websockets.WebSocketClientProtocol | None = None + self._hello: GatewayHello | None = None + self._pending: dict[str, asyncio.Future] = {} + self._event_handlers: list[Callable[[MessageEvent], None]] = [] + self._recv_task: asyncio.Task | None = None + self._nonce: str | None = None + self._connected = False + + @staticmethod + def _load_gateway_token() -> str | None: + """Load gateway token from ~/.openclaw/openclaw.json.""" + try: + from pathlib import Path + token_file = Path.home() / ".openclaw" / "openclaw.json" + if token_file.exists(): + data = json.loads(token_file.read_text()) + return data.get("gateway", {}).get("auth", {}).get("token") + except Exception: + pass + return None + + @property + def is_connected(self) -> bool: + return self._connected and self._ws is not None + + async def __aenter__(self) -> "OpenClawWebSocketClient": + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.disconnect() + + async def connect(self) -> GatewayHello: + """Connect to the Gateway and complete authentication handshake.""" + if self._connected: + return self._hello + + # Load or create device identity + if self.device_identity is None: + self.device_identity = DeviceIdentity.load_or_create() + + logger.info(f"Connecting to OpenClaw Gateway at {self.url}") + + self._ws = await websockets.connect( + self.url, + max_size=25 * 1024 * 1024, # 25MB max payload + ) + + # Start receive loop + self._recv_task = asyncio.create_task(self._recv_loop()) + + # Wait for connect.challenge + challenge = await self._wait_for_event("connect.challenge") + self._nonce = challenge.payload.get("nonce") + + # Build connect params + connect_params = self._build_connect_params() + + # Debug: log connect params + import logging + logging.basicConfig(level=logging.DEBUG) + logger.debug(f"Connect params: {connect_params}") + + # Send connect request and wait for hello-ok + hello_event = await self._send_request("connect", connect_params, _allow_handshake=True) + self._hello = GatewayHello( + protocol=hello_event["protocol"], + server_version=hello_event["server"]["version"], + conn_id=hello_event["server"]["connId"], + methods=hello_event["features"]["methods"], + events=hello_event["features"]["events"], + device_token=hello_event.get("auth", {}).get("deviceToken"), + role=hello_event.get("auth", {}).get("role"), + scopes=hello_event.get("auth", {}).get("scopes"), + ) + + self._connected = True + logger.info(f"Connected to OpenClaw Gateway v{self._hello.server_version}") + logger.info(f"Supported methods: {self._hello.methods}") + + return self._hello + + def _build_connect_params(self) -> dict[str, Any]: + """Build connect parameters with device authentication. + + Implements V3 device auth payload format: + v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily + """ + import base64 + + # Load public key - use Raw format for Ed25519 (32 bytes) + from cryptography.hazmat.primitives.asymmetric import ed25519 + private_key = serialization.load_pem_private_key( + self.device_identity.private_key_pem, password=None, backend=default_backend() + ) + if isinstance(private_key, ed25519.Ed25519PrivateKey): + public_key = private_key.public_key() + public_key_raw = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + else: + # ECDSA: use SPKI format + public_key = serialization.load_pem_public_key( + self.device_identity.public_key_pem, backend=default_backend() + ) + public_key_raw = public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + public_key_b64 = base64.urlsafe_b64encode(public_key_raw).rstrip(b"=").decode() + + # Build auth payload for signing (V3 format) + signed_at_ms = int(time.time() * 1000) + scopes = "operator.admin,operator.approvals,operator.pairing,operator.read,operator.write" + token = self.gateway_token or "" + platform = "darwin" + device_family = "" + + # V3 payload: v3|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce|platform|deviceFamily + auth_payload = "|".join([ + "v3", + self.device_identity.device_id, + self.client_name, # clientId + "backend", # clientMode + "operator", # role + scopes, + str(signed_at_ms), + token, + self._nonce or "", + platform, + device_family, + ]) + + signature_b64 = self.device_identity.sign_base64url(auth_payload) + + params = { + "minProtocol": PROTOCOL_VERSION, + "maxProtocol": PROTOCOL_VERSION, + "client": { + "id": self.client_name, + "version": self.client_version, + "platform": platform, + "mode": "backend", + }, + "device": { + "id": self.device_identity.device_id, + "publicKey": public_key_b64, + "signature": signature_b64, + "signedAt": signed_at_ms, + "nonce": self._nonce, + }, + "auth": { + "token": token or None, + }, + "role": "operator", + "scopes": scopes.split(","), + } + + # Debug output + print(f"DEBUG: nonce={self._nonce}", file=sys.stderr) + print(f"DEBUG: auth_payload={auth_payload}", file=sys.stderr) + print(f"DEBUG: connect params = {json.dumps(params, indent=2)}", file=sys.stderr) + + return params + + async def _recv_loop(self) -> None: + """Receive and dispatch incoming messages.""" + try: + async for raw in self._ws: + if raw is None: + break + await self._handle_frame(json.loads(raw)) + except websockets.exceptions.ConnectionClosed: + pass + except Exception as e: + logger.error(f"Receive loop error: {e}") + finally: + # Clean up pending futures + for future in self._pending.values(): + if not future.done(): + future.set_exception(Exception("Connection closed")) + self._pending.clear() + self._connected = False + + async def _handle_frame(self, frame: dict[str, Any]) -> None: + """Handle incoming frame.""" + frame_type = frame.get("type") + + if frame_type == "event": + event_name = frame.get("event", "") + payload = frame.get("payload", {}) + seq = frame.get("seq") + + event = MessageEvent(event=event_name, payload=payload, seq=seq) + + # Handle connect challenge + if event_name == "connect.challenge": + nonce = payload.get("nonce") + if nonce: + self._nonce = nonce + challenge_event = MessageEvent(event=event_name, payload={"nonce": nonce}, seq=seq) + for handler in self._event_handlers: + try: + handler(challenge_event) + except Exception as e: + logger.error(f"Event handler error: {e}") + + # Notify event handlers + for handler in self._event_handlers: + try: + handler(event) + except Exception as e: + logger.error(f"Event handler error: {e}") + + elif frame_type == "res": + req_id = frame.get("id") + if req_id in self._pending: + future = self._pending.pop(req_id) + if frame.get("ok"): + future.set_result(frame.get("payload", {})) + else: + error = frame.get("error", {}) + future.set_exception(Exception(f"{error.get('code', 'ERROR')}: {error.get('message', 'Unknown error')}")) + + async def _wait_for_event(self, event_name: str, timeout_ms: int | None = None) -> MessageEvent: + """Wait for a specific event.""" + future: asyncio.Future = asyncio.Future() + timeout = timeout_ms or self.timeout_ms + + def handler(event: MessageEvent) -> None: + if event.event == event_name: + if not future.done(): + future.set_result(event) + + self._event_handlers.append(handler) + try: + return await asyncio.wait_for(future, timeout / 1000) + finally: + self._event_handlers.remove(handler) + + async def _send_request(self, method: str, params: dict[str, Any] | None = None, _allow_handshake: bool = False) -> dict[str, Any]: + """Send a request and wait for response. + + Args: + method: The RPC method name + params: Optional parameters for the method + _allow_handshake: If True, allow sending even during handshake (for connect method) + """ + if not self._ws: + raise Exception("Not connected to Gateway") + if not self._connected and not _allow_handshake: + raise Exception("Not connected to Gateway") + + req_id = str(uuid.uuid4()) + frame = {"type": "req", "id": req_id, "method": method} + if params: + frame["params"] = params + + future: asyncio.Future = asyncio.Future() + self._pending[req_id] = future + + await self._ws.send(json.dumps(frame)) + + try: + return await asyncio.wait_for(future, self.timeout_ms / 1000) + except asyncio.TimeoutError: + self._pending.pop(req_id, None) + raise TimeoutError(f"Request {method} timed out after {self.timeout_ms}ms") + + async def call_method(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Call any RPC method on the Gateway. + + Args: + method: The RPC method name (e.g., "sessions.list", "agents.list") + params: Optional parameters for the method + + Returns: + The response payload from the Gateway + """ + return await self._send_request(method, params) + + async def disconnect(self) -> None: + """Disconnect from the Gateway.""" + self._connected = False + + if self._recv_task: + self._recv_task.cancel() + try: + await self._recv_task + except asyncio.CancelledError: + pass + + if self._ws: + await self._ws.close() + self._ws = None + + self._hello = None + + # ------------------------------------------------------------------------- + # Session operations + # ------------------------------------------------------------------------- + + async def list_sessions( + self, + limit: int = 50, + agent_id: str | None = None, + include_last_message: bool = True, + ) -> list[dict[str, Any]]: + """List active sessions.""" + params: dict[str, Any] = {"limit": limit, "includeLastMessage": include_last_message} + if agent_id: + params["agentId"] = agent_id + + result = await self._send_request("sessions.list", params) + return result.get("sessions", []) + + async def resolve_session( + self, + agent_id: str | None = None, + label: str | None = None, + channel: str | None = None, + include_global: bool = True, + ) -> str | None: + """Resolve a session key by agent and optional channel.""" + params: dict[str, Any] = {"includeGlobal": include_global} + if agent_id: + params["agentId"] = agent_id + if label: + params["label"] = label + + result = await self._send_request("sessions.resolve", params) + sessions = result.get("sessions", []) + if sessions: + return sessions[0].get("key") + return None + + async def send_message( + self, + session_key: str, + message: str, + thinking: str | None = None, + timeout_ms: int | None = None, + ) -> dict[str, Any]: + """Send a message to an agent session. + + Args: + session_key: The session key (format: agentId:channelId:accountId:conversationId) + message: The message text to send + thinking: Optional thinking/reasoning to include + timeout_ms: Timeout for the request + + Returns: + The response payload containing message ID and result + """ + params: dict[str, Any] = { + "key": session_key, + "message": message, + } + if thinking: + params["thinking"] = thinking + + # Use shorter timeout for send since it waits for agent response + result = await self._send_request( + "sessions.send", + params, + ) + return result + + async def subscribe(self, session_key: str) -> AsyncMessageIterator: + """Subscribe to messages from a session. + + Usage: + async for event in client.subscribe(session_key): + print(f"Event: {event.event}", event.payload) + + Args: + session_key: The session key to subscribe to + + Returns: + AsyncIterator of MessageEvents + """ + # First subscribe to the session + await self._send_request("sessions.messages.subscribe", {"key": session_key}) + + return AsyncMessageIterator(self, session_key) + + # ------------------------------------------------------------------------- + # Agent operations + # ------------------------------------------------------------------------- + + async def list_agents(self) -> list[dict[str, Any]]: + """List configured agents.""" + result = await self._send_request("agents.list", {}) + return result.get("agents", []) + + async def get_agent(self, agent_id: str) -> dict[str, Any] | None: + """Get agent details.""" + agents = await self.list_agents() + for agent in agents: + if agent.get("id") == agent_id: + return agent + return None + + # ------------------------------------------------------------------------- + # Channel operations + # ------------------------------------------------------------------------- + + async def channels_status(self, probe: bool = False) -> dict[str, Any]: + """Get channel status.""" + params = {"probe": probe} if probe else {} + return await self._send_request("channels.status", params) + + async def channels_list(self) -> list[dict[str, Any]]: + """List configured channels.""" + result = await self._send_request("channels.list", {}) + return result.get("channels", []) + + # ------------------------------------------------------------------------- + # Convenience methods + # ------------------------------------------------------------------------- + + async def send_to_agent( + self, + agent_id: str, + message: str, + channel: str | None = None, + label: str | None = None, + ) -> dict[str, Any]: + """Convenience method to send a message to an agent. + + Resolves the session automatically. + + Args: + agent_id: The agent ID + message: Message to send + channel: Optional channel to route through + label: Optional session label + + Returns: + The agent's response + """ + session_key = await self.resolve_session(agent_id=agent_id, label=label, channel=channel) + if not session_key: + raise ValueError(f"No session found for agent {agent_id}") + + return await self.send_message(session_key, message) + + def add_event_handler(self, handler: Callable[[MessageEvent], None]) -> None: + """Add an event handler for incoming events.""" + self._event_handlers.append(handler) + + def remove_event_handler(self, handler: Callable[[MessageEvent], None]) -> None: + """Remove an event handler.""" + self._event_handlers.remove(handler) + + +class AsyncMessageIterator: + """Async iterator for session messages.""" + + def __init__(self, client: OpenClawWebSocketClient, session_key: str): + self._client = client + self._session_key = session_key + self._queue: asyncio.Queue[MessageEvent] = asyncio.Queue() + self._handler_added = False + + def _on_event(self, event: MessageEvent) -> None: + """Handle incoming event and check if it's for our session.""" + # Filter to session-specific events + payload = event.payload or {} + event_session_key = payload.get("sessionKey") or payload.get("key") + if event_session_key == self._session_key or event.event.startswith("sessions."): + self._queue.put_nowait(event) + + async def __aiter__(self) -> "AsyncMessageIterator": + if not self._handler_added: + self._client.add_event_handler(self._on_event) + self._handler_added = True + return self + + async def __anext__(self) -> MessageEvent: + return await self._queue.get() + + +# ----------------------------------------------------------------------------- +# Synchronous convenience functions +# ----------------------------------------------------------------------------- + +async def send_to_agent( + message: str, + agent_id: str, + gateway_url: str = DEFAULT_GATEWAY_URL, + gateway_token: str | None = None, + channel: str | None = None, + label: str | None = None, + timeout_ms: int = 60000, +) -> dict[str, Any]: + """Send a message to an agent and wait for response. + + This is a convenience function for one-shot message sending. + + Args: + message: The message to send + agent_id: The agent ID to target + gateway_url: Gateway WebSocket URL + gateway_token: Optional gateway auth token + channel: Optional channel to route through + label: Optional session label + timeout_ms: Timeout in milliseconds + + Returns: + The agent's response + + Example: + response = await send_to_agent("Hello!", agent_id="my-agent") + """ + async with OpenClawWebSocketClient( + url=gateway_url, + gateway_token=gateway_token, + ) as client: + await client.connect() + return await client.send_to_agent( + agent_id=agent_id, + message=message, + channel=channel, + label=label, + ) + + +async def list_active_sessions( + gateway_url: str = DEFAULT_GATEWAY_URL, + gateway_token: str | None = None, + agent_id: str | None = None, +) -> list[dict[str, Any]]: + """List active sessions. + + Args: + gateway_url: Gateway WebSocket URL + gateway_token: Optional gateway auth token + agent_id: Optional agent ID to filter by + + Returns: + List of active sessions + """ + async with OpenClawWebSocketClient( + url=gateway_url, + gateway_token=gateway_token, + ) as client: + await client.connect() + return await client.list_sessions(agent_id=agent_id) diff --git a/shared/models/__init__.py b/shared/models/__init__.py new file mode 100644 index 0000000..1b4a6c7 --- /dev/null +++ b/shared/models/__init__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""Shared data models.""" + +from shared.models.openclaw import ( + OpenClawStatus, + SessionEntry, + SessionHistory, + SessionHistoryEvent, + SessionsList, + CronJob, + CronList, + ApprovalRequest, + ApprovalsList, + normalize_status, + normalize_sessions, + normalize_session_history, + normalize_cron_jobs, + normalize_approvals, +) + +__all__ = [ + "OpenClawStatus", + "SessionEntry", + "SessionHistory", + "SessionHistoryEvent", + "SessionsList", + "CronJob", + "CronList", + "ApprovalRequest", + "ApprovalsList", + "normalize_status", + "normalize_sessions", + "normalize_session_history", + "normalize_cron_jobs", + "normalize_approvals", +] diff --git a/shared/models/openclaw.py b/shared/models/openclaw.py new file mode 100644 index 0000000..bb56471 --- /dev/null +++ b/shared/models/openclaw.py @@ -0,0 +1,1103 @@ +# -*- coding: utf-8 -*- +"""Stable Pydantic models for OpenClaw CLI output. + +These models normalize the raw JSON from `openclaw status --json`, +`sessions --json`, `cron list --json`, and `approvals get --json` +into a consistent, well-typed structure with safe accessors. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Literal + +from pydantic import AliasChoices, BaseModel, Field + + +# --------------------------------------------------------------------------- +# Status +# --------------------------------------------------------------------------- + +class HeartbeatAgentStatus(BaseModel): + """Heartbeat status for a single agent.""" + + agent_id: str + enabled: bool + every: str = "" + every_ms: int | None = None + + +class HeartbeatStatus(BaseModel): + """Heartbeat section of status output.""" + + default_agent_id: str + agents: list[HeartbeatAgentStatus] = Field(default_factory=list) + + +class LinkChannel(BaseModel): + """Linked channel info in status output.""" + + id: str + label: str + linked: bool + auth_age_ms: int | None = None + + +class SessionDefaults(BaseModel): + """Session defaults in status output.""" + + model: str | None = None + context_tokens: int | None = None + + +class SessionStatus(BaseModel): + """A single session's live status fields.""" + + key: str + kind: Literal["direct", "group", "global", "unknown"] = "unknown" + agent_id: str | None = None + session_id: str | None = None + updated_at: int | None = Field(default=None, validation_alias="updatedAt") + age_ms: int | None = Field(default=None, validation_alias="ageMs") + thinking_level: str | None = Field(default=None) + fast_mode: bool | None = None + verbose_level: str | None = None + reasoning_level: str | None = None + elevated_level: str | None = None + system_sent: bool | None = None + aborted_last_run: bool | None = None + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + total_tokens_fresh: bool | None = None + cache_read: int | None = None + cache_write: int | None = None + remaining_tokens: int | None = None + percent_used: float | None = None + model: str | None = None + context_tokens: int | None = None + flags: list[str] = Field(default_factory=list) + + @property + def updated_at_dt(self) -> datetime | None: + if self.updated_at is None: + return None + return datetime.fromtimestamp(self.updated_at / 1000, tz=timezone.utc) + + @property + def age_str(self) -> str: + if self.age_ms is None: + return "?" + seconds = int(self.age_ms / 1000) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m" + hours = minutes // 60 + if hours < 24: + return f"{hours}h" + days = hours // 24 + return f"{days}d" + + +class SessionsStatusGroup(BaseModel): + """Sessions grouped by agent.""" + + agent_id: str + path: str + count: int + recent: list[SessionStatus] = Field(default_factory=list) + + +class SessionsStatus(BaseModel): + """Sessions section of status output.""" + + paths: list[str] = Field(default_factory=list) + count: int = 0 + defaults: SessionDefaults = Field(default_factory=SessionDefaults) + recent: list[SessionStatus] = Field(default_factory=list) + by_agent: list[SessionsStatusGroup] = Field(default_factory=list) + + +class UpdateChannelInfo(BaseModel): + """Update channel info.""" + + channel: str | None = None + source: str | None = None + + +class UpdateInfo(BaseModel): + """Update info in status output.""" + + install_kind: str | None = Field(default=None, validation_alias="installKind") + git_tag: str | None = Field(default=None, validation_alias="gitTag") + git_branch: str | None = Field(default=None, validation_alias="gitBranch") + + +class OsSummary(BaseModel): + """OS summary in status output.""" + + os: str | None = None + arch: str | None = None + hostname: str | None = None + + +class MemoryInfo(BaseModel): + """Memory info in status output.""" + + used_mb: float | None = Field(default=None, validation_alias="usedMb") + total_mb: float | None = None + percent_used: float | None = None + + +class SecretDiagnostics(BaseModel): + """Secret diagnostics summary.""" + + total: int = 0 + providers: dict[str, int] = Field(default_factory=dict) + + +class GatewayStatus(BaseModel): + """Gateway connection status.""" + + mode: str | None = None + url: str | None = None + url_source: str | None = Field(default=None, validation_alias="urlSource") + misconfigured: bool | None = None + reachable: bool | None = None + connect_latency_ms: int | None = Field(default=None, validation_alias="connectLatencyMs") + self_: str | None = Field(default=None, validation_alias="self") + error: str | None = None + auth_warning: str | None = Field(default=None, validation_alias="authWarning") + + +class AgentHealth(BaseModel): + """Per-agent health status.""" + + agent_id: str | None = Field(default=None, validation_alias="agentId") + status: str | None = None + model: str | None = None + error: str | None = None + + +class OpenClawStatus(BaseModel): + """Complete parsed output of `openclaw status --json`. + + All fields are optional so that partial/incomplete responses + from older CLI versions still parse cleanly. + """ + + runtime_version: str | None = Field(default=None, validation_alias="runtimeVersion") + link_channel: LinkChannel | None = Field(default=None, validation_alias="linkChannel") + heartbeat: HeartbeatStatus | None = None + channel_summary: list[str] = Field(default_factory=list) + queued_system_events: list[str] = Field(default_factory=list) + sessions: SessionsStatus | None = None + os: OsSummary | None = None + update: UpdateInfo | None = None + update_channel: UpdateChannelInfo | None = Field(default=None, validation_alias="updateChannel") + memory: MemoryInfo | None = None + memory_plugin: str | None = Field(default=None, validation_alias="memoryPlugin") + gateway: GatewayStatus | None = None + gateway_service: dict[str, Any] | None = Field(default=None, validation_alias="gatewayService") + node_service: dict[str, Any] | None = Field(default=None, validation_alias="nodeService") + agents: list[AgentHealth] = Field(default_factory=list) + secret_diagnostics: SecretDiagnostics | None = Field(default=None, validation_alias="secretDiagnostics") + health: dict[str, Any] | None = None + usage: dict[str, Any] | None = None + last_heartbeat: dict[str, Any] | None = Field(default=None, validation_alias="lastHeartbeat") + + +# --------------------------------------------------------------------------- +# Sessions +# --------------------------------------------------------------------------- + +class SessionEntry(BaseModel): + """A single session entry from `openclaw sessions --json`.""" + + key: str = Field(validation_alias="key") + session_key: str | None = Field(default=None, validation_alias="sessionKey") + session_id: str | None = Field(default=None, validation_alias="sessionId") + updated_at: int | None = Field(default=None, validation_alias="updatedAt") + age_ms: int | None = Field(default=None, validation_alias="ageMs") + model: str | None = None + model_provider: str | None = Field(default=None, validation_alias="modelProvider") + kind: Literal["direct", "group", "global", "unknown"] = "unknown" + agent_id: str | None = Field(default=None, validation_alias="agentId") + flags: list[str] = Field(default_factory=list) + + @property + def resolved_key(self) -> str: + return self.session_key or self.key + + @property + def updated_at_dt(self) -> datetime | None: + if self.updated_at is None: + return None + return datetime.fromtimestamp(self.updated_at / 1000, tz=timezone.utc) + + @property + def age_str(self) -> str: + if self.age_ms is None: + return "?" + seconds = int(self.age_ms / 1000) + if seconds < 60: + return f"{seconds}s" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m" + hours = minutes // 60 + if hours < 24: + return f"{hours}h" + days = hours // 24 + return f"{days}d" + + +class SessionHistoryEvent(BaseModel): + """A single event within a session history.""" + + ts: int | None = None + type: str = "" + role: str | None = None + content: str | None = None + model: str | None = None + tokens: int | None = None + channel_id: str | None = Field(default=None, validation_alias="channelId") + + @property + def ts_dt(self) -> datetime | None: + if self.ts is None: + return None + return datetime.fromtimestamp(self.ts / 1000, tz=timezone.utc) + + +class SessionHistory(BaseModel): + """Session history response from `openclaw sessions history --json`.""" + + session_key: str = Field(validation_alias="sessionKey") + session_id: str | None = Field(default=None, validation_alias="sessionId") + events: list[SessionHistoryEvent] = Field(default_factory=list) + raw_text: str | None = Field(default=None, validation_alias="rawText") + + +class SessionsList(BaseModel): + """Parsed response from `openclaw sessions --json`.""" + + sessions: list[SessionEntry] = Field(default_factory=list) + + def get(self, key: str) -> SessionEntry | None: + for s in self.sessions: + if s.key == key or s.session_key == key: + return s + return None + + +# --------------------------------------------------------------------------- +# Cron +# --------------------------------------------------------------------------- + +class CronJob(BaseModel): + """A single cron job from `openclaw cron list --json`.""" + + id: str | None = None + label: str | None = None + schedule: str | None = None + cron: str | None = None + command: str | None = None + agent_id: str | None = Field(default=None, validation_alias="agentId") + enabled: bool = True + status: str | None = None + next_run_at: int | None = Field(default=None, validation_alias="nextRunAt") + last_run_at: int | None = Field(default=None, validation_alias="lastRunAt") + last_exit_code: int | None = Field(default=None, validation_alias="lastExitCode") + + @property + def display_schedule(self) -> str: + return self.schedule or self.cron or "?" + + @property + def next_run_dt(self) -> datetime | None: + if self.next_run_at is None: + return None + return datetime.fromtimestamp(self.next_run_at / 1000, tz=timezone.utc) + + +class CronList(BaseModel): + """Parsed response from `openclaw cron list --json`.""" + + cron: list[CronJob] = Field(default_factory=list) + jobs: list[CronJob] = Field(default_factory=list) + + @classmethod + def from_raw(cls, raw: dict[str, Any] | None) -> "CronList": + if not raw: + return cls() + raw_jobs: list[dict[str, Any]] = raw.get("cron") or raw.get("jobs") or [] + jobs = [CronJob.model_validate(j, strict=False) if isinstance(j, dict) else j for j in raw_jobs] + return cls(cron=jobs, jobs=jobs) + + def __iter__(self): + return iter(self.cron) + + def __len__(self): + return len(self.cron) + + +# --------------------------------------------------------------------------- +# Approvals +# --------------------------------------------------------------------------- + +class ExecApprovalToolInput(BaseModel): + """Structured tool_input from an approval request.""" + + command: str | None = None + command_preview: str | None = Field(default=None, validation_alias="commandPreview") + cwd: str | None = None + env_keys: list[str] = Field(default_factory=list, validation_alias="envKeys") + argv: list[str] = Field(default_factory=list) + + +class ExecApprovalFinding(BaseModel): + """A security finding attached to an approval request.""" + + severity: Literal["critical", "high", "medium", "low", "info"] + message: str + check: str | None = None + + +class ApprovalRequest(BaseModel): + """A single approval request from `openclaw approvals get --json`.""" + + approval_id: str = Field(validation_alias="approvalId") + tool_name: str = Field(validation_alias="toolName") + status: Literal["pending", "approved", "denied", "expired"] = "pending" + agent_id: str | None = Field(default=None, validation_alias="agentId") + workspace_id: str | None = Field(default=None, validation_alias="workspaceId") + session_id: str | None = Field(default=None, validation_alias="sessionId") + created_at: int | None = Field(default=None, validation_alias="createdAt") + expires_at: int | None = Field(default=None, validation_alias="expiresAt") + tool_input: ExecApprovalToolInput | None = Field(default=None, validation_alias="toolInput") + findings: list[ExecApprovalFinding] = Field(default_factory=list) + + @property + def created_at_dt(self) -> datetime | None: + if self.created_at is None: + return None + return datetime.fromtimestamp(self.created_at / 1000, tz=timezone.utc) + + @property + def is_expired(self) -> bool: + if self.expires_at is None: + return False + return datetime.now(timezone.utc).timestamp() * 1000 > self.expires_at + + +class ApprovalsList(BaseModel): + """Parsed response from `openclaw approvals get --json`.""" + + approvals: list[ApprovalRequest] = Field(default_factory=list) + pending: list[ApprovalRequest] = Field(default_factory=list) + + @classmethod + def from_raw(cls, raw: dict[str, Any] | None) -> "ApprovalsList": + if not raw: + return cls() + raw_approvals: list[dict[str, Any]] = raw.get("approvals") or [] + approvals = [ + ApprovalRequest.model_validate(a, strict=False) if isinstance(a, dict) else a + for a in raw_approvals + ] + pending = [a for a in approvals if a.status == "pending" and not a.is_expired] + return cls(approvals=approvals, pending=pending) + + def __iter__(self): + return iter(self.approvals) + + def __len__(self): + return len(self.approvals) + + +# --------------------------------------------------------------------------- +# Normalization helpers +# --------------------------------------------------------------------------- + +def normalize_status(raw: dict[str, Any] | None) -> OpenClawStatus: + """Parse raw status dict into a strongly-typed OpenClawStatus. + + Silently ignores unknown fields so schema changes in newer CLI + versions don't break existing callers. + """ + if not raw: + return OpenClawStatus() + return OpenClawStatus.model_validate(raw, strict=False) + + +def normalize_sessions(raw: dict[str, Any] | None) -> SessionsList: + """Parse raw sessions dict into a strongly-typed SessionsList.""" + if not raw: + return SessionsList() + sessions = raw.get("sessions") or [] + return SessionsList( + sessions=[ + SessionEntry.model_validate(s, strict=False) for s in sessions if isinstance(s, dict) + ] + ) + + +def normalize_session_history( + raw: dict[str, Any] | None, + session_key: str = "", +) -> SessionHistory: + """Parse raw session history dict into a SessionHistory.""" + if not raw: + return SessionHistory(session_key=session_key, events=[]) + history = raw.get("history") or [] + return SessionHistory.model_validate( + { + **raw, + "sessionKey": raw.get("sessionKey", session_key), + "events": [ + SessionHistoryEvent.model_validate(e, strict=False) + for e in history + if isinstance(e, dict) + ], + }, + strict=False, + ) + + +def normalize_cron_jobs(raw: dict[str, Any] | None) -> CronList: + """Parse raw cron list dict into a CronList.""" + return CronList.from_raw(raw) + + +def normalize_approvals(raw: dict[str, Any] | None) -> ApprovalsList: + """Parse raw approvals dict into an ApprovalsList.""" + return ApprovalsList.from_raw(raw) + + +# --------------------------------------------------------------------------- +# Agents +# --------------------------------------------------------------------------- + + +class AgentSummary(BaseModel): + """A single agent summary from `openclaw agents list --json`.""" + + id: str + name: str | None = None + identity_name: str | None = Field(default=None, validation_alias=AliasChoices("identityName", "identity_name")) + identity_emoji: str | None = Field(default=None, validation_alias=AliasChoices("identityEmoji", "identity_emoji")) + identity_source: Literal["identity", "config"] | None = Field(default=None, validation_alias=AliasChoices("identitySource", "identity_source")) + workspace: str = "" + agent_dir: str = Field(default="", validation_alias=AliasChoices("agentDir", "agent_dir")) + model: str | None = None + bindings: int = 0 + binding_details: list[str] = Field(default_factory=list, validation_alias=AliasChoices("bindingDetails", "binding_details")) + routes: list[str] = Field(default_factory=list) + providers: list[str] = Field(default_factory=list) + is_default: bool = Field(default=False, validation_alias=AliasChoices("isDefault", "is_default")) + + +class AgentsList(BaseModel): + """Parsed response from `openclaw agents list --json`.""" + + agents: list[AgentSummary] = Field(default_factory=list) + + def __iter__(self): + return iter(self.agents) + + def __len__(self): + return len(self.agents) + + +# --------------------------------------------------------------------------- +# Skills +# --------------------------------------------------------------------------- + + +class SkillRequirement(BaseModel): + """Requirements entry for a skill.""" + + bins: list[str] = Field(default_factory=list) + any_bins: list[str] = Field(default_factory=list, validation_alias="anyBins") + env: list[str] = Field(default_factory=list) + config: list[str] = Field(default_factory=list) + os: list[str] = Field(default_factory=list) + + +class SkillStatusEntry(BaseModel): + """A single skill entry from `openclaw skills list --json`.""" + + name: str = "" + description: str = "" + source: str = "" + bundled: bool = False + file_path: str = Field(default="", validation_alias="filePath") + base_dir: str = Field(default="", validation_alias="baseDir") + skill_key: str = Field(default="", validation_alias="skillKey") + primary_env: str | None = Field(default=None, validation_alias="primaryEnv") + emoji: str | None = None + homepage: str | None = None + always: bool = False + disabled: bool = False + blocked_by_allowlist: bool = Field(default=False, validation_alias="blockedByAllowlist") + eligible: bool = True + requirements: SkillRequirement = Field(default_factory=SkillRequirement) + missing: SkillRequirement = Field(default_factory=SkillRequirement, validation_alias="missing") + config_checks: list[dict[str, Any]] = Field(default_factory=list, validation_alias="configChecks") + install: list[dict[str, Any]] = Field(default_factory=list) + + +class SkillStatusReport(BaseModel): + """Parsed response from `openclaw skills list --json`.""" + + workspace_dir: str = Field(default="", validation_alias="workspaceDir") + managed_skills_dir: str = Field(default="", validation_alias="managedSkillsDir") + skills: list[SkillStatusEntry] = Field(default_factory=list) + + def __iter__(self): + return iter(self.skills) + + def __len__(self): + return len(self.skills) + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + + +class ModelRow(BaseModel): + """A single model row from `openclaw models list --json`.""" + + key: str = "" + name: str = "" + input: str = "" + context_window: int | None = Field(default=None, validation_alias="contextWindow") + local: bool | None = None + available: bool | None = None + tags: list[str] = Field(default_factory=list) + missing: bool = False + + +class ModelsList(BaseModel): + """Parsed response from `openclaw models list --json`.""" + + models: list[ModelRow] = Field(default_factory=list) + + def __iter__(self): + return iter(self.models) + + def __len__(self): + return len(self.models) + + +# --------------------------------------------------------------------------- +# Normalization helpers (agents, skills, models) +# --------------------------------------------------------------------------- + + +def normalize_agents(raw: dict[str, Any] | None) -> AgentsList: + """Parse raw agents list dict into an AgentsList.""" + if not raw: + return AgentsList() + agents = raw.get("agents") or (raw if isinstance(raw, list) else []) + if isinstance(agents, list): + return AgentsList( + agents=[ + AgentSummary.model_validate(a, strict=False) for a in agents if isinstance(a, dict) + ] + ) + return AgentsList() + + +def normalize_skills(raw: dict[str, Any] | None) -> SkillStatusReport: + """Parse raw skills list dict into a SkillStatusReport.""" + if not raw: + return SkillStatusReport() + skills = raw.get("skills") or [] + workspace_dir = raw.get("workspaceDir", "") + managed_skills_dir = raw.get("managedSkillsDir", "") + parsed_skills = [ + SkillStatusEntry.model_validate(s, strict=False) for s in skills if isinstance(s, dict) + ] + return SkillStatusReport( + workspace_dir=workspace_dir, + managed_skills_dir=managed_skills_dir, + skills=parsed_skills, + ) + + +def normalize_models(raw: dict[str, Any] | None) -> ModelsList: + """Parse raw models list dict into a ModelsList.""" + if not raw: + return ModelsList() + models = raw.get("models") or raw if isinstance(raw, list) else [] + if isinstance(models, list): + return ModelsList( + models=[ModelRow.model_validate(m, strict=False) for m in models if isinstance(m, dict)] + ) + return ModelsList() + + +# --------------------------------------------------------------------------- +# Hooks +# --------------------------------------------------------------------------- + + +class HookInstallOption(BaseModel): + id: str = "" + kind: str = "" + label: str = "" + bins: list[str] = Field(default_factory=list) + + +class HookStatusEntry(BaseModel): + name: str = "" + description: str = "" + source: str = "" + plugin_id: str | None = Field(default=None, validation_alias="pluginId") + file_path: str = Field(default="", validation_alias="filePath") + base_dir: str = Field(default="", validation_alias="baseDir") + handler_path: str = Field(default="", validation_alias="handlerPath") + hook_key: str = Field(default="", validation_alias="hookKey") + emoji: str | None = None + homepage: str | None = None + events: list[str] = Field(default_factory=list) + always: bool = False + enabled_by_config: bool = Field(default=False, validation_alias="enabledByConfig") + requirements_satisfied: bool = Field(default=False, validation_alias="requirementsSatisfied") + loadable: bool = False + blocked_reason: str | None = Field(default=None, validation_alias="blockedReason") + managed_by_plugin: bool = Field(default=False, validation_alias="managedByPlugin") + requirements: SkillRequirement = Field(default_factory=SkillRequirement) + missing: SkillRequirement = Field(default_factory=SkillRequirement) + config_checks: list[dict[str, Any]] = Field(default_factory=list, validation_alias="configChecks") + install: list[HookInstallOption] = Field(default_factory=list) + + +class HookStatusReport(BaseModel): + workspace_dir: str = Field(default="", validation_alias="workspaceDir") + managed_hooks_dir: str = Field(default="", validation_alias="managedHooksDir") + hooks: list[HookStatusEntry] = Field(default_factory=list) + + def __iter__(self): + return iter(self.hooks) + + def __len__(self): + return len(self.hooks) + + +# --------------------------------------------------------------------------- +# Plugins +# --------------------------------------------------------------------------- + + +class PluginRecord(BaseModel): + id: str = "" + name: str = "" + version: str | None = None + description: str | None = None + format: str | None = None + bundle_format: str | None = Field(default=None, validation_alias="bundleFormat") + bundle_capabilities: list[str] = Field(default_factory=list, validation_alias="bundleCapabilities") + kind: str | None = None + source: str = "" + root_dir: str | None = Field(default=None, validation_alias="rootDir") + origin: dict[str, Any] = Field(default_factory=dict) + workspace_dir: str | None = Field(default=None, validation_alias="workspaceDir") + enabled: bool = False + status: Literal["loaded", "disabled", "error"] = "disabled" + error: str | None = None + tool_names: list[str] = Field(default_factory=list, validation_alias="toolNames") + hook_names: list[str] = Field(default_factory=list, validation_alias="hookNames") + channel_ids: list[str] = Field(default_factory=list, validation_alias="channelIds") + provider_ids: list[str] = Field(default_factory=list, validation_alias="providerIds") + speech_provider_ids: list[str] = Field(default_factory=list, validation_alias="speechProviderIds") + media_understanding_provider_ids: list[str] = Field(default_factory=list, validation_alias="mediaUnderstandingProviderIds") + image_generation_provider_ids: list[str] = Field(default_factory=list, validation_alias="imageGenerationProviderIds") + web_search_provider_ids: list[str] = Field(default_factory=list, validation_alias="webSearchProviderIds") + gateway_methods: list[str] = Field(default_factory=list, validation_alias="gatewayMethods") + cli_commands: list[str] = Field(default_factory=list, validation_alias="cliCommands") + services: list[str] = Field(default_factory=list) + commands: list[str] = Field(default_factory=list) + http_routes: int = 0 + hook_count: int = Field(default=0, validation_alias="hookCount") + config_schema: bool = False + + +class PluginDiagnostic(BaseModel): + id: str = "" + message: str = "" + + +class PluginsList(BaseModel): + workspace_dir: str = Field(default="", validation_alias="workspaceDir") + plugins: list[PluginRecord] = Field(default_factory=list) + diagnostics: list[PluginDiagnostic] = Field(default_factory=list) + + def __iter__(self): + return iter(self.plugins) + + def __len__(self): + return len(self.plugins) + + +# --------------------------------------------------------------------------- +# Secrets +# --------------------------------------------------------------------------- + + +class SecretsAuditFinding(BaseModel): + code: str = "" + severity: Literal["info", "warn", "error"] = "info" + file: str = "" + json_path: str = Field(default="", validation_alias="jsonPath") + message: str = "" + provider: str | None = None + profile_id: str | None = Field(default=None, validation_alias="profileId") + + +class SecretsAuditSummary(BaseModel): + plaintext_count: int = Field(default=0, validation_alias="plaintextCount") + unresolved_ref_count: int = Field(default=0, validation_alias="unresolvedRefCount") + shadowed_ref_count: int = Field(default=0, validation_alias="shadowedRefCount") + legacy_residue_count: int = Field(default=0, validation_alias="legacyResidueCount") + + +class SecretsAuditResolution(BaseModel): + refs_checked: int = Field(default=0, validation_alias="refsChecked") + skipped_exec_refs: int = Field(default=0, validation_alias="skippedExecRefs") + resolvability_complete: bool = Field(default=False, validation_alias="resolvabilityComplete") + + +class SecretsAuditReport(BaseModel): + version: int = 1 + status: Literal["clean", "findings", "unresolved"] = "clean" + resolution: SecretsAuditResolution = Field(default_factory=SecretsAuditResolution) + files_scanned: list[str] = Field(default_factory=list, validation_alias="filesScanned") + summary: SecretsAuditSummary = Field(default_factory=SecretsAuditSummary) + findings: list[SecretsAuditFinding] = Field(default_factory=list) + + @classmethod + def from_raw(cls, raw: dict[str, Any] | None) -> "SecretsAuditReport": + if not raw: + return cls() + return cls.model_validate(raw, strict=False) + + +# --------------------------------------------------------------------------- +# Security +# --------------------------------------------------------------------------- + + +class SecurityAuditFinding(BaseModel): + check_id: str = Field(default="", validation_alias="checkId") + severity: Literal["info", "warn", "critical"] = "info" + title: str = "" + detail: str = "" + remediation: str | None = None + + +class SecurityAuditSummary(BaseModel): + critical: int = 0 + warn: int = 0 + info: int = 0 + + +class SecurityAuditGatewayResult(BaseModel): + attempted: bool = False + url: str | None = None + ok: bool = False + error: str | None = None + close: dict[str, Any] | None = None + + +class SecurityAuditDeep(BaseModel): + gateway: SecurityAuditGatewayResult | None = None + + +class SecurityAuditReport(BaseModel): + ts: int = 0 + summary: SecurityAuditSummary = Field(default_factory=SecurityAuditSummary) + findings: list[SecurityAuditFinding] = Field(default_factory=list) + deep: SecurityAuditDeep | None = None + + +class SecurityAuditResponse(BaseModel): + fix: dict[str, Any] | None = None + report: SecurityAuditReport | None = None + secret_diagnostics: list[str] = Field(default_factory=list, validation_alias="secretDiagnostics") + + +# --------------------------------------------------------------------------- +# Daemon +# --------------------------------------------------------------------------- + + +class DaemonServiceCommand(BaseModel): + program_arguments: list[str] = Field(default_factory=list, validation_alias="programArguments") + working_directory: str | None = Field(default=None, validation_alias="workingDirectory") + environment: dict[str, str] | None = None + source_path: str | None = Field(default=None, validation_alias="sourcePath") + + +class DaemonServiceInfo(BaseModel): + label: str = "" + loaded: bool = False + loaded_text: str = Field(default="", validation_alias="loadedText") + not_loaded_text: str = Field(default="", validation_alias="notLoadedText") + command: DaemonServiceCommand | None = None + runtime: dict[str, Any] | None = None + config_audit: dict[str, Any] | None = Field(default=None, validation_alias="configAudit") + + +class DaemonPortListener(BaseModel): + port: int = 0 + status: str = "" + type: str = "" + + +class DaemonPortInfo(BaseModel): + port: int = 0 + status: str = "" + listeners: list[DaemonPortListener] = Field(default_factory=list) + hints: list[str] = Field(default_factory=list) + + +class DaemonRpcInfo(BaseModel): + ok: bool = False + error: str | None = None + url: str | None = None + auth_warning: str | None = Field(default=None, validation_alias="authWarning") + + +class DaemonHealthInfo(BaseModel): + healthy: bool = False + stale_gateway_pids: list[int] = Field(default_factory=list, validation_alias="staleGatewayPids") + + +class DaemonExtraService(BaseModel): + label: str = "" + detail: str = "" + scope: str = "" + + +class DaemonStatus(BaseModel): + service: DaemonServiceInfo | None = None + config: dict[str, Any] | None = None + gateway: dict[str, Any] | None = None + port: DaemonPortInfo | None = None + port_cli: DaemonPortInfo | None = Field(default=None, validation_alias="portCli") + last_error: str | None = Field(default=None, validation_alias="lastError") + rpc: DaemonRpcInfo | None = None + health: DaemonHealthInfo | None = None + extra_services: list[DaemonExtraService] = Field(default_factory=list, validation_alias="extraServices") + + +# --------------------------------------------------------------------------- +# QR / Pairing +# --------------------------------------------------------------------------- + + +class PairingRequest(BaseModel): + code: str = "" + id: str = "" + meta: dict[str, Any] | None = None + created_at: str = Field(default="", validation_alias="createdAt") + + +class PairingListResponse(BaseModel): + channel: str = "" + requests: list[PairingRequest] = Field(default_factory=list) + + +class QrCodeResponse(BaseModel): + setup_code: str = Field(default="", validation_alias="setupCode") + gateway_url: str = Field(default="", validation_alias="gatewayUrl") + auth: Literal["token", "password"] = "token" + url_source: str = Field(default="", validation_alias="urlSource") + + +# --------------------------------------------------------------------------- +# Update status +# --------------------------------------------------------------------------- + + +class UpdateGitStatus(BaseModel): + root: str = "" + sha: str | None = None + tag: str | None = None + branch: str | None = None + upstream: str | None = None + dirty: bool | None = None + ahead: int | None = None + behind: int | None = None + fetch_ok: bool = Field(default=False, validation_alias="fetchOk") + error: str | None = None + + +class UpdateDepsStatus(BaseModel): + manager: str = "" + status: Literal["ok", "missing", "stale", "unknown"] = "unknown" + lockfile_path: str | None = Field(default=None, validation_alias="lockfilePath") + marker_path: str | None = Field(default=None, validation_alias="markerPath") + reason: str | None = None + + +class UpdateRegistryStatus(BaseModel): + latest_version: str | None = Field(default=None, validation_alias="latestVersion") + error: str | None = None + + +class UpdateCheckResult(BaseModel): + root: str | None = None + install_kind: Literal["git", "package", "unknown"] = "unknown" + package_manager: str = "" + git: UpdateGitStatus | None = None + deps: UpdateDepsStatus | None = None + registry: UpdateRegistryStatus | None = None + + +class UpdateChannelInfo2(BaseModel): + value: str | None = None + source: str = "" + label: str = "" + config: str | None = None + + +class UpdateStatusResponse(BaseModel): + update: UpdateCheckResult | None = None + channel: UpdateChannelInfo2 | None = None + availability: dict[str, Any] | None = None + + +# --------------------------------------------------------------------------- +# Models aliases / fallbacks +# --------------------------------------------------------------------------- + + +class ModelAliasEntry(BaseModel): + alias: str = "" + target: str = "" + + +class ModelAliasesList(BaseModel): + aliases: dict[str, str] = Field(default_factory=dict) + + def items_list(self) -> list[ModelAliasEntry]: + return [ModelAliasEntry(alias=k, target=v) for k, v in self.aliases.items()] + + +class ModelFallbackItem(BaseModel): + raw: str = "" + provider: str = "" + model: str = "" + + +class ModelFallbacksList(BaseModel): + key: str = "" + label: str = "" + items: list[ModelFallbackItem] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Skills update result +# --------------------------------------------------------------------------- + + +class SkillUpdateResult(BaseModel): + ok: bool = False + slug: str = "" + previous_version: str | None = Field(default=None, validation_alias="previousVersion") + version: str = "" + changed: bool = False + target_dir: str = Field(default="", validation_alias="targetDir") + error: str | None = None + + +# --------------------------------------------------------------------------- +# Normalization helpers +# --------------------------------------------------------------------------- + + +def normalize_hooks(raw: dict[str, Any] | None) -> HookStatusReport: + if not raw: + return HookStatusReport() + hooks = raw.get("hooks") or [] + workspace_dir = raw.get("workspaceDir", "") + managed_hooks_dir = raw.get("managedHooksDir", "") + parsed = [ + HookStatusEntry.model_validate(h, strict=False) for h in hooks if isinstance(h, dict) + ] + return HookStatusReport( + workspace_dir=workspace_dir, + managed_hooks_dir=managed_hooks_dir, + hooks=parsed, + ) + + +def normalize_plugins(raw: dict[str, Any] | None) -> PluginsList: + if not raw: + return PluginsList() + plugins = raw.get("plugins") or [] + diagnostics = raw.get("diagnostics") or [] + return PluginsList( + workspace_dir=raw.get("workspaceDir", ""), + plugins=[PluginRecord.model_validate(p, strict=False) for p in plugins if isinstance(p, dict)], + diagnostics=[PluginDiagnostic.model_validate(d, strict=False) for d in diagnostics if isinstance(d, dict)], + ) + + +def normalize_secrets_audit(raw: dict[str, Any] | None) -> SecretsAuditReport: + return SecretsAuditReport.from_raw(raw) + + +def normalize_security_audit(raw: dict[str, Any] | None) -> SecurityAuditResponse: + if not raw: + return SecurityAuditResponse() + return SecurityAuditResponse.model_validate(raw, strict=False) + + +def normalize_daemon_status(raw: dict[str, Any] | None) -> DaemonStatus: + if not raw: + return DaemonStatus() + return DaemonStatus.model_validate(raw, strict=False) + + +def normalize_pairing(raw: dict[str, Any] | None) -> PairingListResponse: + if not raw: + return PairingListResponse() + return PairingListResponse.model_validate(raw, strict=False) + + +def normalize_qr(raw: dict[str, Any] | None) -> QrCodeResponse: + if not raw: + return QrCodeResponse() + return QrCodeResponse.model_validate(raw, strict=False) + + +def normalize_update_status(raw: dict[str, Any] | None) -> UpdateStatusResponse: + if not raw: + return UpdateStatusResponse() + return UpdateStatusResponse.model_validate(raw, strict=False) + + +def normalize_model_aliases(raw: dict[str, Any] | None) -> ModelAliasesList: + if not raw: + return ModelAliasesList() + return ModelAliasesList.model_validate(raw, strict=False) + + +def normalize_model_fallbacks(raw: dict[str, Any] | None) -> ModelFallbacksList: + if not raw: + return ModelFallbacksList() + return ModelFallbacksList.model_validate(raw, strict=False) + + +def normalize_skill_update(raw: dict[str, Any] | None) -> SkillUpdateResult: + if not raw: + return SkillUpdateResult() + return SkillUpdateResult.model_validate(raw, strict=False) diff --git a/start-dev.sh b/start-dev.sh index ce99506..5123e3a 100755 --- a/start-dev.sh +++ b/start-dev.sh @@ -57,6 +57,16 @@ cleanup() { fi } +kill_port() { + local port="$1" + local pids=$(lsof -ti :${port} 2>/dev/null || true) + if [ -n "$pids" ]; then + echo -e "${YELLOW}Port ${port} is in use, killing PID(s): ${pids}${NC}" + echo "$pids" | xargs kill -9 2>/dev/null || true + sleep 0.5 + fi +} + trap cleanup EXIT INT TERM if [ $# -gt 0 ]; then @@ -67,11 +77,13 @@ fi export TRADING_SERVICE_URL="${TRADING_SERVICE_URL:-http://localhost:8001}" export NEWS_SERVICE_URL="${NEWS_SERVICE_URL:-http://localhost:8002}" export RUNTIME_SERVICE_URL="${RUNTIME_SERVICE_URL:-http://localhost:8003}" +export OPENCLAW_SERVICE_URL="${OPENCLAW_SERVICE_URL:-http://localhost:18789}" echo "" echo -e "${GREEN}Starting EvoTraders split services (default mode)...${NC}" echo " agent_service: http://localhost:8000" echo " runtime_service: http://localhost:8003" +echo " openclaw_gateway: ws://localhost:18789" echo " trading_service: http://localhost:8001" echo " news_service: http://localhost:8002" echo "" @@ -79,13 +91,28 @@ echo "Exported backend preference URLs:" echo " TRADING_SERVICE_URL=${TRADING_SERVICE_URL}" echo " NEWS_SERVICE_URL=${NEWS_SERVICE_URL}" echo " RUNTIME_SERVICE_URL=${RUNTIME_SERVICE_URL}" +echo " OPENCLAW_SERVICE_URL=${OPENCLAW_SERVICE_URL}" echo "" +echo -e "${GREEN}Checking ports...${NC}" +kill_port 8000 +kill_port 8001 +kill_port 8002 +kill_port 8003 +kill_port 8765 + start_service "agent_service" "backend.apps.agent_service:app" 8000 start_service "runtime_service" "backend.apps.runtime_service:app" 8003 start_service "trading_service" "backend.apps.trading_service:app" 8001 start_service "news_service" "backend.apps.news_service:app" 8002 +echo -e "${GREEN}Starting Gateway (WebSocket, port 8765)...${NC}" +SERVICE_NAME="gateway" python -m backend.main \ + --mode live \ + --host 0.0.0.0 \ + --port 8765 & +PIDS+=($!) + echo -e "${GREEN}Split services are running.${NC}" echo "Use Ctrl+C to stop all services." wait