feat: OpenClaw WebSocket integration with workspace file preview
- Migrate OpenClaw from HTTP (port 8004) to WebSocket (port 18789) - Add workspace file list and content preview handlers - Add OpenClawStatus component with agent/skills view - Add OpenClawView panel in trader interface - Add Zustand store for OpenClaw state management - Fix gateway logging noise (yfinance, websockets) - Fix RunWorkspaceManager.get_agent_asset_dir attribute error - Handle missing workspace files gracefully in preview Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
839
backend/api/openclaw.py
Normal file
839
backend/api/openclaw.py
Normal file
@@ -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 <name>` 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 <id> [--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 <id>] [--bind <spec>]` 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 <id>] [--bind <spec>] [--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 <name> --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 <id>]` 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 <url>] [--token <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 <id>] [--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)
|
||||
49
backend/apps/openclaw_service.py
Normal file
49
backend/apps/openclaw_service.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
179
backend/services/gateway_openclaw_handlers.py
Normal file
179
backend/services/gateway_openclaw_handlers.py
Normal file
@@ -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}))
|
||||
754
backend/services/openclaw_cli.py
Normal file
754
backend/services/openclaw_cli.py
Normal file
@@ -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 <name> --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 <name> --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 <id>]`."""
|
||||
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 <url>] [--token <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 <id>] [--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 <name> [--workspace <dir>] [--model <id>] [--agent-dir <dir>] [--bind <spec>] [--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 <id> [--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 <id>] [--bind <spec>] --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 <id>] [--bind <spec>] [--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 <id>] [--workspace <dir>] [--identity-file <path>] [--from-identity] [--name <n>] [--emoji <e>] [--theme <t>] [--avatar <a>] --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,
|
||||
)
|
||||
60
backend/tests/test_openclaw_cli_service.py
Normal file
60
backend/tests/test_openclaw_cli_service.py
Normal file
@@ -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"
|
||||
110
backend/tests/test_openclaw_service_app.py
Normal file
110
backend/tests/test_openclaw_service_app.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user