From 9d86dff86a581d1bba753bee30be50499c00325b Mon Sep 17 00:00:00 2001
From: rookiestar28 <151893693+rookiestar28@users.noreply.github.com>
Date: Sun, 15 Feb 2026 16:51:21 +0800
Subject: [PATCH] feat: add runtime profile startup hardening, module boot
boundaries, and bridge handshake compatibility
---
README.md | 11 +++
__init__.py | 47 +++++++++++
api/bridge.py | 32 ++++++++
api/routes.py | 19 ++++-
package-lock.json | 3 +
pyproject.toml | 2 +-
services/access_control.py | 13 +++
services/bridge_handshake.py | 49 ++++++++++++
services/capabilities.py | 3 +
services/modules.py | 77 ++++++++++++++++++
services/registry.py | 59 ++++++++++++++
services/runtime_config.py | 33 ++++++++
services/runtime_profile.py | 72 +++++++++++++++++
services/security_gate.py | 120 ++++++++++++++++++++++++++++
services/sidecar/bridge_client.py | 31 ++++++-
services/sidecar/bridge_contract.py | 10 +++
tests/test_bridge_handshake.py | 118 +++++++++++++++++++++++++++
tests/test_capabilities_contract.py | 38 +++++++++
tests/test_f46_worker_e2e.py | 7 +-
tests/test_runtime_profile.py | 62 ++++++++++++++
tests/test_security_gate.py | 73 +++++++++++++++++
tests/test_wp2_registry.py | 55 +++++++++++++
22 files changed, 927 insertions(+), 7 deletions(-)
create mode 100644 services/bridge_handshake.py
create mode 100644 services/modules.py
create mode 100644 services/registry.py
create mode 100644 services/runtime_profile.py
create mode 100644 services/security_gate.py
create mode 100644 tests/test_bridge_handshake.py
create mode 100644 tests/test_capabilities_contract.py
create mode 100644 tests/test_runtime_profile.py
create mode 100644 tests/test_security_gate.py
create mode 100644 tests/test_wp2_registry.py
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()