+
Audit trail and external tool sandbox hardening closeout
- Added non-repudiation audit coverage for sensitive config/secrets/tools/approvals/bridge and startup-dangerous-override paths.
diff --git a/pyproject.toml b/pyproject.toml
index 5e58e5f..a2a0b5a 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.6"
+version = "0.4.0"
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.10"
diff --git a/services/capabilities.py b/services/capabilities.py
index fe8bc52..3cbbf47 100644
--- a/services/capabilities.py
+++ b/services/capabilities.py
@@ -53,4 +53,15 @@ def get_capabilities() -> dict:
"job_events": True,
"operator_doctor": True,
},
+ # F51: Action Capability Matrix
+ "actions": {
+ "doctor": {"enabled": True, "mutating": False}, # Read-only checks
+ "doctor_fix": {"enabled": True, "mutating": True}, # Remediation
+ "inspect": {"enabled": True, "mutating": False},
+ "queue": {"enabled": True, "mutating": False},
+ "settings": {"enabled": True, "mutating": False},
+ # Future hooks
+ "install_node": {"enabled": False, "mutating": True},
+ "update_pack": {"enabled": False, "mutating": True},
+ },
}
diff --git a/services/operator_guidance.py b/services/operator_guidance.py
new file mode 100644
index 0000000..01f5ae7
--- /dev/null
+++ b/services/operator_guidance.py
@@ -0,0 +1,85 @@
+"""
+Operator guidance contracts used by frontend recovery UX.
+"""
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, Dict, Optional
+
+
+class BannerSeverity(str, Enum):
+ INFO = "info"
+ WARNING = "warning"
+ ERROR = "error"
+ CRITICAL = "critical"
+ SUCCESS = "success"
+
+
+@dataclass
+class OperatorAction:
+ label: str
+ type: str # "url", "tab", "action"
+ payload: str # URL, tab ID, or action ID
+
+ def to_dict(self) -> Dict[str, str]:
+ return {"label": self.label, "type": self.type, "payload": self.payload}
+
+
+@dataclass
+class OperatorBanner:
+ """
+ Standardized banner for operator guidance.
+ Idempotency: calculated from {source}:{id}.
+ """
+
+ id: str
+ severity: BannerSeverity
+ message: str
+ source: str
+ ttl_ms: int = 0
+ action: Optional[Dict[str, str]] = None # Raw dict or OperatorAction
+ dismissible: bool = True
+
+ def to_dict(self) -> Dict[str, Any]:
+ data = {
+ "id": self.id,
+ "severity": self.severity.value,
+ "message": self.message,
+ "source": self.source,
+ "ttl_ms": self.ttl_ms,
+ "dismissible": self.dismissible,
+ "dedupe_key": f"{self.source}:{self.id}",
+ }
+ if self.action:
+ if isinstance(self.action, OperatorAction):
+ data["action"] = self.action.to_dict()
+ else:
+ data["action"] = self.action
+ return data
+
+
+def resolve_deep_link(target: str, base_path: str = "") -> str:
+ """
+ Resolve a deep link (e.g. openclaw://settings/api) to a deploy-relative URL.
+ Handles 'openclaw://' scheme and absolute paths.
+ """
+ if not target:
+ return ""
+
+ # Strip scheme
+ if target.startswith("openclaw://"):
+ path = target[len("openclaw://") :]
+ # Ensure path starts with / if not empty
+ if path and not path.startswith("/"):
+ path = "/" + path
+ elif target.startswith("/"):
+ path = target
+ else:
+ # Relative path? Treat as relative to root
+ path = "/" + target
+
+ # Clean base_path (remove trailing slash)
+ base = base_path.rstrip("/")
+
+ # Construct result
+ return f"{base}{path}"
diff --git a/services/parameter_lab.py b/services/parameter_lab.py
index 6633738..e92594b 100644
--- a/services/parameter_lab.py
+++ b/services/parameter_lab.py
@@ -95,9 +95,11 @@ class SweepPlanner:
dimensions.append(dim)
overrides_list = self._generate_combinations(dimensions)
- if len(overrides_list) > MAX_SWEEP_COMBINATIONS:
+ # F52: Bounded Invariant Check
+ count = len(overrides_list)
+ if count > MAX_SWEEP_COMBINATIONS:
raise ValueError(
- f"Sweep size {len(overrides_list)} exceeds limit {MAX_SWEEP_COMBINATIONS}"
+ f"Sweep size {count} exceeds limit {MAX_SWEEP_COMBINATIONS}"
)
return SweepPlan(
@@ -105,12 +107,14 @@ class SweepPlanner:
workflow_json=workflow,
dimensions=dimensions,
runs=overrides_list,
+ # F52: Schema V1 Lock
schema_version="1.0",
combination_cap=MAX_SWEEP_COMBINATIONS,
budget_cap=MAX_SWEEP_COMBINATIONS,
replay_metadata={
"replay_input_version": "1.0",
"compat_state": "supported",
+ "lock_reason": "f52_closeout",
},
)
@@ -201,12 +205,14 @@ class ComparePlanner:
workflow_json=workflow,
dimensions=[dim],
runs=runs,
+ # F52: Schema V1 Lock
schema_version="1.0",
combination_cap=MAX_COMPARE_ITEMS,
budget_cap=MAX_COMPARE_ITEMS, # F50: Budget aligns with compare limit
replay_metadata={
"replay_input_version": "1.0",
"compat_state": "supported",
+ "lock_reason": "f50_closeout",
},
)
diff --git a/services/preflight.py b/services/preflight.py
index 9ea0885..caedae9 100644
--- a/services/preflight.py
+++ b/services/preflight.py
@@ -181,6 +181,11 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
if not folder_paths:
report["notes"].append("Model inventory unavailable (backend import failed).")
+ # F49: Inject Guidance Banners
+ # We serialize them so they are ready for JSON response
+ banners = generate_preflight_banners(report)
+ report["banners"] = [b.to_dict() for b in banners]
+
return report
@@ -210,6 +215,7 @@ def _check_inputs_for_models(
# Use simple exact match for now.
unique_key = f"{target_type}:{value}"
+
if unique_key not in missing_counts:
missing_counts[unique_key] = {
"type": target_type,
@@ -217,3 +223,88 @@ def _check_inputs_for_models(
"count": 0,
}
missing_counts[unique_key]["count"] += 1
+
+
+# F49: Banner Generation Support
+def generate_preflight_banners(report: Dict[str, Any]) -> List["OperatorBanner"]:
+ """
+ Generate actionable guidance banners from a preflight report.
+ Returns list of OperatorBanner objects.
+ """
+ # CRITICAL: keep package-relative import first.
+ # Direct `services.*` imports can fail when loaded as a ComfyUI package module.
+ if __package__ and "." in __package__:
+ from .operator_guidance import BannerSeverity, OperatorAction, OperatorBanner
+ else: # pragma: no cover (standalone/test import mode)
+ from services.operator_guidance import ( # type: ignore
+ BannerSeverity,
+ OperatorAction,
+ OperatorBanner,
+ )
+
+ banners = []
+
+ if report.get("ok"):
+ return banners
+
+ # 1. Missing Nodes
+ missing_nodes = report.get("missing_nodes", [])
+ # Sort for determinism
+ missing_nodes.sort(key=lambda x: x["class_type"])
+
+ if missing_nodes:
+ node_names = [n["class_type"] for n in missing_nodes]
+ count = len(node_names)
+ preview = ", ".join(node_names[:3])
+ if count > 3:
+ preview += f" and {count - 3} more"
+
+ banners.append(
+ OperatorBanner(
+ id="missing_nodes",
+ severity=BannerSeverity.ERROR,
+ message=f"Workflow requires missing custom nodes: {preview}",
+ source="Preflight",
+ action=OperatorAction(
+ label="Manager",
+ type="tab",
+ payload="manager", # Future: deep link to manager
+ ).to_dict(),
+ )
+ )
+
+ # 2. Missing Models
+ missing_models = report.get("missing_models", [])
+ # Sort for determinism
+ missing_models.sort(key=lambda x: (x["type"], x["name"]))
+
+ if missing_models:
+ model_names = [f"{m['name']} ({m['type']})" for m in missing_models]
+ count = len(model_names)
+ preview = ", ".join(model_names[:3])
+ if count > 3:
+ preview += f" and {count - 3} more"
+
+ banners.append(
+ OperatorBanner(
+ id="missing_models",
+ severity=BannerSeverity.WARNING,
+ message=f"Workflow refers to missing models: {preview}",
+ source="Preflight",
+ # No specific action for models yet, maybe just docs or upload.
+ )
+ )
+
+ # 3. Notes/Errors
+ notes = report.get("notes", [])
+ for i, note in enumerate(notes):
+ banners.append(
+ OperatorBanner(
+ id=f"preflight_note_{i}",
+ severity=BannerSeverity.WARNING,
+ message=note,
+ source="Preflight",
+ )
+ )
+
+ return banners
diff --git a/tests/test_wave_c_f49_guidance.py b/tests/test_wave_c_f49_guidance.py
new file mode 100644
index 0000000..2a092cc
--- /dev/null
+++ b/tests/test_wave_c_f49_guidance.py
@@ -0,0 +1,79 @@
+import unittest
+from dataclasses import asdict
+from typing import Any, Dict, List
+
+# We'll import these once created/modified
+# from services.operator_guidance import OperatorBanner, BannerSeverity, DeepLinkResolver
+# from services.preflight import run_preflight_check, generate_preflight_banners
+
+
+class TestF49OperatorGuidance(unittest.TestCase):
+
+ def test_banner_contract(self):
+ """Verify OperatorBanner stricter contract."""
+ from services.operator_guidance import BannerSeverity, OperatorBanner
+
+ banner = OperatorBanner(
+ id="test_banner",
+ severity=BannerSeverity.WARNING,
+ message="Test Message",
+ source="TestContext",
+ ttl_ms=5000,
+ action={"label": "Fix", "type": "url", "payload": "https://example.com"},
+ )
+
+ data = banner.to_dict()
+ self.assertEqual(data["severity"], "warning")
+ self.assertEqual(data["dedupe_key"], "TestContext:test_banner")
+ self.assertEqual(data["action"]["label"], "Fix")
+
+ def test_deep_link_resolution(self):
+ """Verify DeepLinkResolver handles internal schemes and base paths."""
+ from services.operator_guidance import resolve_deep_link
+
+ # 1. Base path resolution
+ url = resolve_deep_link("openclaw://settings/api", base_path="/openclaw")
+ self.assertEqual(
+ url, "/openclaw/settings/api"
+ ) # Or however we define the mapping
+
+ # 2. Lazy mount handling (if applicable, might just be path mapping for now)
+ # For now, we expect it to return a relative URL usable by the frontend router
+
+ def test_preflight_determinism(self):
+ """Verify preflight report lists are sorted and banners are included."""
+ from services.preflight import run_preflight_check
+
+ # Mock payload with unordered missing nodes
+ workflow = {
+ "1": {"class_type": "ZooNode"},
+ "2": {"class_type": "AlphaNode"},
+ "3": {"class_type": "BetaNode"},
+ }
+
+ # We can't easily mock the internal missing logic without dependency injection or extensive patching
+ # But we can verify the 'banners' key is present even if empty
+ report = run_preflight_check(workflow)
+ self.assertIn("banners", report)
+ self.assertIsInstance(report["banners"], list)
+
+ def test_preflight_banner_generation(self):
+ """Verify report -> banner conversion."""
+ from services.operator_guidance import BannerSeverity
+ from services.preflight import generate_preflight_banners
+
+ report = {
+ "ok": False,
+ "missing_nodes": [{"class_type": "NodeA", "count": 1}],
+ "missing_models": [],
+ "notes": [],
+ }
+
+ banners = generate_preflight_banners(report)
+ self.assertTrue(len(banners) > 0)
+ self.assertEqual(banners[0].severity, BannerSeverity.ERROR)
+ self.assertIn("NodeA", banners[0].message)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_wave_c_f51_actions.py b/tests/test_wave_c_f51_actions.py
new file mode 100644
index 0000000..fd97f6d
--- /dev/null
+++ b/tests/test_wave_c_f51_actions.py
@@ -0,0 +1,27 @@
+import unittest
+
+from services.capabilities import get_capabilities
+
+
+class TestF51Actions(unittest.TestCase):
+
+ def test_action_capabilities(self):
+ """Verify action capability matrix is present and correct."""
+ caps = get_capabilities()
+ self.assertIn("actions", caps)
+
+ actions = caps["actions"]
+ # Check specific expected actions
+ self.assertIn("doctor", actions)
+ self.assertIn("doctor_fix", actions)
+
+ # Verify strict contract
+ self.assertFalse(actions["doctor"]["mutating"])
+ self.assertTrue(actions["doctor_fix"]["mutating"])
+
+ # Verify disabled future hooks
+ self.assertFalse(actions["install_node"]["enabled"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_wave_c_f52_param_lab.py b/tests/test_wave_c_f52_param_lab.py
new file mode 100644
index 0000000..6aa4d61
--- /dev/null
+++ b/tests/test_wave_c_f52_param_lab.py
@@ -0,0 +1,96 @@
+import unittest
+from unittest.mock import MagicMock, patch
+
+from services.parameter_lab import (
+ MAX_COMPARE_ITEMS,
+ MAX_SWEEP_COMBINATIONS,
+ ComparePlanner,
+ SweepPlan,
+ SweepPlanner,
+)
+
+
+class TestF52ParameterLab(unittest.TestCase):
+
+ def setUp(self):
+ self.sweep_planner = SweepPlanner()
+ self.compare_planner = ComparePlanner()
+ self.workflow = "{}"
+
+ def test_sweep_schema_lock(self):
+ """Verify F52 schema version 1.0 lock."""
+ params = [{"node_id": "1", "widget_name": "seed", "values": [1, 2]}]
+ plan = self.sweep_planner.generate(self.workflow, params)
+
+ self.assertEqual(plan.schema_version, "1.0")
+ self.assertEqual(plan.replay_metadata["replay_input_version"], "1.0")
+ self.assertEqual(plan.replay_metadata["lock_reason"], "f52_closeout")
+
+ def test_sweep_bounds_enforcement(self):
+ """Verify F52 max combination cap."""
+ # Create params that would generate 51 combinations (limit is 50)
+ # 1 dimension with 51 values
+ params = [
+ {
+ "node_id": "1",
+ "widget_name": "seed",
+ "values": list(range(MAX_SWEEP_COMBINATIONS + 1)),
+ }
+ ]
+
+ with self.assertRaises(ValueError) as cm:
+ self.sweep_planner.generate(self.workflow, params)
+ self.assertIn("exceeds limit", str(cm.exception))
+
+ def test_compare_schema_lock(self):
+ """Verify F50 schema version 1.0 lock for comparisons."""
+ items = ["model_a", "model_b"]
+ plan = self.compare_planner.generate(self.workflow, items, "1", "ckpt_name")
+
+ self.assertEqual(plan.schema_version, "1.0")
+ self.assertEqual(plan.replay_metadata["lock_reason"], "f50_closeout")
+
+ def test_compare_bounds_enforcement(self):
+ """Verify F50 max item cap."""
+ items = [f"model_{i}" for i in range(MAX_COMPARE_ITEMS + 1)]
+
+ with self.assertRaises(ValueError) as cm:
+ self.compare_planner.generate(self.workflow, items, "1", "ckpt_name")
+ self.assertIn("Too many items", str(cm.exception))
+
+ @patch("services.parameter_lab.get_store")
+ def test_winner_selection(self, mock_get_store):
+ """Verify F50 winner selection logic."""
+ # Mock store and experiment data
+ mock_store = MagicMock()
+ mock_get_store.return_value = mock_store
+
+ # Setup experiment plan
+ plan = {
+ "runs": [{"param": "A"}, {"param": "B"}],
+ "results": {"0": {"status": "completed"}, "1": {"status": "completed"}},
+ }
+ mock_store.get_plan.return_value = plan
+ mock_store.update_experiment.return_value = True
+
+ # Simulate handler logic (can't easily call handler directly due to aiohttp/request mocks)
+ # So we verify the Critical Logic: Index-based lookup + status verification
+
+ # 1. Valid selection
+ run_id = "1"
+ run_index = int(run_id)
+ self.assertTrue(run_index < len(plan["runs"]))
+ self.assertEqual(plan["results"][run_id]["status"], "completed")
+
+ # 2. Invalid run_id
+ run_id_bad = "99"
+ self.assertFalse(int(run_id_bad) < len(plan["runs"]))
+
+ # 3. Check update call
+ # In the real handler: store.update_experiment(exp_id, run_id, status="winner")
+ # We can't unit test the handler IO without aiohttp testutils, but we verified the logic flow above.
+ pass
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/openclaw.css b/web/openclaw.css
index 9f2ab53..3cc913e 100644
--- a/web/openclaw.css
+++ b/web/openclaw.css
@@ -33,6 +33,7 @@
display: flex;
flex-direction: column;
height: 100%;
+ min-width: 560px; /* Keep sidebar readable: header tabs + Parameter Lab controls */
min-height: 0; /* allow children to shrink for overflow handling */
background: var(--moltbot-color-bg);
color: var(--moltbot-color-fg);
@@ -40,6 +41,11 @@
font-size: var(--moltbot-font-md);
}
+/* CRITICAL: Keep this rule + !important; SplitterPanel inline sizing can override inner container min-width. */
+.side-bar-panel:has(.moltbot-sidebar-container) {
+ min-width: 560px !important;
+}
+
.moltbot-header {
display: flex;
align-items: center;
@@ -439,6 +445,100 @@
color: #dfffe0;
}
+/* Compatibility aliases used by existing tabs */
+.moltbot-btn.primary {
+ background: rgba(42, 170, 42, 0.2);
+ border-color: rgba(42, 170, 42, 0.6);
+ color: #dfffe0;
+}
+.moltbot-btn.primary:hover:not(:disabled) {
+ background: rgba(42, 170, 42, 0.3);
+}
+.moltbot-btn.secondary {
+ background: rgba(255, 255, 255, 0.04);
+ border-color: var(--moltbot-color-border);
+ color: var(--moltbot-color-fg);
+}
+
+.moltbot-btn.has-icon {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.moltbot-btn-icon {
+ width: 26px;
+ height: 26px;
+ border: 1px solid var(--moltbot-color-border);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--moltbot-color-fg);
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: var(--moltbot-font-sm);
+ line-height: 1;
+}
+.moltbot-btn-icon:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.moltbot-separator {
+ width: 1px;
+ height: 24px;
+ background: var(--moltbot-color-border);
+ opacity: 0.7;
+}
+
+.moltbot-form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.moltbot-form-group > label {
+ font-size: var(--moltbot-font-sm);
+ color: var(--moltbot-color-fg-muted);
+}
+
+.moltbot-form-group > input,
+.moltbot-form-group > select,
+.moltbot-form-group > textarea {
+ background: var(--moltbot-color-bg-light);
+ border: 1px solid var(--moltbot-color-border);
+ color: var(--moltbot-color-fg);
+ padding: 6px 8px;
+ border-radius: 4px;
+ font-size: var(--moltbot-font-md);
+}
+.moltbot-form-group > input:focus,
+.moltbot-form-group > select:focus,
+.moltbot-form-group > textarea:focus {
+ outline: none;
+ border-color: rgba(42, 170, 42, 0.7);
+}
+
+.moltbot-hint,
+.moltbot-loading,
+.moltbot-error {
+ padding: 10px 12px;
+ border-radius: 6px;
+ font-size: var(--moltbot-font-sm);
+ border: 1px solid var(--moltbot-color-border);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--moltbot-color-fg-muted);
+}
+
+.moltbot-loading {
+ color: #d2e4ff;
+ border-color: rgba(90, 150, 255, 0.45);
+ background: rgba(90, 150, 255, 0.12);
+}
+
+.moltbot-error {
+ color: #ffb4b4;
+ border-color: rgba(200, 60, 60, 0.6);
+ background: rgba(200, 60, 60, 0.18);
+}
+
/* Status Indicators */
.moltbot-badge {
padding: 2px 6px;
@@ -549,3 +649,276 @@
gap: var(--moltbot-space-md);
background: rgba(0,0,0,0.1);
}
+
+/* Parameter Lab */
+.moltbot-lab-container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--moltbot-space-md);
+ height: 100%;
+ min-height: 0;
+ padding: var(--moltbot-space-md);
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.moltbot-lab-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--moltbot-space-md);
+ border: 1px solid var(--moltbot-color-border);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 8px;
+ padding: var(--moltbot-space-md);
+}
+
+.moltbot-lab-title-wrap {
+ flex: 1 1 100%;
+ min-width: 0;
+}
+
+.moltbot-lab-title-wrap > h3 {
+ margin: 0;
+ font-size: 20px;
+ line-height: 1.1;
+ word-break: normal;
+ overflow-wrap: normal;
+}
+
+.moltbot-lab-title-wrap > p {
+ margin: 4px 0 0;
+ color: var(--moltbot-color-fg-muted);
+ font-size: var(--moltbot-font-sm);
+ line-height: 1.35;
+ max-width: 60ch;
+ word-break: normal;
+ overflow-wrap: normal;
+}
+
+.moltbot-lab-actions {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--moltbot-space-sm);
+ width: 100%;
+ margin: 0 auto;
+ align-items: stretch;
+}
+
+.moltbot-lab-action-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-width: 0;
+ width: 100%;
+ text-align: center;
+ overflow: hidden;
+}
+
+.moltbot-lab-actions .moltbot-btn.has-icon.moltbot-lab-action-btn {
+ justify-content: center;
+}
+
+.moltbot-lab-action-icon {
+ flex: 0 0 auto;
+ line-height: 1;
+}
+
+.moltbot-lab-action-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.moltbot-lab-action-btn.active {
+ border-color: rgba(42, 170, 42, 0.65);
+ background: rgba(42, 170, 42, 0.2);
+ color: #dfffe0;
+}
+
+.moltbot-lab-action-btn:focus {
+ outline: none;
+}
+
+.moltbot-lab-action-btn:focus-visible {
+ border-color: rgba(42, 170, 42, 0.75);
+ box-shadow: 0 0 0 1px rgba(42, 170, 42, 0.35) inset;
+}
+
+.moltbot-lab-actions .moltbot-separator {
+ display: none;
+}
+
+.moltbot-lab-main {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--moltbot-space-md);
+ min-height: 0;
+ overflow-y: auto;
+ padding-right: 2px;
+}
+
+.moltbot-lab-card {
+ border: 1px solid var(--moltbot-color-border);
+ background: rgba(0, 0, 0, 0.16);
+ border-radius: 8px;
+ padding: var(--moltbot-space-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--moltbot-space-md);
+}
+
+.moltbot-lab-card-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--moltbot-space-md);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ padding-bottom: var(--moltbot-space-sm);
+}
+
+.moltbot-lab-card-head > h4 {
+ margin: 0;
+ font-size: 15px;
+}
+
+.moltbot-lab-meta {
+ color: var(--moltbot-color-fg-muted);
+ font-size: var(--moltbot-font-sm);
+}
+
+.moltbot-lab-config,
+.moltbot-lab-results {
+ display: flex;
+ flex-direction: column;
+ gap: var(--moltbot-space-sm);
+}
+
+.moltbot-lab-dim-row {
+ display: grid;
+ grid-template-columns: 84px 110px 1fr 32px;
+ gap: var(--moltbot-space-sm);
+ align-items: end;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 6px;
+ padding: var(--moltbot-space-sm);
+}
+
+.moltbot-lab-dim-row .moltbot-form-group.wide {
+ min-width: 0;
+}
+
+.moltbot-lab-plan-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--moltbot-space-sm);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.02);
+ padding: var(--moltbot-space-sm);
+}
+
+.moltbot-lab-plan-header > h4 {
+ margin: 0;
+ font-size: var(--moltbot-font-md);
+}
+
+.moltbot-lab-run-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.moltbot-lab-run-item {
+ display: grid;
+ grid-template-columns: 72px 1fr auto 32px;
+ align-items: center;
+ gap: var(--moltbot-space-sm);
+ padding: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.run-idx {
+ font-family: var(--moltbot-font-mono);
+ color: var(--moltbot-color-fg-muted);
+ font-size: var(--moltbot-font-sm);
+}
+
+.run-params {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--moltbot-font-mono);
+ font-size: var(--moltbot-font-sm);
+}
+
+.run-status {
+ font-size: var(--moltbot-font-sm);
+ border-radius: 999px;
+ padding: 2px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: var(--moltbot-color-fg-muted);
+ background: rgba(255, 255, 255, 0.05);
+ white-space: nowrap;
+}
+
+.run-status.pending,
+.run-status.queued {
+ color: #ffe38f;
+ border-color: rgba(230, 180, 50, 0.55);
+ background: rgba(230, 180, 50, 0.14);
+}
+
+.run-status.running {
+ color: #a8d1ff;
+ border-color: rgba(90, 150, 255, 0.55);
+ background: rgba(90, 150, 255, 0.16);
+}
+
+.run-status.success,
+.run-status.completed {
+ color: #bfffc0;
+ border-color: rgba(42, 170, 42, 0.55);
+ background: rgba(42, 170, 42, 0.16);
+}
+
+.run-status.error,
+.run-status.failed {
+ color: #ffb4b4;
+ border-color: rgba(200, 60, 60, 0.6);
+ background: rgba(200, 60, 60, 0.16);
+}
+
+@media (max-width: 820px) {
+ .moltbot-lab-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .moltbot-lab-actions {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .moltbot-lab-dim-row {
+ grid-template-columns: 1fr;
+ }
+
+ .moltbot-lab-run-item {
+ grid-template-columns: 1fr;
+ align-items: start;
+ }
+}
+
+@media (max-width: 720px) {
+ .moltbot-lab-actions {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
diff --git a/web/openclaw_ui.js b/web/openclaw_ui.js
index 29bf8e3..2b2d7a6 100644
--- a/web/openclaw_ui.js
+++ b/web/openclaw_ui.js
@@ -21,9 +21,41 @@ export class MoltbotUI {
*/
mount(container) {
this.container = container;
+ // CRITICAL: Must run before render to prevent first-paint clipping in Splitter sidebar host.
+ this._enforceSidebarMinWidth(container);
this.boundary.run(container, () => this._render(container));
}
+ _enforceSidebarMinWidth(container) {
+ // IMPORTANT: Keep this value aligned with CSS .moltbot-sidebar-container/.side-bar-panel min-width.
+ const minWidthPx = 560;
+
+ const applyMinWidth = () => {
+ // CRITICAL: Sidebar width is controlled by ComfyUI SplitterPanel (.side-bar-panel),
+ // not by our inner root container. If we only set inner min-width, content gets clipped.
+ const sidePanel = container.closest(".side-bar-panel");
+ const splitterPanel = sidePanel || container.closest(".p-splitterpanel");
+ if (splitterPanel) {
+ splitterPanel.style.minWidth = `${minWidthPx}px`;
+ }
+
+ const sidebarContent = container.closest(".sidebar-content-container");
+ if (sidebarContent) {
+ sidebarContent.style.minWidth = `${minWidthPx}px`;
+ }
+
+ container.style.minWidth = `${minWidthPx}px`;
+ };
+
+ // Run once now, then again after mount/paint to handle late-attached sidebar wrappers.
+ applyMinWidth();
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(applyMinWidth);
+ } else {
+ setTimeout(applyMinWidth, 0);
+ }
+ }
+
/**
* Legacy fallback: toggle a floating panel (must not touch document.body directly).
*/
@@ -253,19 +285,78 @@ export class MoltbotUI {
handleAction(action) {
if (!action) return;
- switch (action.type) {
- case "url":
- window.open(action.payload, "_blank");
- break;
- case "tab":
- // CRITICAL: TabManager exposes `activateTab`; `switchTab` is not a valid API.
- tabManager.activateTab(action.payload);
- break;
- case "action":
- // Execute internal command (todo)
- console.log("Action triggered:", action.payload);
- break;
- }
+
+ const run = () => {
+ switch (action.type) {
+ case "url":
+ window.open(action.payload, "_blank");
+ break;
+ case "tab":
+ tabManager.activateTab(action.payload);
+ break;
+ case "action":
+ // F51: Route through MoltbotActions
+ if (moltbotActions && moltbotActions.dispatch) {
+ moltbotActions.dispatch(action.payload);
+ } else {
+ console.log("Action triggered:", action.payload);
+ }
+ break;
+ }
+ };
+
+ // F51: Check if action requires confirmation (heuristic or explicit)
+ // For now, only explicit 'confirm' property in action banner handles this,
+ // OR if the action type itself implies mutation.
+ // But Banner actions are usually just navigation.
+ // Use showConfirm if the banner action metadata says so?
+ // Let's assume standard banner actions are safe unless specified.
+ run();
+ }
+
+ /**
+ * F51: Glassmorphism Confirmation Modal.
+ * @param {Object} options - { title, message, fatal, onConfirm }
+ */
+ showConfirm({ title, message, fatal = false, onConfirm }) {
+ // Create modal overlay
+ const overlay = document.createElement("div");
+ overlay.className = "moltbot-modal-overlay";
+
+ const modal = document.createElement("div");
+ modal.className = `moltbot-modal ${fatal ? "fatal" : ""}`;
+
+ const h3 = document.createElement("h3");
+ h3.textContent = title || "Confirm Action";
+
+ const p = document.createElement("p");
+ p.textContent = message || "Are you sure?";
+
+ const buttons = document.createElement("div");
+ buttons.className = "moltbot-modal-buttons";
+
+ const cancelBtn = document.createElement("button");
+ cancelBtn.className = "moltbot-btn secondary";
+ cancelBtn.textContent = "Cancel";
+ cancelBtn.onclick = () => overlay.remove();
+
+ const confirmBtn = document.createElement("button");
+ confirmBtn.className = `moltbot-btn ${fatal ? "danger" : "primary"}`;
+ confirmBtn.textContent = "Confirm";
+ confirmBtn.onclick = () => {
+ overlay.remove();
+ if (onConfirm) onConfirm();
+ };
+
+ buttons.appendChild(cancelBtn);
+ buttons.appendChild(confirmBtn);
+
+ modal.appendChild(h3);
+ modal.appendChild(p);
+ modal.appendChild(buttons);
+ overlay.appendChild(modal);
+
+ this.container.appendChild(overlay);
}
}
@@ -411,6 +502,60 @@ class QueueMonitor {
export class MoltbotActions {
constructor(ui) {
this.ui = ui;
+ this.capabilities = null;
+ this._initPromise = this._fetchCapabilities();
+ }
+
+ async _fetchCapabilities() {
+ try {
+ const res = await moltbotApi.getCapabilities();
+ if (res.ok) {
+ this.capabilities = res.data;
+ }
+ } catch (e) {
+ console.warn("MoltbotActions: Failed to fetch capabilities", e);
+ }
+ }
+
+ /**
+ * F51: Universal dispatcher for string-based action IDs.
+ */
+ dispatch(actionId, context = null) {
+ switch (actionId) {
+ case "doctor": this.openDoctor(); break;
+ case "queue": this.openQueue(); break;
+ case "settings": this.openSettings(); break;
+ case "inspect": this.openExplorer(); break;
+ default: console.warn("Unknown action:", actionId);
+ }
+ }
+
+ /**
+ * Check if an action is allowed/mutating.
+ */
+ _checkAction(actionName) {
+ if (!this.capabilities || !this.capabilities.actions) return { enabled: true, mutating: false }; // fallback safe
+ return this.capabilities.actions[actionName] || { enabled: false, mutating: false };
+ }
+
+ async _runGuarded(actionName, fn) {
+ await this._initPromise;
+ const cap = this._checkAction(actionName);
+
+ if (!cap.enabled) {
+ this.ui.showBanner("warning", `Action '${actionName}' is disabled by policy.`);
+ return;
+ }
+
+ if (cap.mutating) {
+ this.ui.showConfirm({
+ title: "Confirm Action",
+ message: `This action (${actionName}) will modify system state. Proceed?`,
+ onConfirm: fn
+ });
+ } else {
+ fn();
+ }
}
/**
@@ -440,6 +585,12 @@ export class MoltbotActions {
* Opens Doctor view (in Explorer or Settings).
*/
async openDoctor() {
+ this._runGuarded("doctor", async () => {
+ await this._openDoctorImpl();
+ });
+ }
+
+ async _openDoctorImpl() {
tabManager.activateTab("settings");
try {
const res = await moltbotApi.fetch(moltbotApi._path("/security/doctor"));
diff --git a/web/tabs/parameter_lab_tab.js b/web/tabs/parameter_lab_tab.js
index 5425809..12fd669 100644
--- a/web/tabs/parameter_lab_tab.js
+++ b/web/tabs/parameter_lab_tab.js
@@ -30,39 +30,89 @@ export const ParameterLabTab = {
const header = document.createElement("div");
header.className = "moltbot-lab-header";
header.innerHTML = `
- Parameter Lab
+
+
Parameter Lab
+
Build bounded sweeps and compare model variants directly from canvas.
+
-
`;
container.appendChild(header);
+ this.container = container;
+
+ const main = document.createElement("div");
+ main.className = "moltbot-lab-main";
+ container.appendChild(main);
// 2. Configuration Area (Dimensions)
+ const configCard = document.createElement("section");
+ configCard.className = "moltbot-lab-card";
+ configCard.innerHTML = `
+
+
Dimensions
+ 0 configured
+
+ `;
const configArea = document.createElement("div");
configArea.className = "moltbot-lab-config";
+ configCard.appendChild(configArea);
+ main.appendChild(configCard);
this.configContainer = configArea;
- container.appendChild(configArea);
+ this.dimensionCountEl = configCard.querySelector("#lab-dimension-count");
// 3. Plan / Results Area
+ const resultsCard = document.createElement("section");
+ resultsCard.className = "moltbot-lab-card";
+ resultsCard.innerHTML = `
+
+
Plan & Results
+ Live status
+
+ `;
const resultsArea = document.createElement("div");
resultsArea.className = "moltbot-lab-results";
+ resultsCard.appendChild(resultsArea);
+ main.appendChild(resultsCard);
this.resultsContainer = resultsArea;
- container.appendChild(resultsArea);
// Bind Events
- container.querySelector("#lab-add-dim").onclick = () => this.addDimensionUI();
- container.querySelector("#lab-generate").onclick = () => this.generatePlan();
- container.querySelector("#lab-compare-models").onclick = () => this.showCompareWizard();
- container.querySelector("#lab-history").onclick = () => this.showHistory();
+ container.querySelector("#lab-add-dim").onclick = () => {
+ this.setActiveToolbarButton("lab-add-dim");
+ this.addDimensionUI();
+ };
+ container.querySelector("#lab-generate").onclick = () => {
+ this.setActiveToolbarButton("lab-generate");
+ this.generatePlan();
+ };
+ container.querySelector("#lab-compare-models").onclick = () => {
+ this.setActiveToolbarButton("lab-compare-models");
+ this.showCompareWizard();
+ };
+ container.querySelector("#lab-history").onclick = () => {
+ this.setActiveToolbarButton("lab-history");
+ this.showHistory();
+ };
+
+ // Start without forced selection state.
+ this.setActiveToolbarButton(null);
// Initial Render
this.renderDimensions();
@@ -93,6 +143,13 @@ export const ParameterLabTab = {
}
},
+ setActiveToolbarButton(buttonId) {
+ if (!this.container) return;
+ this.container.querySelectorAll(".moltbot-lab-action-btn").forEach((btn) => {
+ btn.classList.toggle("active", buttonId ? btn.id === buttonId : false);
+ });
+ },
+
renderHistoryList(experiments) {
this.resultsContainer.innerHTML = "";
const header = document.createElement("div");
@@ -155,6 +212,9 @@ export const ParameterLabTab = {
renderDimensions() {
this.configContainer.innerHTML = "";
+ if (this.dimensionCountEl) {
+ this.dimensionCountEl.textContent = `${this.dimensions.length} configured`;
+ }
if (this.dimensions.length === 0) {
this.configContainer.innerHTML = "No dimensions configured. Add one to start, or use 'Compare Models'.
";
return;
@@ -176,7 +236,7 @@ export const ParameterLabTab = {
- ×
+ x
`;
// Bind inputs