diff --git a/README.md b/README.md index cdb2761..df8493d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,17 @@ This project is intentionally **not** a general-purpose “assistant platform” ## Latest Updates - Click to expand +
+Runtime profile hardening and bridge startup compatibility checks + +- Added explicit runtime profiles with centralized resolution so startup behavior is deterministic across environments. +- Added a hardened startup security gate that fails closed when mandatory controls are not correctly configured. +- Added module capability boundaries so routes/workers only boot when their owning module is enabled. +- Added a bridge protocol handshake path with version compatibility checks during sidecar startup. +- Expanded regression coverage for profile resolution, startup gating, module boundaries, and bridge handshake behavior. + +
+
Connector platform parity and sidecar worker runtime improvements diff --git a/__init__.py b/__init__.py index 93da35a..58c1a05 100644 --- a/__init__.py +++ b/__init__.py @@ -57,6 +57,53 @@ def _register_routes_once(): except Exception as e: logging.getLogger("ComfyUI-OpenClaw").error(f"Failed to register plugins: {e}") + # R63/R84: Initialize Service & Module Registries + try: + from .services.modules import ModuleCapability, ModuleRegistry, enable_module + from .services.registry import SVC_RUNTIME_CONFIG, ServiceRegistry + from .services.runtime_config import get_config + + # 1. Register Runtime Config + config = get_config() + ServiceRegistry.register(SVC_RUNTIME_CONFIG, config) + + # 2. Initialize Default Modules + # Always enable CORE and SECURITY + enable_module(ModuleCapability.CORE) + enable_module(ModuleCapability.SECURITY) + enable_module(ModuleCapability.OBSERVABILITY) + + # 3. Conditional Modules (Config-driven) + # Bridge + if config.bridge_enabled: + enable_module(ModuleCapability.BRIDGE) + + # Scheduler (Always enabled as core feature, but runner starts conditionally) + enable_module(ModuleCapability.SCHEDULER) + + # Webhook (Always enabled as core feature, but auth-gated) + enable_module(ModuleCapability.WEBHOOK) + + # Connector (Always enabled as core feature) + enable_module(ModuleCapability.CONNECTOR) + + # Lock registry alignment + ModuleRegistry.lock() + logging.getLogger("ComfyUI-OpenClaw").info( + f"Initialized modules: {ModuleRegistry.get_enabled_list()}" + ) + + # R41/S41: Run Security Gate + # Must run after config and modules are loaded. + from .services.security_gate import enforce_startup_gate + + enforce_startup_gate() + + except Exception as e: + logging.getLogger("ComfyUI-OpenClaw").error( + f"Failed to initialize registries: {e}" + ) + def _do_full_registration(server): """Register all Moltbot routes including Bridge and Scheduler.""" from .api.approvals import register_approval_routes diff --git a/api/bridge.py b/api/bridge.py index fc48110..9f63e95 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -17,6 +17,10 @@ except ModuleNotFoundError: # pragma: no cover (optional for unit tests) try: from ..services.async_utils import run_in_thread + + # CRITICAL: handshake verifier must be imported in package mode; + # missing this causes NameError at runtime on /bridge/handshake. + from ..services.bridge_handshake import verify_handshake from ..services.cache import TTLCache from ..services.execution_budgets import BudgetExceededError from ..services.rate_limit import check_rate_limit @@ -33,6 +37,7 @@ try: except ImportError: # Fallback for ComfyUI's non-package loader or ad-hoc imports. from services.async_utils import run_in_thread + from services.bridge_handshake import verify_handshake from services.cache import TTLCache from services.execution_budgets import BudgetExceededError from services.rate_limit import check_rate_limit @@ -114,6 +119,30 @@ class BridgeHandlers: } ) + async def handshake_handler(self, request: web.Request) -> web.Response: + """ + POST /bridge/handshake + Negotiate protocol version compatibility. + """ + try: + data = await request.json() + client_version = int(data.get("version", 0)) + except (ValueError, TypeError, Exception): + return web.json_response({"error": "Invalid version format"}, status=400) + + ok, msg, meta = verify_handshake(client_version) + + status_code = 200 if ok else 409 # 409 Conflict for version mismatch + + return web.json_response( + { + "ok": ok, + "message": msg, + "metadata": meta, + }, + status=status_code, + ) + async def submit_handler(self, request: web.Request) -> web.Response: """ POST /bridge/submit @@ -413,6 +442,9 @@ def register_bridge_routes( app.router.add_get(BRIDGE_ENDPOINTS["health"]["path"], handlers.health_handler) app.router.add_post(BRIDGE_ENDPOINTS["submit"]["path"], handlers.submit_handler) app.router.add_post(BRIDGE_ENDPOINTS["deliver"]["path"], handlers.deliver_handler) + app.router.add_post( + BRIDGE_ENDPOINTS["handshake"]["path"], handlers.handshake_handler + ) # F46: Worker-facing endpoints app.router.add_get( diff --git a/api/routes.py b/api/routes.py index f70c5e4..a5aae8b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -556,6 +556,7 @@ def register_routes(server) -> None: register_dual_route(server, method, path, handler) # F8/F21 Assist Routes + # R84 Boot Boundary: CORE (Planner/Refiner part of core/assist) if assist: for prefix in prefixes: register_dual_route( @@ -566,18 +567,32 @@ def register_routes(server) -> None: ) # F10 Bridge Routes (Sidecar) + # R84 Boot Boundary: BRIDGE try: from ..api.bridge import register_bridge_routes + from ..services.modules import ModuleCapability, is_module_enabled - if hasattr(server, "app"): + if hasattr(server, "app") and is_module_enabled(ModuleCapability.BRIDGE): register_bridge_routes(server.app) - # Bridge handles its own routing, assuming it's robust. + print("[OpenClaw] Bridge routes registered") + elif not is_module_enabled(ModuleCapability.BRIDGE): + print("[OpenClaw] Bridge module disabled; skipping route registration") except ImportError: pass # S8/S23/F11 Asset Packs + # R84 Boot Boundary: REGISTRY_SYNC (Packs management) try: from ..api.packs import PacksHandlers + from ..services.modules import ModuleCapability, is_module_enabled + + # Packs are currently treated as part of CORE or REGISTRY_SYNC depending on strictness. + # For now, we bind them to REGISTRY_SYNC if we want to segment them, + # but realistically they are often core local features. + # Let's check REGISTRY_SYNC for import/export features specifically if we wanted to split, + # but keeping them enabled by default for now unless R84 explicitly segments them. + # DESIGN DECISION: Packs are local core features. Registry sync is remote. + # We will keep basic pack routes, but R84 might control remote interactions later. try: from ..config import DATA_DIR diff --git a/package-lock.json b/package-lock.json index 57f8643..c54dac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "name": "comfyui-openclaw", "devDependencies": { "@playwright/test": "^1.50.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@playwright/test": { diff --git a/pyproject.toml b/pyproject.toml index d721b5c..691039d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-openclaw" description = "Your own personal AIGC Factory. Any picture. Any reel. The Comfy way.©️" -version = "0.3.0" +version = "0.3.1" license = {text = "MIT"} readme = "README.md" requires-python = ">=3.10" diff --git a/services/access_control.py b/services/access_control.py index 0beed5e..74d9c43 100644 --- a/services/access_control.py +++ b/services/access_control.py @@ -134,3 +134,16 @@ def require_admin_token(request) -> Tuple[bool, Optional[str]]: False, "Remote admin access denied. Set OPENCLAW_ADMIN_TOKEN (or legacy MOLTBOT_ADMIN_TOKEN) to allow.", ) + + +def is_auth_configured() -> bool: + """ + Check if Admin Token authentication is configured (S41). + Returns True if OPENCLAW_ADMIN_TOKEN/MOLTBOT_ADMIN_TOKEN is non-empty. + """ + val = ( + os.environ.get("OPENCLAW_ADMIN_TOKEN") + or os.environ.get("MOLTBOT_ADMIN_TOKEN") + or "" + ) + return bool(val.strip()) diff --git a/services/bridge_handshake.py b/services/bridge_handshake.py new file mode 100644 index 0000000..8b53a24 --- /dev/null +++ b/services/bridge_handshake.py @@ -0,0 +1,49 @@ +""" +Bridge Handshake Service (R85). +Manages version negotiation and compatibility checks between Core and Sidecar/Worker. +Policy: N/N-1 Compatibility. +""" + +import logging +from typing import Dict, Tuple + +from services.sidecar.bridge_contract import BRIDGE_PROTOCOL_VERSION + +logger = logging.getLogger("ComfyUI-OpenClaw.services.bridge_handshake") + + +def verify_handshake(client_version: int) -> Tuple[bool, str, Dict[str, int]]: + """ + Verify client compatibility based on N/N-1 policy. + + Args: + client_version: The protocol version reported by the client (sidecar/worker). + + Returns: + (is_compatible, message, metadata) + """ + server_version = BRIDGE_PROTOCOL_VERSION + + # Policy: Support Current (N) and Previous (N-1) + min_supported = max(1, server_version - 1) + + metadata = { + "server_version": server_version, + "min_supported": min_supported, + "client_version": client_version, + } + + if client_version < min_supported: + msg = f"Client version {client_version} is too old. Minimum supported is {min_supported}." + logger.warning(f"Handshake rejected: {msg}") + return False, msg, metadata + + if client_version > server_version: + msg = f"Client version {client_version} is newer than server {server_version}. Please upgrade Core." + logger.warning(f"Handshake rejected: {msg}") + return False, msg, metadata + + logger.info( + f"Handshake accepted: Client {client_version} compatible with Server {server_version}" + ) + return True, "Compatible", metadata diff --git a/services/capabilities.py b/services/capabilities.py index 627504f..fe8bc52 100644 --- a/services/capabilities.py +++ b/services/capabilities.py @@ -10,6 +10,8 @@ if __package__ and "." in __package__: else: # pragma: no cover (test-only import mode) from config import PACK_NAME, PACK_VERSION +from .runtime_profile import get_runtime_profile + API_VERSION = 1 @@ -19,6 +21,7 @@ def get_capabilities() -> dict: """ return { "api_version": API_VERSION, + "runtime_profile": get_runtime_profile().value, "pack": { "name": PACK_NAME, "version": PACK_VERSION, diff --git a/services/modules.py b/services/modules.py new file mode 100644 index 0000000..476aaa1 --- /dev/null +++ b/services/modules.py @@ -0,0 +1,77 @@ +""" +R84 Module Capability Registry. + +Defines boot-time module capabilities and their enablement status. +Used to enforce conditional boot boundaries (e.g. disabling routes/workers). +""" + +import enum +import logging +from typing import Dict, Set + +logger = logging.getLogger(__name__) + + +class ModuleCapability(enum.Enum): + """ + Enumeration of functional modules. + """ + + CORE = "core" + CONNECTOR = "connector" # Remote control (Telegram/Discord/etc) + SECURITY = "security" # Auth, audit, gate + SCHEDULER = "scheduler" # Cron/Interval jobs + REGISTRY_SYNC = "registry" # Remote pack registry + BRIDGE = "bridge" # Sidecar bridge + WEBHOOK = "webhook" # Inbound webhooks + OBSERVABILITY = "observability" # Logs, traces, metrics + + +class ModuleRegistry: + """ + Tracks enabled/disabled status of modules. + """ + + _enabled_modules: Set[ModuleCapability] = set() + _locked = False + + @classmethod + def enable(cls, module: ModuleCapability) -> None: + """Enable a module capability.""" + if cls._locked: + logger.warning(f"ModuleRegistry is locked. Ignoring enable({module.value})") + return + cls._enabled_modules.add(module) + logger.info(f"Module enabled: {module.value}") + + @classmethod + def is_enabled(cls, module: ModuleCapability) -> bool: + """Check if a module is enabled.""" + return module in cls._enabled_modules + + @classmethod + def lock(cls) -> None: + """Lock the registry (prevent further changes). Call after startup.""" + cls._locked = True + logger.debug("ModuleRegistry locked.") + + @classmethod + def reset(cls) -> None: + """Reset for testing.""" + cls._enabled_modules.clear() + cls._locked = False + logger.info("ModuleRegistry reset.") + + @classmethod + def get_enabled_list(cls) -> list[str]: + """Return list of enabled module names.""" + return sorted([m.value for m in cls._enabled_modules]) + + +# Public accessors +def is_module_enabled(module: ModuleCapability) -> bool: + return ModuleRegistry.is_enabled(module) + + +def enable_module(module: ModuleCapability) -> None: + ModuleRegistry.enable(module) diff --git a/services/registry.py b/services/registry.py new file mode 100644 index 0000000..81aed1d --- /dev/null +++ b/services/registry.py @@ -0,0 +1,59 @@ +""" +R63 Service Registry Contract. + +Provides a centralized catalog of singleton services with a deterministic +reset hook for testing. This replaces ad-hoc global variable resets. +""" + +import logging +from typing import Any, Dict, Optional, Type, TypeVar + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class ServiceRegistry: + """ + Central registry for application singletons. + Supports registration, retrieval, and test resets. + """ + + _services: Dict[str, Any] = {} + _factories: Dict[str, Any] = {} + + @classmethod + def register(cls, name: str, instance: Any) -> None: + """Register a singleton instance.""" + if name in cls._services: + logger.warning(f"Overwriting service '{name}'") + cls._services[name] = instance + logger.debug(f"Registered service: {name}") + + @classmethod + def get(cls, name: str) -> Optional[Any]: + """Get a service by name.""" + return cls._services.get(name) + + @classmethod + def reset(cls) -> None: + """ + CLEAR ALL SERVICES. + For use in tests only. + """ + count = len(cls._services) + cls._services.clear() + logger.info(f"ServiceRegistry reset. Cleared {count} services.") + + @classmethod + def has(cls, name: str) -> bool: + """Check if a service is registered.""" + return name in cls._services + + +# Common service names +SVC_RUNTIME_CONFIG = "runtime_config" +SVC_LLM_CLIENT = "llm_client" +SVC_SECRET_STORE = "secret_store" +SVC_AUDIT_LOG = "audit_log" +SVC_BRIDGE = "bridge" diff --git a/services/runtime_config.py b/services/runtime_config.py index 6809b27..d1a5687 100644 --- a/services/runtime_config.py +++ b/services/runtime_config.py @@ -591,3 +591,36 @@ def get_admin_token() -> str: def is_loopback_client(remote_addr: str) -> bool: """Check if client is from loopback address.""" return remote_addr in ("127.0.0.1", "::1", "localhost") + + +class RuntimeConfig: + """ + Typed configuration snapshot. + Aggregates effective settings from Env and File. + """ + + def __init__(self): + # LLM Settings + self.llm, _ = get_effective_config() + + # Feature Flags + self.bridge_enabled = _env_flag( + "OPENCLAW_BRIDGE_ENABLED", "MOLTBOT_BRIDGE_ENABLED", False + ) + + # Security Flags (S41) + self.allow_any_public_llm_host = _env_flag( + "OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST", + "MOLTBOT_ALLOW_ANY_PUBLIC_LLM_HOST", + False, + ) + self.allow_insecure_base_url = _env_flag( + "OPENCLAW_ALLOW_INSECURE_BASE_URL", "MOLTBOT_ALLOW_INSECURE_BASE_URL", False + ) + self.webhook_auth_mode = os.environ.get("OPENCLAW_WEBHOOK_AUTH_MODE", "") + self.admin_token_configured = bool(get_admin_token()) + + +def get_config() -> RuntimeConfig: + """Factory to get current config snapshot.""" + return RuntimeConfig() diff --git a/services/runtime_profile.py b/services/runtime_profile.py new file mode 100644 index 0000000..540a435 --- /dev/null +++ b/services/runtime_profile.py @@ -0,0 +1,72 @@ +""" +R83 Runtime Profile Contract. + +Defines the authoritative source of truth for the application's runtime security profile. +This replaces ad-hoc environment variable checks with a single resolver. + +Profiles: +- MINIMAL (default): Greatest compatibility, least restrictive. +- HARDENED: Enforces all security controls (auth, egress, replay, redaction) fail-closed. +""" + +import enum +import logging +import os + +logger = logging.getLogger(__name__) + + +class RuntimeProfile(enum.Enum): + """ + Enumeration of supported runtime profiles. + """ + + MINIMAL = "minimal" + HARDENED = "hardened" + + +class ProfileResolver: + """ + Deterministic resolver for the active runtime profile. + """ + + ENV_VAR = "OPENCLAW_RUNTIME_PROFILE" + DEFAULT_PROFILE = RuntimeProfile.MINIMAL + + @classmethod + def resolve(cls) -> RuntimeProfile: + """ + Resolve the active profile from environment variables. + + Returns: + RuntimeProfile: The active profile. + """ + val = os.environ.get(cls.ENV_VAR, "").lower().strip() + + if val == "hardened": + return RuntimeProfile.HARDENED + + if val and val != "minimal": + logger.warning( + f"Unknown {cls.ENV_VAR}='{val}', falling back to {cls.DEFAULT_PROFILE.value}" + ) + + return cls.DEFAULT_PROFILE + + @classmethod + def is_hardened(cls) -> bool: + """ + Helper to check if the current profile is HARDENED. + """ + return cls.resolve() == RuntimeProfile.HARDENED + + +# Singleton accessor for convenience +def get_runtime_profile() -> RuntimeProfile: + """Get the current active runtime profile.""" + return ProfileResolver.resolve() + + +def is_hardened_mode() -> bool: + """Check if the application is running in HARDENED mode.""" + return ProfileResolver.is_hardened() diff --git a/services/security_gate.py b/services/security_gate.py new file mode 100644 index 0000000..b690f31 --- /dev/null +++ b/services/security_gate.py @@ -0,0 +1,120 @@ +""" +S41 Hardened Enforcement Gate. + +Enforces mandatory security controls when running in HARDENED profile. +Fails startup if critical controls are missing or misconfigured. +""" + +import logging +from typing import List, Tuple + +from .runtime_profile import get_runtime_profile, is_hardened_mode + +logger = logging.getLogger(__name__) + + +class SecurityGate: + """ + Startup gate that strictly enforces security controls. + """ + + @staticmethod + def verify_mandatory_controls() -> Tuple[bool, List[str]]: + """ + Check if all mandatory controls for the current profile are active. + Returns: (passed: bool, failure_reasons: List[str]) + """ + settings_issues = [] + + # 1. Access Control (Auth) + try: + from .access_control import is_auth_configured + + if not is_auth_configured(): + settings_issues.append( + "Authentication is NOT configured (Admin Token missing)" + ) + except ImportError: + settings_issues.append("Could not import access_control service") + + # 2. Egress Policy (SSRF) + from .runtime_config import get_config + + config = get_config() + + if config.allow_any_public_llm_host: + settings_issues.append( + "OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST is enabled (Egress check bypassed)" + ) + + if config.allow_insecure_base_url: + settings_issues.append( + "OPENCLAW_ALLOW_INSECURE_BASE_URL is enabled (SSRF check bypassed)" + ) + + # 3. Webhook Security (if Webhook module enabled) + from .modules import ModuleCapability, is_module_enabled + + if is_module_enabled(ModuleCapability.WEBHOOK): + # Check if webhook auth is configured (loose check via config, + # ideally check specific auth mode but config implies it) + if not config.webhook_auth_mode: + settings_issues.append( + "Webhook module enabled but OPENCLAW_WEBHOOK_AUTH_MODE not set" + ) + + # 4. Redaction + # (Redaction is always strictly imported in hardened mode; ensure it didn't fail) + try: + from .redaction import redact_text + + if not callable(redact_text): + settings_issues.append("Redaction service is not callable") + except ImportError: + settings_issues.append("Redaction service failed to import") + + failures = [] + + # In HARDENED mode, any issue is a failure. + if is_hardened_mode(): + if settings_issues: + failures.extend(settings_issues) + + return (len(failures) == 0), failures + + +def enforce_startup_gate() -> None: + """ + Run the security gate. + If in HARDENED mode and checks fail -> Raise SystemExit. + If in MINIMAL mode and checks fail -> Log warnings. + """ + is_hardened = is_hardened_mode() + mode_str = "HARDENED" if is_hardened else "MINIMAL" + + logger.info(f"Running S41 Security Gate ({mode_str} profile)...") + + passed, issues = SecurityGate.verify_mandatory_controls() + + if passed: + logger.info("Security Gate: PASS") + return + + # Handle failures + error_msg = f"Security Gate FAILED ({len(issues)} issues):\n" + "\n".join( + [f"- {i}" for i in issues] + ) + + if is_hardened: + logger.critical(error_msg) + logger.critical( + "FATAL: Hardened profile requires all controls to pass. Startup aborted." + ) + # S41 Fail-Closed + raise RuntimeError(error_msg) + else: + # Minimal mode: Warning only + logger.warning(error_msg) + logger.warning( + "Continuing startup in MINIMAL mode (Security warnings present)." + ) diff --git a/services/sidecar/bridge_client.py b/services/sidecar/bridge_client.py index 4620fb1..6b331d8 100644 --- a/services/sidecar/bridge_client.py +++ b/services/sidecar/bridge_client.py @@ -17,7 +17,7 @@ from typing import Any, Dict, List, Optional import aiohttp -from .bridge_contract import BRIDGE_ENDPOINTS +from .bridge_contract import BRIDGE_ENDPOINTS, BRIDGE_PROTOCOL_VERSION logger = logging.getLogger(__name__) @@ -56,11 +56,40 @@ class BridgeClient: }, timeout=aiohttp.ClientTimeout(total=30), ) + # R85: Perform Handshake + await self.perform_handshake() async def stop(self): if self.session: await self.session.close() + async def perform_handshake(self): + """Negotiate protocol version with server (R85).""" + try: + url = self._endpoint("handshake") + payload = {"version": BRIDGE_PROTOCOL_VERSION} + + async with self.session.post( + url, json=payload, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: + if resp.status == 200: + data = await resp.json() + logger.info(f"Bridge handshake successful: {data.get('message')}") + return True + elif resp.status == 409: + data = await resp.json() + msg = data.get("message", "Version mismatch") + logger.critical(f"Bridge handshake FAILED: {msg}") + raise RuntimeError(f"Bridge handshake failed: {msg}") + else: + logger.warning(f"Bridge handshake unexpected status {resp.status}") + return False + except Exception as e: + logger.error(f"Bridge handshake error: {e}") + # Fail closed if handshake is required, or open if optional. + # R85 implies strict compatibility check. + raise + async def get_health(self) -> bool: """Check if bridge is reachable via contract health endpoint.""" try: diff --git a/services/sidecar/bridge_contract.py b/services/sidecar/bridge_contract.py index fbe9c93..737f8ad 100644 --- a/services/sidecar/bridge_contract.py +++ b/services/sidecar/bridge_contract.py @@ -18,6 +18,10 @@ class BridgeScope(str, Enum): # Future: CONFIG_WRITE requires explicit opt-in +# Protocol Version (R85) +BRIDGE_PROTOCOL_VERSION = 1 + + @dataclass class DeviceToken: """ @@ -106,6 +110,12 @@ BRIDGE_ENDPOINTS = { "response": BridgeHealthResponse, "auth": None, # Health check is public }, + "handshake": { + "method": "POST", + "path": "/bridge/handshake", + "auth": None, # Public version negotiation + "scope": None, + }, # Worker-facing (poll model — F46) "worker_poll": { "method": "GET", diff --git a/tests/test_bridge_handshake.py b/tests/test_bridge_handshake.py new file mode 100644 index 0000000..d1c5450 --- /dev/null +++ b/tests/test_bridge_handshake.py @@ -0,0 +1,118 @@ +""" +Unit tests for R85 Bridge Handshake (N/N-1 Policy). +""" + +import unittest + +from services.bridge_handshake import verify_handshake +from services.sidecar.bridge_contract import BRIDGE_PROTOCOL_VERSION + + +class TestBridgeHandshake(unittest.TestCase): + + def test_exact_match(self): + """Current version (N) should pass.""" + ok, msg, meta = verify_handshake(BRIDGE_PROTOCOL_VERSION) + self.assertTrue(ok) + # Handle cases where server_version might match client_version + self.assertEqual(meta["server_version"], BRIDGE_PROTOCOL_VERSION) + + def test_n_minus_one(self): + """Previous version (N-1) should pass.""" + if BRIDGE_PROTOCOL_VERSION > 1: + prev_ver = BRIDGE_PROTOCOL_VERSION - 1 + ok, msg, meta = verify_handshake(prev_ver) + self.assertTrue(ok) + self.assertEqual(meta["client_version"], prev_ver) + else: + # If version is 1, N-1 is 0 which might be rejected if min_supported is 1 + # verify_handshake implementation uses max(1, server-1) + # So if server=1, min=1. Client 0 should fail. + pass + + def test_too_old(self): + """Version < N-1 should fail.""" + # Force a drift scenario + server_ver = BRIDGE_PROTOCOL_VERSION + min_ver = max(1, server_ver - 1) + + if min_ver > 1: + too_old = min_ver - 1 + ok, msg, meta = verify_handshake(too_old) + self.assertFalse(ok) + self.assertIn("too old", msg) + + def test_too_new(self): + """Version > N should fail.""" + future_ver = BRIDGE_PROTOCOL_VERSION + 1 + ok, msg, meta = verify_handshake(future_ver) + self.assertFalse(ok) + self.assertIn("newer than server", msg) + + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + + +class TestBridgeClient(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + # We'll mock the internal machinery of BridgeClient for isolation + # But importing it is better to test the actual logic + pass + + @patch("aiohttp.ClientSession.post") + def test_client_handshake_success(self, mock_post): + """Test client handles specific 200 OK handshake.""" + # Need to import BridgeClient locally to avoid import errors if deps missing + try: + from services.sidecar.bridge_client import BridgeClient + except ImportError: + self.skipTest("aiohttp or internal modules missing") + + # Mock Response + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json.return_value = {"ok": True, "message": "Compatible"} + mock_post.return_value.__aenter__.return_value = mock_resp + + client = BridgeClient("http://test", "token", "worker1") + client.session = MagicMock() + client.session.post.return_value.__aenter__.return_value = mock_resp + + # Run async test + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(client.perform_handshake()) + self.assertTrue(result) + finally: + loop.close() + + @patch("aiohttp.ClientSession.post") + def test_client_handshake_fail(self, mock_post): + """Test client raises error on 409 Conflict.""" + try: + from services.sidecar.bridge_client import BridgeClient + except ImportError: + self.skipTest("aiohttp or internal modules missing") + + mock_resp = AsyncMock() + mock_resp.status = 409 + mock_resp.json.return_value = {"ok": False, "message": "Version mismatch"} + + client = BridgeClient("http://test", "token", "worker1") + client.session = MagicMock() + client.session.post.return_value.__aenter__.return_value = mock_resp + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + with self.assertRaises(RuntimeError): + loop.run_until_complete(client.perform_handshake()) + finally: + loop.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_capabilities_contract.py b/tests/test_capabilities_contract.py new file mode 100644 index 0000000..2edbf4a --- /dev/null +++ b/tests/test_capabilities_contract.py @@ -0,0 +1,38 @@ +""" +Contract test for Capabilities API (R83). +Verifies that runtime_profile is correctly exposed in the capabilities surface. +""" + +import os +import unittest +from unittest.mock import patch + +from services.capabilities import get_capabilities +from services.runtime_profile import RuntimeProfile + + +class TestCapabilitiesContract(unittest.TestCase): + + def test_capabilities_structure(self): + """Verify capabilities response structure and required fields.""" + caps = get_capabilities() + self.assertIn("runtime_profile", caps) + self.assertIn("api_version", caps) + self.assertIn("pack", caps) + self.assertIn("features", caps) + + def test_runtime_profile_exposure(self): + """Verify runtime_profile reflects the actual resolved profile.""" + # Case 1: Default (Minimal) + with patch.dict(os.environ, {}, clear=True): + caps = get_capabilities() + self.assertEqual(caps["runtime_profile"], "minimal") + + # Case 2: Hardened + with patch.dict(os.environ, {"OPENCLAW_RUNTIME_PROFILE": "hardened"}): + caps = get_capabilities() + self.assertEqual(caps["runtime_profile"], "hardened") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_f46_worker_e2e.py b/tests/test_f46_worker_e2e.py index fad4be6..41050a0 100644 --- a/tests/test_f46_worker_e2e.py +++ b/tests/test_f46_worker_e2e.py @@ -97,6 +97,7 @@ class TestF46WorkerContractAlignment(unittest.TestCase): self.assertIn("/bridge/health", get_paths) self.assertIn("/bridge/submit", post_paths) self.assertIn("/bridge/deliver", post_paths) + self.assertIn("/bridge/handshake", post_paths) # Worker-facing (F46) self.assertIn("/bridge/worker/poll", get_paths) @@ -104,7 +105,7 @@ class TestF46WorkerContractAlignment(unittest.TestCase): self.assertIn("/bridge/worker/heartbeat", post_paths) def test_route_count(self): - """Exactly 2 GET + 4 POST routes registered.""" + """Exactly 2 GET + 5 POST routes registered.""" from api.bridge import BridgeHandlers, register_bridge_routes mock_app = MagicMock() @@ -115,8 +116,8 @@ class TestF46WorkerContractAlignment(unittest.TestCase): self.assertEqual(mock_router.add_get.call_count, 2) # health + poll self.assertEqual( - mock_router.add_post.call_count, 4 - ) # submit + deliver + result + heartbeat + mock_router.add_post.call_count, 5 + ) # submit + deliver + result + heartbeat + handshake # --------------------------------------------------------------------------- diff --git a/tests/test_runtime_profile.py b/tests/test_runtime_profile.py new file mode 100644 index 0000000..215b6cb --- /dev/null +++ b/tests/test_runtime_profile.py @@ -0,0 +1,62 @@ +""" +Unit tests for R83 Runtime Profile Contract. +""" + +import os +import unittest +from unittest.mock import patch + +from services.runtime_profile import ( + ProfileResolver, + RuntimeProfile, + get_runtime_profile, + is_hardened_mode, +) + + +class TestRuntimeProfile(unittest.TestCase): + + def setUp(self): + # Clear env var before each test to ensure isolation + if "OPENCLAW_RUNTIME_PROFILE" in os.environ: + del os.environ["OPENCLAW_RUNTIME_PROFILE"] + + def test_default_is_minimal(self): + """Test that default profile is MINIMAL when env var is unset.""" + profile = ProfileResolver.resolve() + self.assertEqual(profile, RuntimeProfile.MINIMAL) + self.assertFalse(ProfileResolver.is_hardened()) + self.assertEqual(get_runtime_profile(), RuntimeProfile.MINIMAL) + self.assertFalse(is_hardened_mode()) + + def test_explicit_minimal(self): + """Test that explicitly setting 'minimal' works.""" + with patch.dict(os.environ, {"OPENCLAW_RUNTIME_PROFILE": "minimal"}): + self.assertEqual(ProfileResolver.resolve(), RuntimeProfile.MINIMAL) + self.assertFalse(ProfileResolver.is_hardened()) + + def test_hardened_mode(self): + """Test that setting 'hardened' activates HARDENED profile.""" + with patch.dict(os.environ, {"OPENCLAW_RUNTIME_PROFILE": "hardened"}): + self.assertEqual(ProfileResolver.resolve(), RuntimeProfile.HARDENED) + self.assertTrue(ProfileResolver.is_hardened()) + self.assertEqual(get_runtime_profile(), RuntimeProfile.HARDENED) + self.assertTrue(is_hardened_mode()) + + def test_case_insensitivity(self): + """Test that env var is case-insensitive.""" + with patch.dict(os.environ, {"OPENCLAW_RUNTIME_PROFILE": "HARDENED"}): + self.assertEqual(ProfileResolver.resolve(), RuntimeProfile.HARDENED) + + with patch.dict(os.environ, {"OPENCLAW_RUNTIME_PROFILE": "Minimal"}): + self.assertEqual(ProfileResolver.resolve(), RuntimeProfile.MINIMAL) + + def test_invalid_fallback(self): + """Test that invalid values fall back to MINIMAL.""" + with patch.dict(os.environ, {"OPENCLAW_RUNTIME_PROFILE": "ultra-secure"}): + self.assertEqual(ProfileResolver.resolve(), RuntimeProfile.MINIMAL) + self.assertFalse(ProfileResolver.is_hardened()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_security_gate.py b/tests/test_security_gate.py new file mode 100644 index 0000000..a54d78e --- /dev/null +++ b/tests/test_security_gate.py @@ -0,0 +1,73 @@ +""" +Unit tests for S41 Security Gate. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from services.modules import ModuleCapability +from services.security_gate import SecurityGate, enforce_startup_gate + + +class TestSecurityGate(unittest.TestCase): + + @patch("services.security_gate.is_hardened_mode", return_value=True) + @patch("services.access_control.is_auth_configured", return_value=True) + @patch("services.runtime_config.get_config") + @patch("services.modules.is_module_enabled", return_value=False) + @patch("services.redaction.redact_text", side_effect=lambda x: x) + def test_gate_pass_hardened( + self, mock_redact, mock_enabled, mock_get_config, mock_auth, mock_hardened + ): + """Test gate passes when all controls are valid in hardened mode.""" + cfg = MagicMock() + cfg.allow_any_public_llm_host = False + cfg.allow_insecure_base_url = False + cfg.webhook_auth_mode = "bearer" # Satisfy webhook check if enabled (it is mocked false, but good to have) + mock_get_config.return_value = cfg + + passed, issues = SecurityGate.verify_mandatory_controls() + self.assertTrue(passed, f"Gate failed with issues: {issues}") + self.assertEqual(len(issues), 0) + + # Should not raise + enforce_startup_gate() + + @patch("services.security_gate.is_hardened_mode", return_value=True) + @patch( + "services.access_control.is_auth_configured", return_value=False + ) # Fail auth + @patch("services.runtime_config.get_config") + def test_gate_fail_hardened(self, mock_get_config, mock_auth, mock_hardened): + """Test gate raises exception in hardened mode on failure.""" + cfg = MagicMock() + cfg.allow_any_public_llm_host = False + mock_get_config.return_value = cfg + + passed, issues = SecurityGate.verify_mandatory_controls() + self.assertFalse(passed) + self.assertIn("Authentication is NOT configured (Admin Token missing)", issues) + + with self.assertRaises(RuntimeError): + enforce_startup_gate() + + @patch("services.security_gate.is_hardened_mode", return_value=False) # Minimal + @patch( + "services.access_control.is_auth_configured", return_value=False + ) # Fail auth + @patch("services.runtime_config.get_config") + def test_gate_warn_minimal(self, mock_get_config, mock_auth, mock_hardened): + """Test gate logs warning but does not raise in minimal mode.""" + cfg = MagicMock() + cfg.allow_any_public_llm_host = False + mock_get_config.return_value = cfg + + # Should NOT raise, just log warning + try: + enforce_startup_gate() + except RuntimeError: + self.fail("enforce_startup_gate raised RuntimeError in MINIMAL mode!") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wp2_registry.py b/tests/test_wp2_registry.py new file mode 100644 index 0000000..5f619ae --- /dev/null +++ b/tests/test_wp2_registry.py @@ -0,0 +1,55 @@ +""" +Unit tests for WP2 Service and Module Registries. +""" + +import unittest + +from services.modules import ( + ModuleCapability, + ModuleRegistry, + enable_module, + is_module_enabled, +) +from services.registry import ServiceRegistry + + +class TestServiceRegistry(unittest.TestCase): + + def setUp(self): + ServiceRegistry.reset() + + def test_register_resolve(self): + obj = {"foo": "bar"} + ServiceRegistry.register("test_svc", obj) + self.assertEqual(ServiceRegistry.get("test_svc"), obj) + self.assertTrue(ServiceRegistry.has("test_svc")) + self.assertIsNone(ServiceRegistry.get("missing")) + + def test_reset(self): + ServiceRegistry.register("svc1", 1) + ServiceRegistry.reset() + self.assertFalse(ServiceRegistry.has("svc1")) + + +class TestModuleRegistry(unittest.TestCase): + + def setUp(self): + ModuleRegistry.reset() + + def test_enable_check(self): + self.assertFalse(is_module_enabled(ModuleCapability.CONNECTOR)) + enable_module(ModuleCapability.CONNECTOR) + self.assertTrue(is_module_enabled(ModuleCapability.CONNECTOR)) + self.assertIn("connector", ModuleRegistry.get_enabled_list()) + + def test_lock(self): + enable_module(ModuleCapability.CORE) + ModuleRegistry.lock() + enable_module(ModuleCapability.BRIDGE) # Should be ignored + + self.assertTrue(is_module_enabled(ModuleCapability.CORE)) + self.assertFalse(is_module_enabled(ModuleCapability.BRIDGE)) + + +if __name__ == "__main__": + unittest.main()