From bd1e599ae5ebaad8cef23e2a69affe37dd9ee1cb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 12 Feb 2026 02:54:56 +0530 Subject: [PATCH] feat: add endpoint to delete board agents and implement related service logic --- backend/app/api/agent.py | 17 ++ .../app/services/openclaw/provisioning_db.py | 33 +++- backend/tests/test_agent_delete_lead_agent.py | 175 ++++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_agent_delete_lead_agent.py diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index f9770e02..3f88f9ec 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -550,6 +550,23 @@ async def update_agent_soul( return OkResponse() +@router.delete("/boards/{board_id}/agents/{agent_id}", response_model=OkResponse) +async def delete_board_agent( + agent_id: str, + board: Board = BOARD_DEP, + session: AsyncSession = SESSION_DEP, + agent_ctx: AgentAuthContext = AGENT_CTX_DEP, +) -> OkResponse: + """Delete a board agent as the board lead.""" + _guard_board_access(agent_ctx, board) + _require_board_lead(agent_ctx) + service = AgentLifecycleService(session) + return await service.delete_agent_as_lead( + agent_id=agent_id, + actor_agent=agent_ctx.agent, + ) + + @router.post( "/boards/{board_id}/gateway/main/ask-user", response_model=GatewayMainAskUserResponse, diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index a7ab59ba..61b3bdd3 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -1658,7 +1658,38 @@ class AgentLifecycleService(OpenClawDBService): if agent is None: return OkResponse() await self.require_agent_access(agent=agent, ctx=ctx, write=True) + return await self._delete_agent_record(agent=agent) + async def delete_agent_as_lead( + self, + *, + agent_id: str, + actor_agent: Agent, + ) -> OkResponse: + """Delete a board-scoped agent as the board lead.""" + self.logger.log(TRACE_LEVEL, "agent.delete.lead.start agent_id=%s", agent_id) + lead = OpenClawAuthorizationPolicy.require_board_lead_actor( + actor_agent=actor_agent, + detail="Only board leads can delete agents", + ) + agent = await Agent.objects.by_id(agent_id).first(self.session) + if agent is None: + return OkResponse() + if agent.board_id is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board leads cannot delete gateway main agents", + ) + board = await self.require_board(lead.board_id) + OpenClawAuthorizationPolicy.require_board_agent_target(target=agent, board=board) + if agent.is_board_lead: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board leads cannot delete lead agents", + ) + return await self._delete_agent_record(agent=agent) + + async def _delete_agent_record(self, *, agent: Agent) -> OkResponse: gateway: Gateway | None = None client_config: GatewayClientConfig | None = None workspace_path: str | None = None @@ -1772,5 +1803,5 @@ class AgentLifecycleService(OpenClawDBService): ) except (OSError, OpenClawGatewayError, ValueError): pass - self.logger.info("agent.delete.success agent_id=%s", agent_id) + self.logger.info("agent.delete.success agent_id=%s", agent.id) return OkResponse() diff --git a/backend/tests/test_agent_delete_lead_agent.py b/backend/tests/test_agent_delete_lead_agent.py new file mode 100644 index 00000000..b1bcf3de --- /dev/null +++ b/backend/tests/test_agent_delete_lead_agent.py @@ -0,0 +1,175 @@ +# ruff: noqa: S101 +"""Unit tests for lead-agent delete behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import pytest +from fastapi import HTTPException, status + +import app.services.openclaw.provisioning_db as agent_service +from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig + + +@dataclass +class _FakeSession: + committed: int = 0 + deleted: list[object] = field(default_factory=list) + + def add(self, _value: object) -> None: + return None + + async def commit(self) -> None: + self.committed += 1 + + async def delete(self, value: object) -> None: + self.deleted.append(value) + + +@dataclass +class _AgentStub: + id: UUID + name: str + gateway_id: UUID + board_id: UUID | None = None + is_board_lead: bool = False + + +@dataclass +class _BoardStub: + id: UUID + gateway_id: UUID + + +@dataclass +class _GatewayStub: + id: UUID + url: str + token: str | None + workspace_root: str + + +@pytest.mark.asyncio +async def test_delete_agent_as_lead_removes_board_agent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = _FakeSession() + service = agent_service.AgentLifecycleService(session) # type: ignore[arg-type] + + gateway_id = uuid4() + board = _BoardStub(id=uuid4(), gateway_id=gateway_id) + lead = _AgentStub( + id=uuid4(), + name="Lead Agent", + gateway_id=gateway_id, + board_id=board.id, + is_board_lead=True, + ) + target = _AgentStub( + id=uuid4(), + name="Worker Agent", + gateway_id=gateway_id, + board_id=board.id, + is_board_lead=False, + ) + + async def _fake_first_agent(_session: object) -> _AgentStub: + return target + + monkeypatch.setattr( + agent_service.Agent, + "objects", + SimpleNamespace(by_id=lambda _id: SimpleNamespace(first=_fake_first_agent)), + ) + + async def _fake_require_board(_board_id: object, **_kwargs: object) -> _BoardStub: + return board + + async def _fake_require_gateway( + _board: object, + ) -> tuple[_GatewayStub, GatewayClientConfig]: + gateway = _GatewayStub( + id=gateway_id, + url="ws://gateway.example/ws", + token=None, + workspace_root="/tmp/openclaw", + ) + return gateway, GatewayClientConfig(url=gateway.url, token=None) + + async def _fake_delete_agent_lifecycle( + _self, + *, + agent: object, + gateway: object, + delete_files: bool = True, + delete_session: bool = True, + ) -> str | None: + _ = (_self, agent, gateway, delete_files, delete_session) + return None + + async def _fake_update_where(*_args, **_kwargs) -> None: + return None + + monkeypatch.setattr(service, "require_board", _fake_require_board) + monkeypatch.setattr(service, "require_gateway", _fake_require_gateway) + monkeypatch.setattr( + agent_service.OpenClawGatewayProvisioner, + "delete_agent_lifecycle", + _fake_delete_agent_lifecycle, + ) + monkeypatch.setattr(agent_service.crud, "update_where", _fake_update_where) + monkeypatch.setattr(agent_service, "record_activity", lambda *_a, **_k: None) + + result = await service.delete_agent_as_lead( + agent_id=str(target.id), + actor_agent=lead, # type: ignore[arg-type] + ) + + assert result.ok is True + assert session.deleted and session.deleted[0] == target + + +@pytest.mark.asyncio +async def test_delete_agent_as_lead_rejects_gateway_main( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = _FakeSession() + service = agent_service.AgentLifecycleService(session) # type: ignore[arg-type] + + gateway_id = uuid4() + board_id = uuid4() + lead = _AgentStub( + id=uuid4(), + name="Lead Agent", + gateway_id=gateway_id, + board_id=board_id, + is_board_lead=True, + ) + target = _AgentStub( + id=uuid4(), + name="Gateway Main", + gateway_id=gateway_id, + board_id=None, + is_board_lead=False, + ) + + async def _fake_first_agent(_session: object) -> _AgentStub: + return target + + monkeypatch.setattr( + agent_service.Agent, + "objects", + SimpleNamespace(by_id=lambda _id: SimpleNamespace(first=_fake_first_agent)), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.delete_agent_as_lead( + agent_id=str(target.id), + actor_agent=lead, # type: ignore[arg-type] + ) + + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + assert "gateway main" in str(exc_info.value.detail).lower()