From 275cc6f473c1c3aeffa59cbf42e463431017ce06 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 01:47:24 +0530 Subject: [PATCH] refactor: centralize gateway resolution logic with new helper functions --- backend/app/api/board_onboarding.py | 15 ++- backend/app/api/boards.py | 22 +---- .../services/openclaw/coordination_service.py | 4 +- .../app/services/openclaw/gateway_dispatch.py | 30 ++---- .../app/services/openclaw/gateway_resolver.py | 96 +++++++++++++++++++ .../app/services/openclaw/provisioning_db.py | 51 +++------- .../app/services/openclaw/session_service.py | 29 +++--- 7 files changed, 147 insertions(+), 100 deletions(-) create mode 100644 backend/app/services/openclaw/gateway_resolver.py diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py index 9de6f0e8..e7ec8ded 100644 --- a/backend/app/api/board_onboarding.py +++ b/backend/app/api/board_onboarding.py @@ -21,7 +21,6 @@ from app.core.config import settings from app.core.time import utcnow from app.db.session import get_session from app.models.board_onboarding import BoardOnboardingSession -from app.models.gateways import Gateway from app.schemas.board_onboarding import ( BoardOnboardingAgentComplete, BoardOnboardingAgentUpdate, @@ -33,6 +32,7 @@ from app.schemas.board_onboarding import ( BoardOnboardingUserProfile, ) from app.schemas.boards import BoardRead +from app.services.openclaw.gateway_resolver import get_gateway_for_board from app.services.openclaw.gateway_dispatch import GatewayDispatchService from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService from app.services.openclaw.policies import OpenClawAuthorizationPolicy @@ -310,13 +310,12 @@ async def agent_onboarding_update( agent = actor.agent OpenClawAuthorizationPolicy.require_gateway_scoped_actor(actor_agent=agent) - if board.gateway_id: - gateway = await Gateway.objects.by_id(board.gateway_id).first(session) - if gateway: - OpenClawAuthorizationPolicy.require_gateway_main_actor_binding( - actor_agent=agent, - gateway=gateway, - ) + gateway = await get_gateway_for_board(session, board) + if gateway is not None: + OpenClawAuthorizationPolicy.require_gateway_main_actor_binding( + actor_agent=agent, + gateway=gateway, + ) onboarding = ( await BoardOnboardingSession.objects.filter_by(board_id=board.id) diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index acfe7454..e202b5df 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -39,6 +39,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot from app.services.board_group_snapshot import build_board_group_snapshot from app.services.board_snapshot import build_board_snapshot +from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board from app.services.openclaw.gateway_rpc import OpenClawGatewayError from app.services.openclaw.provisioning import OpenClawGatewayProvisioner from app.services.organizations import OrganizationContext, board_access_filter @@ -173,23 +174,10 @@ async def _board_gateway( ) -> Gateway | None: if not board.gateway_id: return None - config = await Gateway.objects.by_id(board.gateway_id).first(session) - if config is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is invalid", - ) - if not config.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway url is required", - ) - if not config.workspace_root: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway workspace_root is required", - ) - return config + gateway = await require_gateway_for_board(session, board, require_workspace_root=True) + # Validate the connection config; the caller needs a configured gateway URL. + gateway_client_config(gateway) + return gateway @router.get("", response_model=DefaultLimitOffsetPage[BoardRead]) diff --git a/backend/app/services/openclaw/coordination_service.py b/backend/app/services/openclaw/coordination_service.py index d4be349d..18e59eb2 100644 --- a/backend/app/services/openclaw/coordination_service.py +++ b/backend/app/services/openclaw/coordination_service.py @@ -33,6 +33,7 @@ from app.services.openclaw.exceptions import ( map_gateway_error_to_http_exception, ) from app.services.openclaw.gateway_dispatch import GatewayDispatchService +from app.services.openclaw.gateway_resolver import gateway_client_config from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call from app.services.openclaw.internal.agent_key import agent_key @@ -119,8 +120,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService): actor_agent=actor_agent, gateway=gateway, ) - OpenClawAuthorizationPolicy.require_gateway_configured(gateway) - return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) + return gateway, gateway_client_config(gateway) async def require_gateway_board( self, diff --git a/backend/app/services/openclaw/gateway_dispatch.py b/backend/app/services/openclaw/gateway_dispatch.py index 6fad5e69..17d24bd2 100644 --- a/backend/app/services/openclaw/gateway_dispatch.py +++ b/backend/app/services/openclaw/gateway_dispatch.py @@ -8,11 +8,15 @@ from __future__ import annotations from uuid import uuid4 -from fastapi import HTTPException, status - from app.models.boards import Board from app.models.gateways import Gateway from app.services.openclaw.db_service import OpenClawDBService +from app.services.openclaw.gateway_resolver import ( + get_gateway_for_board, + gateway_client_config, + optional_gateway_client_config, + require_gateway_for_board, +) from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import OpenClawGatewayError, ensure_session, send_message @@ -24,29 +28,15 @@ class GatewayDispatchService(OpenClawDBService): self, board: Board, ) -> GatewayClientConfig | None: - if board.gateway_id is None: - return None - gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) - if gateway is None or not gateway.url: - return None - return GatewayClientConfig(url=gateway.url, token=gateway.token) + gateway = await get_gateway_for_board(self.session, board) + return optional_gateway_client_config(gateway) async def require_gateway_config_for_board( self, board: Board, ) -> tuple[Gateway, GatewayClientConfig]: - if board.gateway_id is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board is not attached to a gateway", - ) - gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) - if gateway is None or not gateway.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway is not configured for this board", - ) - return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) + gateway = await require_gateway_for_board(self.session, board) + return gateway, gateway_client_config(gateway) async def send_agent_message( self, diff --git a/backend/app/services/openclaw/gateway_resolver.py b/backend/app/services/openclaw/gateway_resolver.py new file mode 100644 index 00000000..4f6db691 --- /dev/null +++ b/backend/app/services/openclaw/gateway_resolver.py @@ -0,0 +1,96 @@ +"""DB-backed gateway resolution helpers. + +This module is the narrow boundary between Mission Control's DB models and the +DB-free OpenClaw gateway client/provisioning layers. + +Goals: +- Centralize "board -> gateway row" resolution and defensive org checks. +- Centralize construction of `GatewayConfig` objects used by gateway RPC calls. +- Keep call-sites thin and avoid re-implementing the same validation rules. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import HTTPException, status + +from app.models.boards import Board +from app.models.gateways import Gateway +from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig + +if TYPE_CHECKING: + from sqlmodel.ext.asyncio.session import AsyncSession + + +def gateway_client_config(gateway: Gateway) -> GatewayClientConfig: + """Build a gateway RPC config from a Gateway model, requiring a URL.""" + url = (gateway.url or "").strip() + if not url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway url is required", + ) + token = (gateway.token or "").strip() or None + return GatewayClientConfig(url=url, token=token) + + +def optional_gateway_client_config(gateway: Gateway | None) -> GatewayClientConfig | None: + """Build a gateway RPC config when the gateway is configured; otherwise return None.""" + if gateway is None: + return None + url = (gateway.url or "").strip() + if not url: + return None + token = (gateway.token or "").strip() or None + return GatewayClientConfig(url=url, token=token) + + +def require_gateway_workspace_root(gateway: Gateway) -> str: + """Return a gateway workspace_root string, requiring it to be configured.""" + workspace_root = (gateway.workspace_root or "").strip() + if not workspace_root: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Gateway workspace_root is required", + ) + return workspace_root + + +async def get_gateway_for_board( + session: AsyncSession, + board: Board, +) -> Gateway | None: + """Return the gateway for a board when present and valid; otherwise return None.""" + if board.gateway_id is None: + return None + gateway = await Gateway.objects.by_id(board.gateway_id).first(session) + if gateway is None: + return None + # Defensive guard: boards and gateways are tenant-scoped; reject cross-org mismatches. + if gateway.organization_id != board.organization_id: + return None + return gateway + + +async def require_gateway_for_board( + session: AsyncSession, + board: Board, + *, + require_workspace_root: bool = False, +) -> Gateway: + """Return a board's gateway or raise a 422 with a stable error message.""" + if board.gateway_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is required", + ) + gateway = await get_gateway_for_board(session, board) + if gateway is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Board gateway_id is invalid", + ) + if require_workspace_root: + require_gateway_workspace_root(gateway) + return gateway diff --git a/backend/app/services/openclaw/provisioning_db.py b/backend/app/services/openclaw/provisioning_db.py index 4d48172e..8d00a8e0 100644 --- a/backend/app/services/openclaw/provisioning_db.py +++ b/backend/app/services/openclaw/provisioning_db.py @@ -55,6 +55,11 @@ from app.services.openclaw.db_agent_state import ( mint_agent_token, ) from app.services.openclaw.db_service import OpenClawDBService +from app.services.openclaw.gateway_resolver import ( + gateway_client_config, + optional_gateway_client_config, + require_gateway_for_board, +) from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import ( OpenClawGatewayError, @@ -766,42 +771,12 @@ class AgentLifecycleService(OpenClawDBService): self, board: Board, ) -> tuple[Gateway, GatewayClientConfig]: - if not board.gateway_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is required", - ) - gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) - if gateway is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is invalid", - ) - if gateway.organization_id != board.organization_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is invalid", - ) - if not gateway.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway url is required", - ) - if not gateway.workspace_root: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway workspace_root is required", - ) - return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) - - @staticmethod - def gateway_client_config(gateway: Gateway) -> GatewayClientConfig: - if not gateway.url: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Gateway url is required", - ) - return GatewayClientConfig(url=gateway.url, token=gateway.token) + gateway = await require_gateway_for_board( + self.session, + board, + require_workspace_root=True, + ) + return gateway, gateway_client_config(gateway) @staticmethod def is_gateway_main(agent: Agent) -> bool: @@ -1679,8 +1654,8 @@ class AgentLifecycleService(OpenClawDBService): if agent.board_id is None: # Gateway-main agents are not tied to a board; resolve via agent.gateway_id. gateway = await Gateway.objects.by_id(agent.gateway_id).first(self.session) - if gateway and gateway.url: - client_config = GatewayClientConfig(url=gateway.url, token=gateway.token) + client_config = optional_gateway_client_config(gateway) + if gateway is not None and client_config is not None: try: workspace_path = await OpenClawGatewayProvisioner().delete_agent_lifecycle( agent=agent, diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py index f8aad4a5..bf2ca18f 100644 --- a/backend/app/services/openclaw/session_service.py +++ b/backend/app/services/openclaw/session_service.py @@ -12,7 +12,6 @@ from sqlmodel import col from app.models.agents import Agent from app.models.boards import Board -from app.models.gateways import Gateway from app.schemas.gateway_api import ( GatewayResolveQuery, GatewaySessionHistoryResponse, @@ -22,6 +21,7 @@ from app.schemas.gateway_api import ( GatewaysStatusResponse, ) from app.services.openclaw.db_service import OpenClawDBService +from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import ( OpenClawGatewayError, @@ -95,9 +95,18 @@ class GatewaySessionService(OpenClawDBService): params.gateway_url, ) if params.gateway_url: + raw_url = params.gateway_url.strip() + if not raw_url: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="board_id or gateway_url is required", + ) return ( None, - GatewayClientConfig(url=params.gateway_url, token=params.gateway_token), + GatewayClientConfig( + url=raw_url, + token=(params.gateway_token or "").strip() or None, + ), None, ) if not params.board_id: @@ -113,18 +122,8 @@ class GatewaySessionService(OpenClawDBService): ) if user is not None: await require_board_access(self.session, user=user, board=board, write=False) - if not board.gateway_id: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is required", - ) - gateway = await Gateway.objects.by_id(board.gateway_id).first(self.session) - if gateway is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="Board gateway_id is invalid", - ) - OpenClawAuthorizationPolicy.require_gateway_configured(gateway) + gateway = await require_gateway_for_board(self.session, board) + config = gateway_client_config(gateway) main_agent = ( await Agent.objects.filter_by(gateway_id=gateway.id) .filter(col(Agent.board_id).is_(None)) @@ -133,7 +132,7 @@ class GatewaySessionService(OpenClawDBService): main_session = main_agent.openclaw_session_id if main_agent else None return ( board, - GatewayClientConfig(url=gateway.url, token=gateway.token), + config, main_session, )