diff --git a/backend/.env.example b/backend/.env.example index 12de11d2..aad21354 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,7 @@ REQUEST_LOG_INCLUDE_HEALTH=false DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control # For remote access, set this to your UI origin (e.g. http://:3000 or https://mc.example.com). CORS_ORIGINS=http://localhost:3000 +# REQUIRED for gateway provisioning/agent heartbeats. Must be reachable by gateway runtime. BASE_URL= # Security response headers (blank values disable each header). SECURITY_HEADER_X_CONTENT_TYPE_OPTIONS= diff --git a/backend/.env.test b/backend/.env.test index 70f9a7ee..2d22385a 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -13,7 +13,7 @@ REQUEST_LOG_INCLUDE_HEALTH=false # Local backend -> local Postgres (adjust host/port if needed) DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control_test CORS_ORIGINS=http://localhost:3000 -BASE_URL= +BASE_URL=http://localhost:8000 # Auth mode: local for test/dev AUTH_MODE=local diff --git a/backend/README.md b/backend/README.md index 03684367..6343cebd 100644 --- a/backend/README.md +++ b/backend/README.md @@ -57,7 +57,7 @@ A starter file exists at `backend/.env.example`. `postgresql+psycopg://postgres:postgres@localhost:5432/mission_control` - `CORS_ORIGINS` (comma-separated) - Example: `http://localhost:3000` -- `BASE_URL` (optional) +- `BASE_URL` (required for gateway provisioning/agent heartbeat templates; no fallback) ### Database lifecycle diff --git a/backend/app/api/board_group_memory.py b/backend/app/api/board_group_memory.py index d965c718..b9b1fd63 100644 --- a/backend/app/api/board_group_memory.py +++ b/backend/app/api/board_group_memory.py @@ -331,7 +331,7 @@ async def _notify_group_memory_targets( if len(snippet) > MAX_SNIPPET_LENGTH: snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..." - base_url = settings.base_url or "http://localhost:8000" + base_url = settings.base_url context = _NotifyGroupContext( session=session, diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py index bf234be3..d04ea3f5 100644 --- a/backend/app/api/board_memory.py +++ b/backend/app/api/board_memory.py @@ -191,7 +191,7 @@ async def _notify_chat_targets( snippet = memory.content.strip() if len(snippet) > MAX_SNIPPET_LENGTH: snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..." - base_url = settings.base_url or "http://localhost:8000" + base_url = settings.base_url for agent in targets.values(): if not agent.openclaw_session_id: continue diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py index e0ac1a7b..51be333e 100644 --- a/backend/app/api/board_onboarding.py +++ b/backend/app/api/board_onboarding.py @@ -229,7 +229,7 @@ async def start_onboarding( return onboarding dispatcher = BoardOnboardingMessagingService(session) - base_url = settings.base_url or "http://localhost:8000" + base_url = settings.base_url prompt = ( "BOARD ONBOARDING REQUEST\n\n" f"Board Name: {board.name}\n" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 67822b57..46d20683 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -4,6 +4,7 @@ from __future__ import annotations from pathlib import Path from typing import Self +from urllib.parse import urlparse from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -48,7 +49,7 @@ class Settings(BaseSettings): clerk_leeway: float = 10.0 cors_origins: str = "" - base_url: str = "" + base_url: str # Security response headers (blank disables header injection) security_header_x_content_type_options: str = "" security_header_x_frame_options: str = "" @@ -93,6 +94,15 @@ class Settings(BaseSettings): raise ValueError( "LOCAL_AUTH_TOKEN must be at least 50 characters and non-placeholder when AUTH_MODE=local.", ) + base_url = self.base_url.strip() + if not base_url: + raise ValueError("BASE_URL must be set and non-empty.") + parsed_base_url = urlparse(base_url) + if parsed_base_url.scheme not in {"http", "https"} or not parsed_base_url.netloc: + raise ValueError( + "BASE_URL must be an absolute http(s) URL (e.g. http://localhost:8000).", + ) + self.base_url = base_url.rstrip("/") # In dev, default to applying Alembic migrations at startup to avoid # schema drift (e.g. missing newly-added columns). if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev": diff --git a/backend/app/services/openclaw/coordination_service.py b/backend/app/services/openclaw/coordination_service.py index 8b6d8365..6384ecd8 100644 --- a/backend/app/services/openclaw/coordination_service.py +++ b/backend/app/services/openclaw/coordination_service.py @@ -93,7 +93,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService): reply_tags: list[str] | None, reply_source: str | None, ) -> str: - base_url = settings.base_url or "http://localhost:8000" + base_url = settings.base_url header = "GATEWAY MAIN QUESTION" if kind == "question" else "GATEWAY MAIN HANDOFF" correlation = correlation_id.strip() if correlation_id else "" correlation_line = f"Correlation ID: {correlation}\n" if correlation else "" @@ -440,7 +440,7 @@ class GatewayCoordinationService(AbstractGatewayMessagingService): tags = payload.reply_tags or ["gateway_main", "user_reply"] tags_json = json.dumps(tags) reply_source = payload.reply_source or "user_via_gateway_main" - base_url = settings.base_url or "http://localhost:8000" + base_url = settings.base_url message = ( "LEAD REQUEST: ASK USER\n" f"Board: {board.name}\n" diff --git a/backend/app/services/openclaw/provisioning.py b/backend/app/services/openclaw/provisioning.py index 3a1d76f2..f49fecf6 100644 --- a/backend/app/services/openclaw/provisioning.py +++ b/backend/app/services/openclaw/provisioning.py @@ -370,7 +370,7 @@ def _build_context( workspace_root = gateway.workspace_root workspace_path = _workspace_path(agent, workspace_root) session_key = agent.openclaw_session_id or "" - base_url = settings.base_url or "REPLACE_WITH_BASE_URL" + base_url = settings.base_url main_session_key = GatewayAgentIdentity.session_key(gateway) identity_context = _identity_context(agent) user_context = _user_context(user) @@ -411,7 +411,7 @@ def _build_main_context( auth_token: str, user: User | None, ) -> dict[str, str]: - base_url = settings.base_url or "REPLACE_WITH_BASE_URL" + base_url = settings.base_url identity_context = _identity_context(agent) user_context = _user_context(user) return { diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index e522fc29..a9022efe 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -13,3 +13,4 @@ if str(ROOT) not in sys.path: # defaults during import-time settings initialization, regardless of shell env. os.environ["AUTH_MODE"] = "local" os.environ["LOCAL_AUTH_TOKEN"] = "test-local-token-0123456789-0123456789-0123456789x" +os.environ["BASE_URL"] = "http://localhost:8000" diff --git a/backend/tests/test_config_auth_mode.py b/backend/tests/test_config_auth_mode.py index 1f913302..6c6ac1fc 100644 --- a/backend/tests/test_config_auth_mode.py +++ b/backend/tests/test_config_auth_mode.py @@ -9,6 +9,8 @@ from pydantic import ValidationError from app.core.auth_mode import AuthMode from app.core.config import Settings +BASE_URL = "http://localhost:8000" + def test_local_mode_requires_non_empty_token() -> None: with pytest.raises( @@ -19,6 +21,7 @@ def test_local_mode_requires_non_empty_token() -> None: _env_file=None, auth_mode=AuthMode.LOCAL, local_auth_token="", + base_url=BASE_URL, ) @@ -31,6 +34,7 @@ def test_local_mode_requires_minimum_length() -> None: _env_file=None, auth_mode=AuthMode.LOCAL, local_auth_token="x" * 49, + base_url=BASE_URL, ) @@ -43,6 +47,7 @@ def test_local_mode_rejects_placeholder_token() -> None: _env_file=None, auth_mode=AuthMode.LOCAL, local_auth_token="change-me", + base_url=BASE_URL, ) @@ -52,6 +57,7 @@ def test_local_mode_accepts_real_token() -> None: _env_file=None, auth_mode=AuthMode.LOCAL, local_auth_token=token, + base_url=BASE_URL, ) assert settings.auth_mode == AuthMode.LOCAL @@ -67,4 +73,65 @@ def test_clerk_mode_requires_secret_key() -> None: _env_file=None, auth_mode=AuthMode.CLERK, clerk_secret_key="", + base_url=BASE_URL, ) + + +def test_base_url_required() -> None: + with pytest.raises( + ValidationError, + match="BASE_URL must be set and non-empty", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.CLERK, + clerk_secret_key="sk_test", + base_url=" ", + ) + + +def test_base_url_field_is_required(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BASE_URL", raising=False) + + with pytest.raises(ValidationError) as exc_info: + Settings( + _env_file=None, + auth_mode=AuthMode.CLERK, + clerk_secret_key="sk_test", + ) + + text = str(exc_info.value) + assert "base_url" in text + assert "Field required" in text + + +@pytest.mark.parametrize( + "base_url", + [ + "localhost:8000", + "ws://localhost:8000", + ], +) +def test_base_url_requires_absolute_http_url(base_url: str) -> None: + with pytest.raises( + ValidationError, + match="BASE_URL must be an absolute http\\(s\\) URL", + ): + Settings( + _env_file=None, + auth_mode=AuthMode.CLERK, + clerk_secret_key="sk_test", + base_url=base_url, + ) + + +def test_base_url_is_normalized_without_trailing_slash() -> None: + token = "a" * 50 + settings = Settings( + _env_file=None, + auth_mode=AuthMode.LOCAL, + local_auth_token=token, + base_url="http://localhost:8000/ ", + ) + + assert settings.base_url == BASE_URL