mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat: add runtime profile startup hardening, module boot boundaries, and bridge handshake compatibility
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+3
@@ -7,6 +7,9 @@
|
||||
"name": "comfyui-openclaw",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)."
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user