diff --git a/connector/config.py b/connector/config.py index 6c50246..ef58a73 100644 --- a/connector/config.py +++ b/connector/config.py @@ -3,12 +3,155 @@ Connector Configuration (F29). Loads environment variables and validates allowlists. """ +import logging import os import sys from dataclasses import dataclass, field from enum import Enum from typing import Dict, List, Optional, Set +logger = logging.getLogger("ComfyUI-OpenClaw.connector.config") + +DEFAULT_DELIVERY_MAX_IMAGES = 4 +MIN_DELIVERY_MAX_IMAGES = 1 +MAX_DELIVERY_MAX_IMAGES = 16 + +DEFAULT_DELIVERY_MAX_BYTES = 10 * 1024 * 1024 +MIN_DELIVERY_MAX_BYTES = 64 * 1024 +MAX_DELIVERY_MAX_BYTES = 50 * 1024 * 1024 + +DEFAULT_DELIVERY_TIMEOUT_SEC = 600 +MIN_DELIVERY_TIMEOUT_SEC = 30 +MAX_DELIVERY_TIMEOUT_SEC = 3600 + +DEFAULT_LINE_BIND_PORT = 8099 +DEFAULT_WHATSAPP_BIND_PORT = 8098 +DEFAULT_WECHAT_BIND_PORT = 8097 +DEFAULT_KAKAO_BIND_PORT = 8096 +DEFAULT_SLACK_BIND_PORT = 8095 +DEFAULT_FEISHU_BIND_PORT = 8094 +MIN_BIND_PORT = 1 +MAX_BIND_PORT = 65535 + +DEFAULT_SLACK_OAUTH_STATE_TTL_SEC = 600 +MIN_SLACK_OAUTH_STATE_TTL_SEC = 60 +MAX_SLACK_OAUTH_STATE_TTL_SEC = 3600 + +DEFAULT_RATE_LIMIT_USER_RPM = 10 +DEFAULT_RATE_LIMIT_CHANNEL_RPM = 30 +MIN_RATE_LIMIT_RPM = 1 +MAX_RATE_LIMIT_RPM = 600 + +DEFAULT_MAX_COMMAND_LENGTH = 4096 +MIN_MAX_COMMAND_LENGTH = 128 +MAX_MAX_COMMAND_LENGTH = 32768 + +DEFAULT_MEDIA_TTL_SEC = 300 +MIN_MEDIA_TTL_SEC = 60 +MAX_MEDIA_TTL_SEC = 86400 + +DEFAULT_MEDIA_MAX_MB = 8 +MIN_MEDIA_MAX_MB = 1 +MAX_MEDIA_MAX_MB = 64 + + +def _warn_default_env( + env_key: str, raw_value: str, *, default: int, reason: str +) -> None: + logger.warning( + "Connector env %s=%r %s; using default %s.", + env_key, + raw_value, + reason, + default, + ) + + +def _warn_clamped_env( + env_key: str, + raw_value: str, + *, + bound_name: str, + bound_value: int, + resolved: int, +) -> None: + logger.warning( + ( + "Connector env %s=%r is below %s %s; clamped to %s." + if bound_name == "minimum" + else "Connector env %s=%r is above %s %s; clamped to %s." + ), + env_key, + raw_value, + bound_name, + bound_value, + resolved, + ) + + +def _load_bounded_int_env( + env_key: str, + *, + default: int, + minimum: Optional[int] = None, + maximum: Optional[int] = None, + clamp: bool = True, +) -> int: + raw_value = os.environ.get(env_key) + if raw_value is None: + return default + raw_value = raw_value.strip() + if not raw_value: + return default + try: + value = int(raw_value) + except (TypeError, ValueError): + _warn_default_env( + env_key, + raw_value, + default=default, + reason="is not a valid integer", + ) + return default + + if minimum is not None and value < minimum: + if clamp: + _warn_clamped_env( + env_key, + raw_value, + bound_name="minimum", + bound_value=minimum, + resolved=minimum, + ) + return minimum + _warn_default_env( + env_key, + raw_value, + default=default, + reason=f"is outside supported range {minimum}..{maximum or 'inf'}", + ) + return default + + if maximum is not None and value > maximum: + if clamp: + _warn_clamped_env( + env_key, + raw_value, + bound_name="maximum", + bound_value=maximum, + resolved=maximum, + ) + return maximum + _warn_default_env( + env_key, + raw_value, + default=default, + reason=f"is outside supported range {minimum or '-inf'}..{maximum}", + ) + return default + + return value + class CommandClass(str, Enum): PUBLIC = "public" # status, help, tools @@ -40,9 +183,9 @@ class ConnectorConfig: # Results Delivery delivery_enabled: bool = True - delivery_max_images: int = 4 - delivery_max_bytes: int = 10 * 1024 * 1024 # 10MB - delivery_timeout_sec: int = 600 + delivery_max_images: int = DEFAULT_DELIVERY_MAX_IMAGES + delivery_max_bytes: int = DEFAULT_DELIVERY_MAX_BYTES + delivery_timeout_sec: int = DEFAULT_DELIVERY_TIMEOUT_SEC # Telegram telegram_bot_token: Optional[str] = None @@ -60,7 +203,7 @@ class ConnectorConfig: line_allowed_users: List[str] = field(default_factory=list) line_allowed_groups: List[str] = field(default_factory=list) line_bind_host: str = "127.0.0.1" - line_bind_port: int = 8099 + line_bind_port: int = DEFAULT_LINE_BIND_PORT line_webhook_path: str = "/line/webhook" # WhatsApp @@ -70,7 +213,7 @@ class ConnectorConfig: whatsapp_phone_number_id: Optional[str] = None whatsapp_allowed_users: List[str] = field(default_factory=list) whatsapp_bind_host: str = "127.0.0.1" - whatsapp_bind_port: int = 8098 + whatsapp_bind_port: int = DEFAULT_WHATSAPP_BIND_PORT whatsapp_webhook_path: str = "/whatsapp/webhook" # WeChat Official Account (R74/S31/F43) @@ -80,13 +223,13 @@ class ConnectorConfig: wechat_encoding_aes_key: Optional[str] = None # R82: AES encrypted mode wechat_allowed_users: List[str] = field(default_factory=list) wechat_bind_host: str = "127.0.0.1" - wechat_bind_port: int = 8097 + wechat_bind_port: int = DEFAULT_WECHAT_BIND_PORT wechat_webhook_path: str = "/wechat/webhook" # KakaoTalk (F44 Phase A) kakao_enabled: bool = False kakao_bind_host: str = "127.0.0.1" - kakao_bind_port: int = 8096 + kakao_bind_port: int = DEFAULT_KAKAO_BIND_PORT kakao_webhook_path: str = "/kakao/webhook" kakao_allowed_users: List[str] = field(default_factory=list) @@ -96,7 +239,7 @@ class ConnectorConfig: slack_allowed_users: List[str] = field(default_factory=list) slack_allowed_channels: List[str] = field(default_factory=list) slack_bind_host: str = "127.0.0.1" - slack_bind_port: int = 8095 + slack_bind_port: int = DEFAULT_SLACK_BIND_PORT slack_webhook_path: str = "/slack/events" slack_require_mention: bool = True slack_reply_in_thread: bool = True @@ -118,7 +261,7 @@ class ConnectorConfig: "mpim:history", ] ) - slack_oauth_state_ttl_sec: int = 600 + slack_oauth_state_ttl_sec: int = DEFAULT_SLACK_OAUTH_STATE_TTL_SEC # Feishu / Lark (F67) feishu_app_id: Optional[str] = None @@ -133,7 +276,7 @@ class ConnectorConfig: feishu_allowed_users: List[str] = field(default_factory=list) feishu_allowed_chats: List[str] = field(default_factory=list) feishu_bind_host: str = "127.0.0.1" - feishu_bind_port: int = 8094 + feishu_bind_port: int = DEFAULT_FEISHU_BIND_PORT feishu_webhook_path: str = "/feishu/events" feishu_callback_path: str = "/feishu/callback" feishu_domain: str = "feishu" # feishu | lark @@ -147,13 +290,19 @@ class ConnectorConfig: # Media Host (F33) public_base_url: Optional[str] = None media_path: str = "/media" - media_ttl_sec: int = 300 - media_max_mb: int = 8 + media_ttl_sec: int = DEFAULT_MEDIA_TTL_SEC + media_max_mb: int = DEFAULT_MEDIA_MAX_MB # Security (F32) - rate_limit_user_rpm: int = 10 # Requests per minute per user - rate_limit_channel_rpm: int = 30 # Requests per minute per channel - max_command_length: int = 4096 # Max characters in a single command + rate_limit_user_rpm: int = ( + DEFAULT_RATE_LIMIT_USER_RPM # Requests per minute per user + ) + rate_limit_channel_rpm: int = ( + DEFAULT_RATE_LIMIT_CHANNEL_RPM # Requests per minute per channel + ) + max_command_length: int = ( + DEFAULT_MAX_COMMAND_LENGTH # Max characters in a single command + ) llm_max_tokens_per_request: int = 1024 # LLM token budget # R80: Command Auth Policy @@ -186,14 +335,23 @@ def load_config() -> ConnectorConfig: cfg.state_path = os.environ.get("OPENCLAW_CONNECTOR_STATE_PATH") # Delivery - cfg.delivery_max_images = int( - os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", "4") + cfg.delivery_max_images = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", + default=DEFAULT_DELIVERY_MAX_IMAGES, + minimum=MIN_DELIVERY_MAX_IMAGES, + maximum=MAX_DELIVERY_MAX_IMAGES, ) - cfg.delivery_max_bytes = int( - os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", str(10 * 1024 * 1024)) + cfg.delivery_max_bytes = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", + default=DEFAULT_DELIVERY_MAX_BYTES, + minimum=MIN_DELIVERY_MAX_BYTES, + maximum=MAX_DELIVERY_MAX_BYTES, ) - cfg.delivery_timeout_sec = int( - os.environ.get("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", "600") + cfg.delivery_timeout_sec = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", + default=DEFAULT_DELIVERY_TIMEOUT_SEC, + minimum=MIN_DELIVERY_TIMEOUT_SEC, + maximum=MAX_DELIVERY_TIMEOUT_SEC, ) # Telegram @@ -229,9 +387,13 @@ def load_config() -> ConnectorConfig: cfg.line_allowed_groups = [u.strip() for u in l_groups.split(",") if u.strip()] cfg.line_bind_host = os.environ.get("OPENCLAW_CONNECTOR_LINE_BIND", "127.0.0.1") - if l_port := os.environ.get("OPENCLAW_CONNECTOR_LINE_PORT"): - if l_port.isdigit(): - cfg.line_bind_port = int(l_port) + cfg.line_bind_port = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_LINE_PORT", + default=DEFAULT_LINE_BIND_PORT, + minimum=MIN_BIND_PORT, + maximum=MAX_BIND_PORT, + clamp=False, + ) cfg.line_webhook_path = os.environ.get( "OPENCLAW_CONNECTOR_LINE_PATH", "/line/webhook" ) @@ -254,9 +416,13 @@ def load_config() -> ConnectorConfig: cfg.whatsapp_bind_host = os.environ.get( "OPENCLAW_CONNECTOR_WHATSAPP_BIND", "127.0.0.1" ) - if wa_port := os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_PORT"): - if wa_port.isdigit(): - cfg.whatsapp_bind_port = int(wa_port) + cfg.whatsapp_bind_port = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_WHATSAPP_PORT", + default=DEFAULT_WHATSAPP_BIND_PORT, + minimum=MIN_BIND_PORT, + maximum=MAX_BIND_PORT, + clamp=False, + ) cfg.whatsapp_webhook_path = os.environ.get( "OPENCLAW_CONNECTOR_WHATSAPP_PATH", "/whatsapp/webhook" ) @@ -271,9 +437,13 @@ def load_config() -> ConnectorConfig: if wc_users := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS"): cfg.wechat_allowed_users = [u.strip() for u in wc_users.split(",") if u.strip()] cfg.wechat_bind_host = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_BIND", "127.0.0.1") - if wc_port := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_PORT"): - if wc_port.isdigit(): - cfg.wechat_bind_port = int(wc_port) + cfg.wechat_bind_port = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_WECHAT_PORT", + default=DEFAULT_WECHAT_BIND_PORT, + minimum=MIN_BIND_PORT, + maximum=MAX_BIND_PORT, + clamp=False, + ) cfg.wechat_webhook_path = os.environ.get( "OPENCLAW_CONNECTOR_WECHAT_PATH", "/wechat/webhook" ) @@ -283,9 +453,13 @@ def load_config() -> ConnectorConfig: cfg.kakao_enabled = True cfg.kakao_bind_host = os.environ.get("OPENCLAW_CONNECTOR_KAKAO_BIND", "127.0.0.1") - if kp := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_PORT"): - if kp.isdigit(): - cfg.kakao_bind_port = int(kp) + cfg.kakao_bind_port = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_KAKAO_PORT", + default=DEFAULT_KAKAO_BIND_PORT, + minimum=MIN_BIND_PORT, + maximum=MAX_BIND_PORT, + clamp=False, + ) cfg.kakao_webhook_path = os.environ.get( "OPENCLAW_CONNECTOR_KAKAO_PATH", "/kakao/webhook" ) @@ -300,9 +474,13 @@ def load_config() -> ConnectorConfig: if sc := os.environ.get("OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS"): cfg.slack_allowed_channels = [u.strip() for u in sc.split(",") if u.strip()] cfg.slack_bind_host = os.environ.get("OPENCLAW_CONNECTOR_SLACK_BIND", "127.0.0.1") - if sp := os.environ.get("OPENCLAW_CONNECTOR_SLACK_PORT"): - if sp.isdigit(): - cfg.slack_bind_port = int(sp) + cfg.slack_bind_port = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_SLACK_PORT", + default=DEFAULT_SLACK_BIND_PORT, + minimum=MIN_BIND_PORT, + maximum=MAX_BIND_PORT, + clamp=False, + ) cfg.slack_webhook_path = os.environ.get( "OPENCLAW_CONNECTOR_SLACK_PATH", "/slack/events" ) @@ -335,11 +513,12 @@ def load_config() -> ConnectorConfig: ] if parsed_scopes: cfg.slack_oauth_scopes = parsed_scopes - if slack_oauth_ttl := os.environ.get( - "OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC" - ): - if slack_oauth_ttl.isdigit(): - cfg.slack_oauth_state_ttl_sec = max(60, int(slack_oauth_ttl)) + cfg.slack_oauth_state_ttl_sec = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC", + default=DEFAULT_SLACK_OAUTH_STATE_TTL_SEC, + minimum=MIN_SLACK_OAUTH_STATE_TTL_SEC, + maximum=MAX_SLACK_OAUTH_STATE_TTL_SEC, + ) # Feishu / Lark (F67) cfg.feishu_app_id = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_APP_ID") @@ -362,9 +541,13 @@ def load_config() -> ConnectorConfig: if fc := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_ALLOWED_CHATS"): cfg.feishu_allowed_chats = [u.strip() for u in fc.split(",") if u.strip()] cfg.feishu_bind_host = os.environ.get("OPENCLAW_CONNECTOR_FEISHU_BIND", "127.0.0.1") - if fp := os.environ.get("OPENCLAW_CONNECTOR_FEISHU_PORT"): - if fp.isdigit(): - cfg.feishu_bind_port = int(fp) + cfg.feishu_bind_port = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_FEISHU_PORT", + default=DEFAULT_FEISHU_BIND_PORT, + minimum=MIN_BIND_PORT, + maximum=MAX_BIND_PORT, + clamp=False, + ) cfg.feishu_webhook_path = os.environ.get( "OPENCLAW_CONNECTOR_FEISHU_PATH", "/feishu/events" ) @@ -393,25 +576,40 @@ def load_config() -> ConnectorConfig: cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()] # Security (F32) - if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM"): - if rpm.isdigit(): - cfg.rate_limit_user_rpm = int(rpm) - if rpm := os.environ.get("OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM"): - if rpm.isdigit(): - cfg.rate_limit_channel_rpm = int(rpm) - if max_len := os.environ.get("OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH"): - if max_len.isdigit(): - cfg.max_command_length = int(max_len) + cfg.rate_limit_user_rpm = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM", + default=DEFAULT_RATE_LIMIT_USER_RPM, + minimum=MIN_RATE_LIMIT_RPM, + maximum=MAX_RATE_LIMIT_RPM, + ) + cfg.rate_limit_channel_rpm = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM", + default=DEFAULT_RATE_LIMIT_CHANNEL_RPM, + minimum=MIN_RATE_LIMIT_RPM, + maximum=MAX_RATE_LIMIT_RPM, + ) + cfg.max_command_length = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH", + default=DEFAULT_MAX_COMMAND_LENGTH, + minimum=MIN_MAX_COMMAND_LENGTH, + maximum=MAX_MAX_COMMAND_LENGTH, + ) # Media Host (F33) cfg.public_base_url = os.environ.get("OPENCLAW_CONNECTOR_PUBLIC_BASE_URL") cfg.media_path = os.environ.get("OPENCLAW_CONNECTOR_MEDIA_PATH", "/media") - if ttl := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_TTL_SEC"): - if ttl.isdigit(): - cfg.media_ttl_sec = int(ttl) - if mb := os.environ.get("OPENCLAW_CONNECTOR_MEDIA_MAX_MB"): - if mb.isdigit(): - cfg.media_max_mb = int(mb) + cfg.media_ttl_sec = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_MEDIA_TTL_SEC", + default=DEFAULT_MEDIA_TTL_SEC, + minimum=MIN_MEDIA_TTL_SEC, + maximum=MAX_MEDIA_TTL_SEC, + ) + cfg.media_max_mb = _load_bounded_int_env( + "OPENCLAW_CONNECTOR_MEDIA_MAX_MB", + default=DEFAULT_MEDIA_MAX_MB, + minimum=MIN_MEDIA_MAX_MB, + maximum=MAX_MEDIA_MAX_MB, + ) # R80: Command Auth Policy import json diff --git a/docs/connector.md b/docs/connector.md index 8c613a5..380b4fb 100644 --- a/docs/connector.md +++ b/docs/connector.md @@ -171,7 +171,7 @@ Set the following environment variables (or put them in a `.env` file if you use - `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH`: Local install route (default `/slack/install`). - `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH`: Local callback route (default `/slack/oauth/callback`). - `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES`: Comma-separated bot scopes used for install URL generation. -- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`). +- `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC`: TTL in seconds for single-use OAuth state tokens (default `600`, clamped to `60..3600`). - `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS`: Comma-separated user IDs (e.g. `U12345, U67890`). - `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS`: Comma-separated channel IDs (e.g. `C12345`). - `OPENCLAW_CONNECTOR_SLACK_BIND`: Host to bind (default `127.0.0.1`). @@ -206,10 +206,13 @@ Set the following environment variables (or put them in a `.env` file if you use **Image Delivery:** +- `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES`: Max completed images delivered per job (default `4`, clamped to `1..16`). +- `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES`: Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`). +- `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC`: Result delivery timeout in seconds (default `600`, clamped to `30..3600`). - `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL`: Public HTTPS URL of your connector (e.g. `https://your-tunnel.example.com`). Required for sending images. - `OPENCLAW_CONNECTOR_MEDIA_PATH`: URL path for serving temporary media (default `/media`). -- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`). -- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`). +- `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC`: Image expiry in seconds (default `300`, clamped to `60..86400`). +- `OPENCLAW_CONNECTOR_MEDIA_MAX_MB`: Max image size in MB (default `8`, clamped to `1..64`). > **Note:** Media URLs are signed with a secret derived from `OPENCLAW_CONNECTOR_ADMIN_TOKEN` or a random key. > To ensure URLs remain valid after connector restarts, **you must set `OPENCLAW_CONNECTOR_ADMIN_TOKEN`**. diff --git a/docs/release/config_secrets_contract.md b/docs/release/config_secrets_contract.md index 3bb8b64..5285d7e 100644 --- a/docs/release/config_secrets_contract.md +++ b/docs/release/config_secrets_contract.md @@ -140,7 +140,7 @@ Controls the `connector` sidecar process and outbound delivery. | `OPENCLAW_CONNECTOR_SLACK_OAUTH_INSTALL_PATH` | Slack | Local install route path (default `/slack/install`). | | `OPENCLAW_CONNECTOR_SLACK_OAUTH_CALLBACK_PATH` | Slack | Local OAuth callback route path (default `/slack/oauth/callback`). | | `OPENCLAW_CONNECTOR_SLACK_OAUTH_SCOPES` | Slack | Comma-separated bot scopes for install URL generation. | -| `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC` | Slack | TTL for single-use OAuth state tokens (default `600`). | +| `OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC` | Slack | TTL for single-use OAuth state tokens (default `600`, clamped to `60..3600`). | | `OPENCLAW_CONNECTOR_SLACK_ALLOWED_USERS` | Slack | Comma-separated trusted user IDs. | | `OPENCLAW_CONNECTOR_SLACK_ALLOWED_CHANNELS` | Slack | Comma-separated trusted channel IDs. | | `OPENCLAW_CONNECTOR_FEISHU_APP_ID` | Feishu/Lark | App ID for the default binding. | @@ -168,9 +168,13 @@ Connector posture rules: | Variable | Description | | :--- | :--- | -| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default: `600`). | +| `OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC` | Timeout (sec) for delivering results to chat (default `600`, clamped to `30..3600`). | | `OPENCLAW_CONNECTOR_PUBLIC_BASE_URL` | Public base URL for serving images to LINE/Webhooks. | | `OPENCLAW_CONNECTOR_MEDIA_PATH` | Local directory for staging media files. | +| `OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES` | Max completed images delivered per job (default `4`, clamped to `1..16`). | +| `OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES` | Per-image delivery cap in bytes (default `10485760`, clamped to `65536..52428800`). | +| `OPENCLAW_CONNECTOR_MEDIA_TTL_SEC` | Media expiry in seconds (default `300`, clamped to `60..86400`). | +| `OPENCLAW_CONNECTOR_MEDIA_MAX_MB` | Max staged media size in MB (default `8`, clamped to `1..64`). | ### 2.5 Execution Budgets & Limits diff --git a/tests/test_r176_import_safe_config.py b/tests/test_r176_import_safe_config.py index 8d9c9fb..9deff1a 100644 --- a/tests/test_r176_import_safe_config.py +++ b/tests/test_r176_import_safe_config.py @@ -14,7 +14,9 @@ ROOT = Path(__file__).resolve().parents[1] class TestR176ImportSafeConfig(unittest.TestCase): - def _run_python(self, script: str, *, env: dict[str, str]) -> subprocess.CompletedProcess: + def _run_python( + self, script: str, *, env: dict[str, str] + ) -> subprocess.CompletedProcess: return subprocess.run( [sys.executable, "-c", script], capture_output=True, diff --git a/tests/test_r177_connector_config_env.py b/tests/test_r177_connector_config_env.py new file mode 100644 index 0000000..36b0884 --- /dev/null +++ b/tests/test_r177_connector_config_env.py @@ -0,0 +1,147 @@ +import os +import unittest +from unittest.mock import patch + +from connector import config as connector_config + + +class TestR177ConnectorConfigEnv(unittest.TestCase): + def test_invalid_delivery_envs_fall_back_to_defaults_with_warnings(self): + with patch.dict( + os.environ, + { + "OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES": "NaN", + "OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES": "10mb", + "OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC": "forever", + }, + clear=False, + ): + with self.assertLogs( + "ComfyUI-OpenClaw.connector.config", level="WARNING" + ) as logs: + cfg = connector_config.load_config() + + self.assertEqual( + cfg.delivery_max_images, + connector_config.DEFAULT_DELIVERY_MAX_IMAGES, + ) + self.assertEqual( + cfg.delivery_max_bytes, + connector_config.DEFAULT_DELIVERY_MAX_BYTES, + ) + self.assertEqual( + cfg.delivery_timeout_sec, + connector_config.DEFAULT_DELIVERY_TIMEOUT_SEC, + ) + rendered = "\n".join(logs.output) + self.assertIn("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", rendered) + self.assertIn("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", rendered) + self.assertIn("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", rendered) + self.assertIn("using default", rendered) + + def test_delivery_bounds_are_clamped(self): + with patch.dict( + os.environ, + { + "OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES": "0", + "OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES": str( + connector_config.MAX_DELIVERY_MAX_BYTES + 1 + ), + "OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC": "-5", + }, + clear=False, + ): + with self.assertLogs( + "ComfyUI-OpenClaw.connector.config", level="WARNING" + ) as logs: + cfg = connector_config.load_config() + + self.assertEqual( + cfg.delivery_max_images, + connector_config.MIN_DELIVERY_MAX_IMAGES, + ) + self.assertEqual( + cfg.delivery_max_bytes, + connector_config.MAX_DELIVERY_MAX_BYTES, + ) + self.assertEqual( + cfg.delivery_timeout_sec, + connector_config.MIN_DELIVERY_TIMEOUT_SEC, + ) + rendered = "\n".join(logs.output) + self.assertIn("clamped", rendered) + self.assertIn("OPENCLAW_CONNECTOR_DELIVERY_MAX_IMAGES", rendered) + self.assertIn("OPENCLAW_CONNECTOR_DELIVERY_MAX_BYTES", rendered) + self.assertIn("OPENCLAW_CONNECTOR_DELIVERY_TIMEOUT_SEC", rendered) + + def test_invalid_ports_fall_back_to_defaults(self): + with patch.dict( + os.environ, + { + "OPENCLAW_CONNECTOR_LINE_PORT": "0", + "OPENCLAW_CONNECTOR_SLACK_PORT": "99999", + "OPENCLAW_CONNECTOR_FEISHU_PORT": "not-a-port", + }, + clear=False, + ): + with self.assertLogs( + "ComfyUI-OpenClaw.connector.config", level="WARNING" + ) as logs: + cfg = connector_config.load_config() + + self.assertEqual(cfg.line_bind_port, connector_config.DEFAULT_LINE_BIND_PORT) + self.assertEqual(cfg.slack_bind_port, connector_config.DEFAULT_SLACK_BIND_PORT) + self.assertEqual( + cfg.feishu_bind_port, + connector_config.DEFAULT_FEISHU_BIND_PORT, + ) + rendered = "\n".join(logs.output) + self.assertIn("OPENCLAW_CONNECTOR_LINE_PORT", rendered) + self.assertIn("OPENCLAW_CONNECTOR_SLACK_PORT", rendered) + self.assertIn("OPENCLAW_CONNECTOR_FEISHU_PORT", rendered) + self.assertIn("using default", rendered) + + def test_timeout_and_media_limits_are_bounded(self): + with patch.dict( + os.environ, + { + "OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC": "10", + "OPENCLAW_CONNECTOR_MEDIA_TTL_SEC": "999999", + "OPENCLAW_CONNECTOR_MEDIA_MAX_MB": "0", + "OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM": "-1", + "OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM": "10000", + "OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH": "64", + }, + clear=False, + ): + with self.assertLogs( + "ComfyUI-OpenClaw.connector.config", level="WARNING" + ) as logs: + cfg = connector_config.load_config() + + self.assertEqual( + cfg.slack_oauth_state_ttl_sec, + connector_config.MIN_SLACK_OAUTH_STATE_TTL_SEC, + ) + self.assertEqual(cfg.media_ttl_sec, connector_config.MAX_MEDIA_TTL_SEC) + self.assertEqual(cfg.media_max_mb, connector_config.MIN_MEDIA_MAX_MB) + self.assertEqual(cfg.rate_limit_user_rpm, connector_config.MIN_RATE_LIMIT_RPM) + self.assertEqual( + cfg.rate_limit_channel_rpm, + connector_config.MAX_RATE_LIMIT_RPM, + ) + self.assertEqual( + cfg.max_command_length, + connector_config.MIN_MAX_COMMAND_LENGTH, + ) + rendered = "\n".join(logs.output) + self.assertIn("OPENCLAW_CONNECTOR_SLACK_OAUTH_STATE_TTL_SEC", rendered) + self.assertIn("OPENCLAW_CONNECTOR_MEDIA_TTL_SEC", rendered) + self.assertIn("OPENCLAW_CONNECTOR_MEDIA_MAX_MB", rendered) + self.assertIn("OPENCLAW_CONNECTOR_RATE_LIMIT_USER_RPM", rendered) + self.assertIn("OPENCLAW_CONNECTOR_RATE_LIMIT_CHANNEL_RPM", rendered) + self.assertIn("OPENCLAW_CONNECTOR_MAX_COMMAND_LENGTH", rendered) + + +if __name__ == "__main__": + unittest.main()