feat(config): make BASE_URL a required field and update related documentation
This commit is contained in:
@@ -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://<server-ip>: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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user