Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/api/gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.openclaw.admin_service import GatewayAdminLifecycleService
from app.services.openclaw.gateway_listener_manager import gateway_listener_manager
from app.services.openclaw.session_service import GatewayTemplateSyncQuery

if TYPE_CHECKING:
Expand Down Expand Up @@ -197,6 +198,7 @@ async def delete_gateway(
gateway_id=gateway_id,
organization_id=ctx.organization.id,
)
await gateway_listener_manager.stop_for_gateway(gateway.id)
main_agent = await service.find_main_agent(gateway)
if main_agent is not None:
await service.clear_agent_foreign_keys(agent_id=main_agent.id)
Expand Down
3 changes: 3 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from app.core.security_headers import SecurityHeadersMiddleware
from app.db.session import init_db
from app.schemas.health import HealthStatusResponse
from app.services.openclaw.gateway_listener_manager import gateway_listener_manager

if TYPE_CHECKING:
from collections.abc import AsyncIterator
Expand Down Expand Up @@ -444,10 +445,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
logger.info("app.lifecycle.rate_limit backend=redis")
else:
logger.info("app.lifecycle.rate_limit backend=memory")
await gateway_listener_manager.start_all()
logger.info("app.lifecycle.started")
try:
yield
finally:
await gateway_listener_manager.stop_all()
logger.info("app.lifecycle.stopped")


Expand Down
15 changes: 15 additions & 0 deletions backend/app/models/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,20 @@ class Agent(QueryModel, table=True):
checkin_deadline_at: datetime | None = Field(default=None)
last_provision_error: str | None = Field(default=None, sa_column=Column(Text))
is_board_lead: bool = Field(default=False, index=True)
approval_policy: dict[str, object] | None = Field(
default=None,
sa_column=Column(JSON),
)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)


DEFAULT_APPROVAL_POLICY: dict[str, object] = {"mode": "immediate"}

APPROVAL_POLICY_MODE_IMMEDIATE = "immediate"
APPROVAL_POLICY_MODE_MANUAL = "manual"


def get_approval_policy(agent: Agent) -> dict[str, object]:
"""Return the agent's approval policy, defaulting to immediate mode."""
return (agent.approval_policy or DEFAULT_APPROVAL_POLICY).copy()
4 changes: 4 additions & 0 deletions backend/app/models/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,9 @@ class Board(TenantScoped, table=True):
block_status_changes_with_pending_approval: bool = Field(default=False)
only_lead_can_change_status: bool = Field(default=False)
max_agents: int = Field(default=1)
approval_policy: dict[str, object] | None = Field(
default=None,
sa_column=Column(JSON),
)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
10 changes: 10 additions & 0 deletions backend/app/schemas/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ class AgentBase(SQLModel):
description="Template representing deeper agent instructions.",
examples=["When critical blockers appear, escalate in plain language."],
)
approval_policy: dict[str, Any] | None = Field(
default=None,
description="Approval policy controlling how dangerous operations are authorized.",
examples=[{"mode": "immediate"}],
)

@field_validator("identity_template", "soul_template", mode="before")
@classmethod
Expand Down Expand Up @@ -190,6 +195,11 @@ class AgentUpdate(SQLModel):
description="Optional replacement soul template.",
examples=["Escalate only after checking all known mitigations."],
)
approval_policy: dict[str, Any] | None = Field(
default=None,
description="Optional approval policy controlling how dangerous operations are authorized.",
examples=[{"mode": "immediate"}],
)

@field_validator("identity_template", "soul_template", mode="before")
@classmethod
Expand Down
5 changes: 5 additions & 0 deletions backend/app/schemas/boards.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Self
from uuid import UUID

from pydantic import Field as PydanticField
from pydantic import model_validator
from sqlmodel import Field, SQLModel

Expand Down Expand Up @@ -35,6 +36,10 @@ class BoardBase(SQLModel):
block_status_changes_with_pending_approval: bool = False
only_lead_can_change_status: bool = False
max_agents: int = Field(default=1, ge=0)
approval_policy: dict[str, object] | None = PydanticField(
default=None,
description="Default approval policy for agents on this board.",
)


class BoardCreate(BoardBase):
Expand Down
6 changes: 5 additions & 1 deletion backend/app/services/openclaw/admin_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from app.core.time import utcnow
from app.db import crud
from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.agents import DEFAULT_APPROVAL_POLICY, Agent
from app.models.approvals import Approval
from app.models.board_webhooks import BoardWebhook
from app.models.gateways import Gateway
Expand Down Expand Up @@ -127,6 +127,7 @@ async def upsert_main_agent_record(self, gateway: Gateway) -> tuple[Agent, bool]
openclaw_session_id=session_key,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile=identity_profile,
approval_policy=DEFAULT_APPROVAL_POLICY.copy(),
)
self.session.add(agent)
changed = True
Expand Down Expand Up @@ -154,6 +155,9 @@ async def upsert_main_agent_record(self, gateway: Gateway) -> tuple[Agent, bool]
if not agent.status:
agent.status = "provisioning"
changed = True
if agent.approval_policy is None:
agent.approval_policy = DEFAULT_APPROVAL_POLICY.copy()
changed = True
if changed:
agent.updated_at = utcnow()
self.session.add(agent)
Expand Down
76 changes: 76 additions & 0 deletions backend/app/services/openclaw/approval_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Approval policy execution logic for automatic or manual approval handling."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from app.core.logging import get_logger
from app.models.agents import APPROVAL_POLICY_MODE_IMMEDIATE, get_approval_policy
from app.services.openclaw.gateway_resolver import gateway_client_config
from app.services.openclaw.gateway_rpc import resolve_approval

if TYPE_CHECKING:
from app.models.agents import Agent
from app.models.gateways import Gateway

logger = get_logger(__name__)


async def apply_approval_policy(
agent: Agent,
gateway: Gateway,
approval_request: dict[str, Any],
) -> bool:
"""Apply the agent's approval policy to an approval request.

Args:
agent: The agent whose policy to apply.
gateway: The gateway to use for RPC calls.
approval_request: The approval request event payload from the gateway.

Returns:
True if auto-approved (immediate policy), False if requires manual review.
"""
policy = get_approval_policy(agent)
logger.info(
"gateway.listener.apply_policy agent_id=%s policy=%s",
agent.id,
policy,
)
if policy.get("mode") == APPROVAL_POLICY_MODE_IMMEDIATE:
await _auto_resolve_approval(agent, gateway, approval_request)
return True
return False


async def _auto_resolve_approval(
agent: Agent,
gateway: Gateway,
approval_request: dict[str, Any],
) -> None:
"""Automatically approve an approval request by calling exec.approval.resolve."""
config = gateway_client_config(gateway)
approval_id = approval_request.get("id")
if not approval_id:
logger.warning(
"gateway.listener.auto_resolve.no_approval_id agent_id=%s",
agent.id,
)
return
try:
await resolve_approval(
approval_id=str(approval_id),
approved=True,
config=config,
)
logger.info(
"gateway.listener.auto_resolve.success agent_id=%s approval_id=%s",
agent.id,
approval_id,
)
except Exception:
logger.exception(
"gateway.listener.auto_resolve.failed agent_id=%s approval_id=%s",
agent.id,
approval_id,
)
Loading
Loading