feat: add runtime profile startup hardening, module boot boundaries, and bridge handshake compatibility

This commit is contained in:
rookiestar28
2026-02-15 16:51:59 +08:00
parent e23580bbac
commit 9d86dff86a
22 changed files with 927 additions and 7 deletions
+11
View File
@@ -28,6 +28,17 @@ This project is intentionally **not** a general-purpose “assistant platform”
## Latest Updates - Click to expand
<details>
<summary><strong>Runtime profile hardening and bridge startup compatibility checks</strong></summary>
- 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.
</details>
<details>
<summary><strong>Connector platform parity and sidecar worker runtime improvements</strong></summary>
+47
View File
@@ -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
+32
View File
@@ -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(
+17 -2
View File
@@ -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
+3
View File
@@ -7,6 +7,9 @@
"name": "comfyui-openclaw",
"devDependencies": {
"@playwright/test": "^1.50.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@playwright/test": {
+1 -1
View File
@@ -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"
+13
View File
@@ -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())
+49
View File
@@ -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
+3
View File
@@ -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,
+77
View File
@@ -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)
+59
View File
@@ -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"
+33
View File
@@ -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()
+72
View File
@@ -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()
+120
View File
@@ -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)."
)
+30 -1
View File
@@ -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:
+10
View File
@@ -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",
+118
View File
@@ -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()
+38
View File
@@ -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()
+4 -3
View File
@@ -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
# ---------------------------------------------------------------------------
+62
View File
@@ -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()
+73
View File
@@ -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()
+55
View File
@@ -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()