finalize Asset Packs integrity + API surface with CI-ready verification

This commit is contained in:
rookiestar28
2026-02-09 16:43:23 +08:00
parent e8a1dbec5f
commit b1c6426425
20 changed files with 1170 additions and 104 deletions
+5 -8
View File
@@ -71,9 +71,12 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install numpy pillow python -m pip install numpy pillow
- name: Run unit tests - name: Run unit tests
env: env:
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit
run: | run: |
python -m unittest discover -s tests -p "test_*.py" -v
security-audit: security-audit:
name: Security Audit (S23) name: Security Audit (S23)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -85,9 +88,7 @@ jobs:
- name: Frontend Audit (npm) - name: Frontend Audit (npm)
run: | run: |
# Audit only production dependencies, ignore dev # Audit only production dependencies, ignore dev
npm audit --production || true npm audit --production
# Note: || true because audit often fails on harmless things.
# In strict mode, remove || true or configure exceptions.
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: with:
@@ -95,8 +96,4 @@ jobs:
- name: Install pip-audit - name: Install pip-audit
run: pip install pip-audit run: pip install pip-audit
- name: Backend Audit (pip) - name: Backend Audit (pip)
run: | run: pip-audit
pip install -r requirements.txt || true
# Scan environment
pip-audit || true
# Again, allowing failure for now to avoid blocking CI on minor findings.
+1
View File
@@ -0,0 +1 @@
18
+21 -7
View File
@@ -1,20 +1,24 @@
# ComfyUI-OpenClaw # ComfyUI-OpenClaw
ComfyUI-OpenClaw is a ComfyUI custom node pack that adds: ![OpenClaw /run command example](assets/run.png)
ComfyUI-OpenClaw is a **security-first** ComfyUI custom node pack that adds:
- **LLM-assisted nodes** (planner/refiner/vision/batch variants) - **LLM-assisted nodes** (planner/refiner/vision/batch variants)
- **A built-in extension UI** (`OpenClaw` panel) - **A built-in extension UI** (`OpenClaw` panel)
- **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets) - **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets)
- And more exciting features being added continuously - And more exciting features being added continuously
![OpenClaw /run command example](assets/run.png) This project is intentionally **not** a general-purpose “assistant platform” with broad remote execution surfaces.
It is designed to make **ComfyUI a reliable automation target** with an explicit admin boundary and hardened defaults.
- **openclaw**: a broad assistant platform with skills, channels, gateway control plane, etc. **Security stance (how this project differs from convenience-first automation packs):**
- **ComfyUI-OpenClaw**: a ComfyUI-first automation layer that uses LLMs as helpers, with an API surface designed to be safe and manageable. - Localhost-first defaults; remote access is opt-in
- Explicit **Admin Token** boundary for write actions
If you want a “personal assistant OS,” openclaw makes a lot of sense. - Webhooks are **deny-by-default** until auth is configured
If you want **“ComfyUI as a reliable automation target,”** ComfyUI-OpenClaw feels more direct. - Strict outbound SSRF policy (callbacks + custom LLM base URLs)
- Secrets are never stored in browser storage (optional server-side key store is local-only convenience)
--- ---
@@ -238,6 +242,16 @@ Admin boundary:
- `GET /openclaw/packs/export/{name}/{version}` - `GET /openclaw/packs/export/{name}/{version}`
- `DELETE /openclaw/packs/{name}/{version}` - `DELETE /openclaw/packs/{name}/{version}`
Packs are **versioned zip bundles** (templates/presets/profiles) with an integrity manifest (file hashes).
Import/export is designed to be **reproducible** and hardened against common archive attacks (path traversal, zip bombs).
Operational notes:
- Packs are **local-only by default** (no auto-download).
- Packs management requires the Admin Token boundary (or localhost-only convenience mode).
- UI: `OpenClaw` panel → `Packs` tab.
- Verification: `python -m unittest tests.test_packs_integrity -v`
### Bridge (sidecar; optional) ### Bridge (sidecar; optional)
Sidecar bridge routes are registered under `/openclaw/bridge/*` and `/moltbot/bridge/*`. Sidecar bridge routes are registered under `/openclaw/bridge/*` and `/moltbot/bridge/*`.
+1
View File
@@ -55,6 +55,7 @@ def _register_routes_once():
"""Register all Moltbot routes including Bridge and Scheduler.""" """Register all Moltbot routes including Bridge and Scheduler."""
from .api.approvals import register_approval_routes from .api.approvals import register_approval_routes
from .api.bridge import BridgeHandlers from .api.bridge import BridgeHandlers
from .api.presets import register_preset_routes from .api.presets import register_preset_routes
from .api.routes import register_routes from .api.routes import register_routes
from .api.schedules import register_schedule_routes from .api.schedules import register_schedule_routes
+172 -78
View File
@@ -1,11 +1,66 @@
from typing import Optional
try:
from aiohttp import web
except ImportError:
# NOTE:
# - ComfyUI runtime always has `aiohttp` available.
# - Unit tests may run in a minimal Python environment without `aiohttp`.
# We still want this module to be importable, but any HTTP handlers must
# fail fast if invoked without real `aiohttp.web`.
class MockWeb:
_IS_MOCKWEB = True
class Request:
pass
class Response:
pass
class FileResponse:
def __init__(self, path: str, headers: Optional[dict] = None):
self._path = path
self.headers = headers or {}
async def prepare(self, request): # pragma: no cover
return None
@staticmethod
def json_response(*args, **kwargs): # pragma: no cover
raise RuntimeError("aiohttp not available")
web = MockWeb()
import os import os
import shutil
import tempfile import tempfile
from aiohttp import web # OpenClaw imports
if __package__ and "." in __package__:
from ..services.access_control import require_admin_token
from ..services.packs.pack_archive import PackArchive, PackError
from ..services.packs.pack_registry import PackRegistry
else:
from services.access_control import require_admin_token
from services.packs.pack_archive import PackArchive, PackError
from services.packs.pack_registry import PackRegistry
from ..services.access_control import require_admin_token
from ..services.packs.pack_archive import PackArchive, PackError if web:
from ..services.packs.pack_registry import PackRegistry class CleanupFileResponse(web.FileResponse):
"""FileResponse that deletes the file after sending."""
async def prepare(self, request):
try:
return await super().prepare(request)
finally:
path = self._path
if os.path.exists(path):
try:
os.remove(path)
except Exception:
pass
else:
CleanupFileResponse = None
class PacksHandlers: class PacksHandlers:
@@ -13,30 +68,50 @@ class PacksHandlers:
self.registry = PackRegistry(state_dir) self.registry = PackRegistry(state_dir)
async def list_packs_handler(self, request: web.Request) -> web.Response: async def list_packs_handler(self, request: web.Request) -> web.Response:
"""GET /moltbot/packs""" """GET /packs - List installed packs."""
allowed, error = require_admin_token(request) if getattr(web, "_IS_MOCKWEB", False) is True:
if not allowed: raise RuntimeError("aiohttp not available")
return web.json_response({"ok": False, "error": error}, status=403)
# S8: Public read or authenticated?
# Usually list is fine to be public-read if not strictly protected,
# but admin token check is safer for system info.
# Plan says "Integrity (Local)", implies authenticated management.
# list_packs might be needed for UI.
# Let's verify admin token for consistency with other sensitive endpoints.
# Actually, let's keep list public-ish for UI discovery?
# No, "require_admin_token" for everything per F32 is safer.
# But for now, let's just implement listing.
# NOTE: S8/F11 implies rigorous management.
if not await self._check_auth(request):
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
packs = self.registry.list_packs() try:
return web.json_response({"ok": True, "packs": packs}) packs = self.registry.list_packs()
return web.json_response({"ok": True, "packs": packs})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def import_pack_handler(self, request: web.Request) -> web.Response: async def import_pack_handler(self, request: web.Request) -> web.Response:
"""POST /moltbot/packs/import (multipart/form-data)""" """POST /packs/import - Install pack from zip upload."""
allowed, error = require_admin_token(request) if getattr(web, "_IS_MOCKWEB", False) is True:
if not allowed: raise RuntimeError("aiohttp not available")
return web.json_response({"ok": False, "error": error}, status=403)
if not await self._check_auth(request):
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
# Multipart reader
reader = await request.multipart() reader = await request.multipart()
field = await reader.next() field = await reader.next()
if not field or field.name != "file": if not field or field.name != "file":
return web.json_response({"ok": False, "error": "missing_file"}, status=400) return web.json_response({"ok": False, "error": "Missing file field"}, status=400)
# Write upload to temp file filename = field.filename or "pack.zip"
# Save to temp file
fd, temp_path = tempfile.mkstemp(suffix=".zip") fd, temp_path = tempfile.mkstemp(suffix=".zip")
os.close(fd) os.close(fd)
try: try:
with open(temp_path, "wb") as f: with open(temp_path, "wb") as f:
while True: while True:
@@ -44,75 +119,94 @@ class PacksHandlers:
if not chunk: if not chunk:
break break
f.write(chunk) f.write(chunk)
# Install overwrite = request.query.get("overwrite", "false").lower() == "true"
overwrite = request.query.get("overwrite", "").lower() == "true"
meta = self.registry.install_pack(temp_path, overwrite=overwrite) try:
meta = self.registry.install_pack(temp_path, overwrite=overwrite)
return web.json_response({"ok": True, "pack": meta}) return web.json_response({"ok": True, "pack": meta})
except PackError as e:
except PackError as e: return web.json_response({"ok": False, "error": str(e)}, status=400)
return web.json_response({"ok": False, "error": str(e)}, status=400)
except Exception as e: except Exception as e:
return web.json_response( return web.json_response({"ok": False, "error": str(e)}, status=500)
{"ok": False, "error": f"Internal error: {str(e)}"}, status=500
)
finally: finally:
if os.path.exists(temp_path): if os.path.exists(temp_path):
os.remove(temp_path) os.remove(temp_path)
async def export_pack_handler(self, request: web.Request) -> web.Response: async def delete_pack_handler(self, request: web.Request) -> web.Response:
"""GET /moltbot/packs/export/{name}/{version}""" """DELETE /packs/{name}/{version} - Uninstall pack."""
allowed, error = require_admin_token(request) if getattr(web, "_IS_MOCKWEB", False) is True:
if not allowed: raise RuntimeError("aiohttp not available")
return web.json_response({"ok": False, "error": error}, status=403)
if not await self._check_auth(request):
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
name = request.match_info.get("name") name = request.match_info.get("name")
version = request.match_info.get("version") version = request.match_info.get("version")
pack_dir = self.registry.get_pack_path(name, version) if not name or not version:
if not pack_dir: return web.json_response({"ok": False, "error": "Missing name/version"}, status=400)
return web.json_response(
{"ok": False, "error": "pack_not_found"}, status=404
)
# Create temp zip
fd, temp_path = tempfile.mkstemp(suffix=f"-{name}-{version}.zip")
os.close(fd)
try: try:
PackArchive.create_pack_archive(pack_dir, temp_path) success = self.registry.uninstall_pack(name, version)
if success:
# Serve file return web.json_response({"ok": True})
return web.FileResponse( else:
temp_path, return web.json_response({"ok": False, "error": "Not found"}, status=404)
headers={
"Content-Disposition": f'attachment; filename="{name}-{version}.moltpack"'
},
)
# Note: FileResponse might not clean up temp file automatically.
# In a real system, we'd want a cleanup mechanism (e.g. background task).
# For now, we rely on OS temp cleanup or add a cleanup callback if aiohttp supports it.
# A safer way allows a streaming response that deletes after.
# But standard ComfyUI extensions often simple-serve.
except Exception as e: except Exception as e:
if os.path.exists(temp_path):
os.remove(temp_path)
return web.json_response({"ok": False, "error": str(e)}, status=500) return web.json_response({"ok": False, "error": str(e)}, status=500)
async def delete_pack_handler(self, request: web.Request) -> web.Response: async def export_pack_handler(self, request: web.Request) -> web.Response:
"""DELETE /moltbot/packs/{name}/{version}""" """GET /packs/export/{name}/{version} - Download pack zip."""
allowed, error = require_admin_token(request) if getattr(web, "_IS_MOCKWEB", False) is True:
if not allowed: raise RuntimeError("aiohttp not available")
return web.json_response({"ok": False, "error": error}, status=403)
if not await self._check_auth(request):
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
name = request.match_info.get("name")
version = request.match_info.get("version")
if not name or not version:
return web.json_response({"ok": False, "error": "Missing name/version"}, status=400)
pack_path = self.registry.get_pack_path(name, version)
if not pack_path:
return web.json_response({"ok": False, "error": "Pack not found"}, status=404)
# Create temp zip
fd, temp_zip = tempfile.mkstemp(suffix=".zip")
os.close(fd)
try:
# Ensure manifest exists (it should for installed packs)
if not os.path.exists(os.path.join(pack_path, "manifest.json")):
return web.json_response({"ok": False, "error": "Pack manifest missing/corrupt"}, status=500)
# Create deterministic zip
PackArchive.create_pack_archive(pack_path, temp_zip)
# Stream response
return CleanupFileResponse(
temp_zip,
headers={
"Content-Disposition": f'attachment; filename="{name}-{version}.zip"',
"Content-Type": "application/zip",
}
)
except Exception as e:
if os.path.exists(temp_zip):
os.remove(temp_zip)
return web.json_response({"ok": False, "error": str(e)}, status=500)
name = request.match_info.get("name") async def _check_auth(self, request: web.Request) -> bool:
version = request.match_info.get("version") # Re-use require_admin_token logic from access_control?
# require_admin_token(request) returns (allowed, error_msg)
success = self.registry.uninstall_pack(name, version) # Wait, require_admin_token in access_control might depend on config.
if success: # Let's import it.
return web.json_response({"ok": True}) try:
else: allowed, _ = require_admin_token(request)
return web.json_response( return allowed
{"ok": False, "error": "pack_not_found"}, status=404 except Exception:
) return False
+6
View File
@@ -52,6 +52,12 @@ def _print_security_banner(config):
"⚠️ Admin commands (/approve, /reject, etc.) will be unavailable." "⚠️ Admin commands (/approve, /reject, etc.) will be unavailable."
) )
if not config.admin_token:
logger.warning("⚠️ No admin token configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).")
logger.warning(
"⚠️ Admin commands will fail if OpenClaw Server requires authentication."
)
async def main(): async def main():
logger.info("Initializing OpenClaw Connector (Phase 5)...") logger.info("Initializing OpenClaw Connector (Phase 5)...")
+34 -7
View File
@@ -9,6 +9,7 @@ Privacy:
""" """
import logging import logging
import time
from typing import Dict, List, Optional from typing import Dict, List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +25,8 @@ class LLMClient:
- Never auto-executes commands. - Never auto-executes commands.
""" """
CONFIG_TTL = 60 # seconds
def __init__(self, openclaw_client): def __init__(self, openclaw_client):
""" """
Initialize with OpenClawClient to fetch settings from backend. Initialize with OpenClawClient to fetch settings from backend.
@@ -33,11 +36,13 @@ class LLMClient:
""" """
self._client = openclaw_client self._client = openclaw_client
self._config_cache = None self._config_cache = None
self._last_fetch = 0
self._configured = None self._configured = None
async def _fetch_config(self) -> dict: async def _fetch_config(self) -> dict:
"""Fetch LLM config from OpenClaw backend.""" """Fetch LLM config from OpenClaw backend (with TTL)."""
if self._config_cache is not None: now = time.time()
if self._config_cache is not None and (now - self._last_fetch < self.CONFIG_TTL):
return self._config_cache return self._config_cache
res = await self._client.get_openclaw_config() res = await self._client.get_openclaw_config()
@@ -49,14 +54,25 @@ class LLMClient:
self._config_cache = data.get("config", {}) self._config_cache = data.get("config", {})
else: else:
self._config_cache = data if isinstance(data, dict) else {} self._config_cache = data if isinstance(data, dict) else {}
self._last_fetch = now
# Reset configured state to force re-evaluation
self._configured = None
else: else:
self._config_cache = {} # On failure, keep old cache if available (resilience)
if self._config_cache is None:
self._config_cache = {}
return self._config_cache return self._config_cache
async def is_configured(self) -> bool: async def is_configured(self) -> bool:
"""Check if LLM is properly configured in OpenClaw settings.""" """Check if LLM is properly configured in OpenClaw settings."""
if self._configured is not None: if self._configured is not None:
return self._configured # Re-check TTL on is_configured access too?
# _fetch_config handles it.
# But if config didn't change, _configured is valid.
# If TTL expired, we need to re-fetch and re-evaluate.
if time.time() - self._last_fetch < self.CONFIG_TTL:
return self._configured
config = await self._fetch_config() config = await self._fetch_config()
provider = config.get("provider") provider = config.get("provider")
@@ -103,11 +119,22 @@ class LLMClient:
if res.get("ok"): if res.get("ok"):
data = res.get("text") or res.get("data", {}).get("text") data = res.get("text") or res.get("data", {}).get("text")
return data or "[No response]" return data or "[No response]"
return f"[LLM Error] {res.get('error', 'Request failed')}"
error_msg = res.get('error', 'Request failed')
# Harden error messages for user
if "401" in error_msg or "unauthorized" in error_msg.lower():
return "[LLM Error] API Key Invalid or Missing. Please check Settings."
if "429" in error_msg or "quota" in error_msg.lower():
return "[LLM Error] Rate Limit / Quota Exceeded. Please try again later."
if "503" in error_msg or "overloaded" in error_msg.lower():
return "[LLM Error] Service Overloaded. Please try again later."
return f"[LLM Error] {error_msg}"
except Exception: except Exception:
# Log error without user content # Log error without user content
logger.error("LLM request failed") logger.error("LLM request failed", exc_info=True)
return "[LLM Error] Request failed. Please try again." return "[LLM Error] Request failed. Please check logs."
def _get_default_base_url(self, provider: str) -> str: def _get_default_base_url(self, provider: str) -> str:
"""Get default base URL for provider (matches OpenClaw catalog).""" """Get default base URL for provider (matches OpenClaw catalog)."""
+49 -1
View File
@@ -5,7 +5,7 @@ Dispatches parsed commands to handlers with AST argument parsing.
import logging import logging
import shlex import shlex
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
from .config import ConnectorConfig from .config import ConnectorConfig
from .contract import CommandRequest, CommandResponse from .contract import CommandRequest, CommandResponse
@@ -234,6 +234,17 @@ class CommandRouter:
+ "\n".join(f"- {d}" for d in details) + "\n".join(f"- {d}" for d in details)
) )
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
"""
F32 WP3: Check if admin token is configured before running admin commands.
Fail-fast with clear error message instead of 403/500 later.
"""
if not self.config.admin_token:
return CommandResponse(
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
)
return None
async def _handle_run( async def _handle_run(
self, req: CommandRequest, args: List[str] self, req: CommandRequest, args: List[str]
) -> CommandResponse: ) -> CommandResponse:
@@ -302,6 +313,14 @@ class CommandRouter:
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}" msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
if "expires_at" in data: if "expires_at" in data:
msg += f"\nExpires: {data['expires_at']}" msg += f"\nExpires: {data['expires_at']}"
if self.poller:
# IMPORTANT:
# For untrusted users, approvals are done in the OpenClaw UI.
# We must start tracking the approval_id so we can map
# approval_id -> executed_prompt_id later and auto-deliver images.
self.poller.track_approval(
approval_id, req.platform, req.channel_id, req.sender_id
)
return CommandResponse(text=msg) return CommandResponse(text=msg)
else: else:
prompt_id = data.get("prompt_id", "unknown") prompt_id = data.get("prompt_id", "unknown")
@@ -356,6 +375,10 @@ class CommandRouter:
async def _handle_interrupt( async def _handle_interrupt(
self, req: CommandRequest, args: List[str] self, req: CommandRequest, args: List[str]
) -> CommandResponse: ) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Remediation: Global Interrupt # Remediation: Global Interrupt
res = await self.client.interrupt_output() res = await self.client.interrupt_output()
if res.get("ok"): if res.get("ok"):
@@ -366,6 +389,10 @@ class CommandRouter:
async def _handle_approvals_list( async def _handle_approvals_list(
self, req: CommandRequest, args: List[str] self, req: CommandRequest, args: List[str]
) -> CommandResponse: ) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_approvals() res = await self.client.get_approvals()
if not res.get("ok"): if not res.get("ok"):
return CommandResponse( return CommandResponse(
@@ -405,6 +432,10 @@ class CommandRouter:
if not args: if not args:
return CommandResponse(text="Usage: /approve <id>") return CommandResponse(text="Usage: /approve <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
# Assuming auto_execute=True by default for chat logic # Assuming auto_execute=True by default for chat logic
res = await self.client.approve_request(args[0], auto_execute=True) res = await self.client.approve_request(args[0], auto_execute=True)
if not res.get("ok"): if not res.get("ok"):
@@ -434,6 +465,10 @@ class CommandRouter:
if not args: if not args:
return CommandResponse(text="Usage: /reject <id> [reason]") return CommandResponse(text="Usage: /reject <id> [reason]")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat" reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
res = await self.client.reject_request(args[0], reason) res = await self.client.reject_request(args[0], reason)
if not res.get("ok"): if not res.get("ok"):
@@ -444,6 +479,10 @@ class CommandRouter:
async def _handle_schedules_list( async def _handle_schedules_list(
self, req: CommandRequest, args: List[str] self, req: CommandRequest, args: List[str]
) -> CommandResponse: ) -> CommandResponse:
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_schedules() res = await self.client.get_schedules()
if not res.get("ok"): if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}") return CommandResponse(text=f"[Error] {res.get('error')}")
@@ -467,6 +506,10 @@ class CommandRouter:
if len(args) < 2: if len(args) < 2:
return CommandResponse(text="Usage: /schedule <run|toggle> <id>") return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
sub = args[0].lower() sub = args[0].lower()
sid = args[1] sid = args[1]
@@ -519,6 +562,11 @@ class CommandRouter:
) -> CommandResponse: ) -> CommandResponse:
if not args: if not args:
return CommandResponse(text="Usage: /trace <prompt_id>") return CommandResponse(text="Usage: /trace <prompt_id>")
# F32 WP3: Guard
if err := self._require_admin_token_configured():
return err
res = await self.client.get_trace(args[0]) res = await self.client.get_trace(args[0])
if not res.get("ok"): if not res.get("ok"):
return CommandResponse(text=f"[Error] {res.get('error')}") return CommandResponse(text=f"[Error] {res.get('error')}")
+4 -1
View File
@@ -1,6 +1,9 @@
{ {
"name": "comfyui-openclaw", "name": "comfyui-openclaw",
"private": true, "private": true,
"engines": {
"node": ">=20.0.0"
},
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.0" "@playwright/test": "^1.50.0"
}, },
@@ -11,4 +14,4 @@
"test:debug": "npx playwright test --debug", "test:debug": "npx playwright test --debug",
"test:report": "npx playwright show-report" "test:report": "npx playwright show-report"
} }
} }
+35 -1
View File
@@ -14,6 +14,8 @@ from .pack_manifest import (
) )
from .pack_types import PackMetadata from .pack_types import PackMetadata
MAX_COMPRESSION_RATIO = 100
class PackArchive: class PackArchive:
@staticmethod @staticmethod
@@ -49,12 +51,20 @@ class PackArchive:
if total_size > MAX_FILE_SIZE_MB * 1024 * 1024: if total_size > MAX_FILE_SIZE_MB * 1024 * 1024:
raise PackError(f"Archive content too large ({total_size} bytes)") raise PackError(f"Archive content too large ({total_size} bytes)")
# Check compression ratio
compressed_size = sum(i.compress_size for i in infos)
if compressed_size > 0:
ratio = total_size / compressed_size
if ratio > MAX_COMPRESSION_RATIO:
raise PackError(f"Compression ratio too high ({ratio:.1f} > {MAX_COMPRESSION_RATIO})")
# 2. Safety Check # 2. Safety Check
for info in infos: for info in infos:
if ( if (
info.filename.startswith("/") info.filename.startswith("/")
or ".." in info.filename or ".." in info.filename
or "\\" in info.filename or "\\" in info.filename
or any(c < ' ' for c in info.filename) # Control chars
): ):
raise PackError(f"Unsafe filename: {info.filename}") raise PackError(f"Unsafe filename: {info.filename}")
@@ -118,8 +128,32 @@ class PackArchive:
raise PackError("Source directory does not exist") raise PackError("Source directory does not exist")
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf:
files_to_add = []
for root, _, files in os.walk(source_dir): for root, _, files in os.walk(source_dir):
for file in files: for file in files:
full_path = os.path.join(root, file) full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, source_dir) rel_path = os.path.relpath(full_path, source_dir)
zf.write(full_path, rel_path) files_to_add.append((full_path, rel_path))
# Deterministic order
files_to_add.sort(key=lambda x: x[1])
for full_path, rel_path in files_to_add:
# Deterministic metadata (timestamp)
# ZipInfo requires a tuple (year, month, day, hour, min, sec)
# We use a fixed epoch for reproducibility, or file mtime?
# Plan says "regenerate manifest deterministically".
# If we use file mtime, it changes if we touch files.
# Using fixed timestamp ensures identical binary hash for identical content.
# But standard zip tools use mtime.
# Let's use 1980-01-01 00:00:00 (DOS epoch)
zinfo = zipfile.ZipInfo(rel_path)
zinfo.date_time = (1980, 1, 1, 0, 0, 0)
zinfo.compress_type = zipfile.ZIP_DEFLATED
# Set regular file permissions (0o644)
# External attr: (0o100644 << 16) = 0x81A40000
zinfo.external_attr = 0x81A40000
with open(full_path, "rb") as f:
zf.writestr(zinfo, f.read())
+45
View File
@@ -90,3 +90,48 @@ def validate_manifest_integrity(base_dir: str, manifest: PackManifest) -> List[s
errors.append(f"Error reading {rel_path}: {str(e)}") errors.append(f"Error reading {rel_path}: {str(e)}")
return errors return errors
def create_manifest(base_dir: str, metadata: Dict[str, Any]) -> str:
"""
Generates and writes a deterministic manifest.json for the given value.
Returns the path to the written manifest.
"""
manifest_path = os.path.join(base_dir, "manifest.json")
# 1. Collect all files and compute hashes
files_list = []
for root, _, files in os.walk(base_dir):
for file in files:
if file == "manifest.json":
continue # Do not include manifest in manifest
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, base_dir).replace("\\", "/") # Normalize separators
sha = compute_sha256(full_path)
# Use deterministic dictionary structure
files_list.append({
"path": rel_path,
"sha256": sha,
"size": os.path.getsize(full_path)
})
# 2. Sort files by path (Important for determinism)
files_list.sort(key=lambda x: x["path"])
# 3. Create manifest object
manifest = {
"version": metadata.get("version", "0.0.0"),
"files": files_list,
# Add metadata keys sorted?
**{k: v for k, v in sorted(metadata.items()) if k != "version"}
}
# 4. Write with sort_keys=True
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, sort_keys=True)
f.write("\n") # POSIX newline
return manifest_path
+59 -1
View File
@@ -60,6 +60,12 @@ ALLOWED_LLM_KEYS = {
"max_failover_candidates", "max_failover_candidates",
} }
ALLOWED_SCHEDULER_KEYS = {
"startup_jitter_sec",
"max_runs_per_tick",
"skip_missed_intervals",
}
# Default values # Default values
DEFAULTS = { DEFAULTS = {
"llm": { "llm": {
@@ -72,7 +78,12 @@ DEFAULTS = {
"fallback_models": [], "fallback_models": [],
"fallback_providers": [], "fallback_providers": [],
"max_failover_candidates": 3, "max_failover_candidates": 3,
} },
"scheduler": {
"startup_jitter_sec": 30,
"max_runs_per_tick": 5,
"skip_missed_intervals": False,
},
} }
# Value constraints # Value constraints
@@ -82,6 +93,11 @@ CONSTRAINTS = {
"max_failover_candidates": (1, 5), # R14: Limit total candidates "max_failover_candidates": (1, 5), # R14: Limit total candidates
} }
SCHEDULER_CONSTRAINTS = {
"startup_jitter_sec": (0, 300),
"max_runs_per_tick": (1, 100),
}
# Environment variable mappings (new, legacy) # Environment variable mappings (new, legacy)
ENV_MAPPINGS = { ENV_MAPPINGS = {
"provider": ("OPENCLAW_LLM_PROVIDER", "MOLTBOT_LLM_PROVIDER"), "provider": ("OPENCLAW_LLM_PROVIDER", "MOLTBOT_LLM_PROVIDER"),
@@ -92,12 +108,19 @@ ENV_MAPPINGS = {
# R14: Failover env vars # R14: Failover env vars
"fallback_models": ("OPENCLAW_FALLBACK_MODELS", "MOLTBOT_FALLBACK_MODELS"), "fallback_models": ("OPENCLAW_FALLBACK_MODELS", "MOLTBOT_FALLBACK_MODELS"),
"fallback_providers": ("OPENCLAW_FALLBACK_PROVIDERS", "MOLTBOT_FALLBACK_PROVIDERS"), "fallback_providers": ("OPENCLAW_FALLBACK_PROVIDERS", "MOLTBOT_FALLBACK_PROVIDERS"),
"max_failover_candidates": ( "max_failover_candidates": (
"OPENCLAW_MAX_FAILOVER_CANDIDATES", "OPENCLAW_MAX_FAILOVER_CANDIDATES",
"MOLTBOT_MAX_FAILOVER_CANDIDATES", "MOLTBOT_MAX_FAILOVER_CANDIDATES",
), ),
} }
SCHEDULER_ENV_MAPPINGS = {
"startup_jitter_sec": ("OPENCLAW_SCHEDULER_STARTUP_JITTER_SEC", ""),
"max_runs_per_tick": ("OPENCLAW_SCHEDULER_MAX_RUNS_PER_TICK", ""),
"skip_missed_intervals": ("OPENCLAW_SCHEDULER_SKIP_MISSED", ""),
}
def _clamp(value: int, min_val: int, max_val: int) -> int: def _clamp(value: int, min_val: int, max_val: int) -> int:
"""Clamp an integer to a range.""" """Clamp an integer to a range."""
@@ -172,6 +195,41 @@ def _env_flag(primary: str, legacy: str, default: bool = False) -> bool:
return str(v).strip().lower() in ("1", "true", "yes", "on") return str(v).strip().lower() in ("1", "true", "yes", "on")
def get_scheduler_config() -> Dict[str, Any]:
"""
Get effective Scheduler config (Env > Defaults).
Note: Scheduler config is currently not persisted to file (Env only).
"""
effective = {}
defaults = DEFAULTS["scheduler"]
for key in ALLOWED_SCHEDULER_KEYS:
# Check ENV
env_vars = SCHEDULER_ENV_MAPPINGS.get(key)
if env_vars:
primary, _ = env_vars
val = os.environ.get(primary)
if val is not None:
# Parse
if key == "skip_missed_intervals":
effective[key] = str(val).strip().lower() in ("1", "true", "yes", "on")
elif key in SCHEDULER_CONSTRAINTS:
try:
val_int = int(val)
effective[key] = _clamp(val_int, *SCHEDULER_CONSTRAINTS[key])
except ValueError:
effective[key] = defaults[key]
else:
effective[key] = val
continue
# Use default
effective[key] = defaults.get(key)
return effective
def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]: def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]:
""" """
Get effective LLM config with precedence: ENV > file > defaults. Get effective LLM config with precedence: ENV > file > defaults.
+72
View File
@@ -8,9 +8,11 @@ import hashlib
import logging import logging
import threading import threading
import time import time
import random
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Awaitable, Callable, Optional from typing import Awaitable, Callable, Optional
from ..runtime_config import get_scheduler_config
from .history import RunRecord, get_run_history from .history import RunRecord, get_run_history
from .models import Schedule, TriggerType from .models import Schedule, TriggerType
from .storage import get_schedule_store from .storage import get_schedule_store
@@ -172,6 +174,29 @@ class SchedulerRunner:
"""Main scheduler loop (runs in thread).""" """Main scheduler loop (runs in thread)."""
logger.debug("Scheduler loop started") logger.debug("Scheduler loop started")
# R34: Read config once at startup for jitter/skip behavior
config = get_scheduler_config()
# 1. Startup Jitter
jitter_sec = config.get("startup_jitter_sec", 0)
if jitter_sec > 0:
# Clamp to safe range just in case
jitter_sec = min(300, max(0, jitter_sec))
delay = random.uniform(0, jitter_sec)
logger.info(f"Startup jitter enabled: sleeping {delay:.2f}s")
# Wait with stop_event check to be interruptible
if self._stop_event.wait(timeout=delay):
logger.debug("Scheduler stopped during jitter wait")
return
# 2. Skip Missed Intervals
if config.get("skip_missed_intervals"):
logger.info("Skip Missed Intervals enabled: advancing cursors...")
try:
self._skip_missed_ticks()
except Exception as e:
logger.error(f"Failed to skip missed ticks: {e}")
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self._tick() self._tick()
@@ -183,10 +208,46 @@ class SchedulerRunner:
logger.debug("Scheduler loop exited") logger.debug("Scheduler loop exited")
def _skip_missed_ticks(self) -> None:
"""
Advance all due schedules to now without executing them.
Prevents backlog burst after downtime.
"""
now = datetime.now(timezone.utc)
now_ts = now.timestamp()
schedules = self._store.list_all()
skipped_count = 0
for schedule in schedules:
if not schedule.enabled:
continue
is_due = False
if schedule.trigger_type == TriggerType.CRON:
is_due = is_cron_due(schedule.cron_expr, schedule.last_tick_ts, now)
elif schedule.trigger_type == TriggerType.INTERVAL:
is_due = is_interval_due(
schedule.interval_sec, schedule.last_tick_ts, now_ts
)
if is_due:
# Update cursor without running
# Use a special run_id to indicate skip
schedule.update_cursor(now_ts, "skipped_startup")
self._store.update(schedule)
skipped_count += 1
if skipped_count > 0:
logger.info(f"Skipped {skipped_count} missed schedules due to startup policy.")
def _tick(self) -> None: def _tick(self) -> None:
"""Process one scheduler tick.""" """Process one scheduler tick."""
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
now_ts = now.timestamp() now_ts = now.timestamp()
# R34: Dynamic config read for runtime tuning
config = get_scheduler_config()
max_runs = config.get("max_runs_per_tick", 5)
schedules = self._store.list_all() schedules = self._store.list_all()
due_schedules = [] due_schedules = []
@@ -209,6 +270,17 @@ class SchedulerRunner:
if due_schedules: if due_schedules:
logger.debug(f"Found {len(due_schedules)} due schedules") logger.debug(f"Found {len(due_schedules)} due schedules")
# R34: Cap max runs per tick
if len(due_schedules) > max_runs:
logger.warning(
f"Throttling scheduler: {len(due_schedules)} due, "
f"capping to {max_runs} (max_runs_per_tick)."
)
# Sort by last_tick_ts to prioritize oldest starved schedules
# If last_tick_ts is None, treat as 0 (very old)
due_schedules.sort(key=lambda s: s.last_tick_ts or 0)
due_schedules = due_schedules[:max_runs]
for schedule in due_schedules: for schedule in due_schedules:
self._execute_schedule(schedule, now_ts) self._execute_schedule(schedule, now_ts)
+29
View File
@@ -0,0 +1,29 @@
import unittest
import sys
from unittest.mock import MagicMock, patch, AsyncMock
from connector.openclaw_client import OpenClawClient
from connector.config import ConnectorConfig
# Mock aiohttp
sys.modules["aiohttp"] = MagicMock()
class TestClientHeader(unittest.IsolatedAsyncioTestCase):
async def test_admin_header_present(self):
"""Verify X-OpenClaw-Admin-Token is set when config has token."""
config = ConnectorConfig()
config.admin_token = "my-secret-token"
client = OpenClawClient(config)
self.assertIn("X-OpenClaw-Admin-Token", client.headers)
self.assertEqual(client.headers["X-OpenClaw-Admin-Token"], "my-secret-token")
async def test_admin_header_absent(self):
"""Verify X-OpenClaw-Admin-Token is NOT set when token is empty."""
config = ConnectorConfig()
config.admin_token = ""
client = OpenClawClient(config)
self.assertNotIn("X-OpenClaw-Admin-Token", client.headers)
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
import unittest
from unittest.mock import MagicMock, AsyncMock, patch
import time
from connector.llm_client import LLMClient
class TestLLMClientF30(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.mock_client = MagicMock()
self.llm = LLMClient(self.mock_client)
@patch("connector.llm_client.time.time")
async def test_config_ttl(self, mock_time):
# 1. Initial fetch
mock_time.return_value = 1000
self.mock_client.get_openclaw_config = AsyncMock(return_value={
"ok": True,
"data": {"config": {"provider": "p1"}}
})
cfg1 = await self.llm._fetch_config()
self.assertEqual(cfg1["provider"], "p1")
self.assertEqual(self.mock_client.get_openclaw_config.call_count, 1)
# 2. Cached fetch (time < TTL)
mock_time.return_value = 1010 # +10s
cfg2 = await self.llm._fetch_config()
self.assertEqual(self.mock_client.get_openclaw_config.call_count, 1) # Still 1
# 3. Expired fetch (time > TTL)
mock_time.return_value = 1070 # +70s (>60s)
self.mock_client.get_openclaw_config.return_value = {
"ok": True,
"data": {"config": {"provider": "p2"}}
}
cfg3 = await self.llm._fetch_config()
self.assertEqual(cfg3["provider"], "p2")
self.assertEqual(self.mock_client.get_openclaw_config.call_count, 2) # Incremented
async def test_error_handling_401(self):
self.llm.is_configured = AsyncMock(return_value=True)
self.mock_client.chat_llm = AsyncMock(return_value={
"ok": False,
"error": "HTTP 401: Unauthorized"
})
resp = await self.llm.chat("sys", "user")
self.assertIn("API Key Invalid", resp)
self.assertIn("Settings", resp)
async def test_error_handling_429(self):
self.llm.is_configured = AsyncMock(return_value=True)
self.mock_client.chat_llm = AsyncMock(return_value={
"ok": False,
"error": "HTTP 429: Too Many Requests"
})
resp = await self.llm.chat("sys", "user")
self.assertIn("Rate Limit", resp)
self.assertIn("Quota Exceeded", resp)
async def test_generic_error(self):
self.llm.is_configured = AsyncMock(return_value=True)
self.mock_client.chat_llm = AsyncMock(return_value={
"ok": False,
"error": "Something went wrong"
})
resp = await self.llm.chat("sys", "user")
self.assertEqual(resp, "[LLM Error] Something went wrong")
if __name__ == "__main__":
unittest.main()
+69
View File
@@ -0,0 +1,69 @@
import unittest
from unittest.mock import MagicMock, AsyncMock
from connector.router import CommandRouter
from connector.config import ConnectorConfig
from connector.contract import CommandRequest, CommandResponse
class TestRouterAdminEnforcement(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.config = ConnectorConfig()
# Setup admin user
self.config.admin_users = {"12345"}
# IMPORTANT: Initially unset admin_token to test failure
self.config.admin_token = ""
self.client = MagicMock()
self.client.interrupt_output = AsyncMock(return_value={"ok": True})
self.client.get_approvals = AsyncMock(return_value={"ok": True, "items": []})
self.client.approve_request = AsyncMock(return_value={"ok": True})
self.router = CommandRouter(self.config, self.client)
async def test_admin_commands_fail_without_token(self):
"""Verify admin commands fail fast when token is missing."""
req = CommandRequest(
platform="telegram",
channel_id="100",
sender_id="12345", # is admin
username="tester",
message_id="msg1",
text="", # set in loop
timestamp=123.456,
)
test_commands = [
"/stop",
"/approvals",
"/approve 123",
"/reject 123",
"/schedules",
"/schedule run 1",
"/trace 123"
]
for cmd in test_commands:
req.text = cmd
res = await self.router.handle(req)
self.assertIn(
"[Error] Admin token not configured",
res.text,
f"Command '{cmd}' should fail with config error"
)
async def test_admin_commands_succeed_with_token(self):
"""Verify admin commands proceed when token is set."""
self.config.admin_token = "secret-token"
req = CommandRequest(
platform="telegram",
channel_id="100",
sender_id="12345", # is admin
username="tester",
message_id="msg2",
text="/stop",
timestamp=123.456,
)
res = await self.router.handle(req)
self.assertIn("[Stop] Global Interrupt", res.text)
self.client.interrupt_output.assert_called_once()
+178
View File
@@ -0,0 +1,178 @@
import io
import json
import os
import shutil
import tempfile
import unittest
import zipfile
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from api.packs import CleanupFileResponse, PacksHandlers
from services.packs.pack_archive import PackArchive, PackError
from services.packs.pack_manifest import create_manifest
class MockMultipartReader:
def __init__(self, field):
self._field = field
self._returned = False
async def next(self):
if self._returned:
return None
self._returned = True
return self._field
class MockRequest:
def __init__(self, reader: MockMultipartReader | None = None):
self.match_info = {}
self.query = {}
self._reader = reader or MockMultipartReader(MockField("pack.zip", b""))
async def multipart(self):
return self._reader
class MockField:
def __init__(self, filename, content):
self.name = "file"
self.filename = filename
self.content = content
self._chunk_gen = self._chunks()
def _chunks(self):
yield self.content
yield b""
async def read_chunk(self):
try:
return next(self._chunk_gen)
except StopIteration:
return b""
class TestPacksIntegrity(unittest.TestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.packs_dir = os.path.join(self.test_dir, "packs")
os.makedirs(self.packs_dir)
def tearDown(self):
shutil.rmtree(self.test_dir)
def test_zip_traversal_protection(self):
"""Ensure paths with '..' or absolute paths are rejected."""
# Create a malicious zip in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("../evil.txt", "attack")
zip_buffer.seek(0)
# Write to file
zip_path = os.path.join(self.test_dir, "malicious.zip")
with open(zip_path, "wb") as f:
f.write(zip_buffer.getvalue())
# Verify extraction raises PackError
with self.assertRaises(PackError) as cm:
PackArchive.extract_pack(zip_path, self.packs_dir)
self.assertIn("Unsafe filename", str(cm.exception))
def test_zip_bomb_protection(self):
"""Ensure high compression ratio is rejected."""
# Create a "bomb" (highly compressible)
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
# 1MB of zeros compresses to very little
zf.writestr("bomb.txt", b"0" * (1024 * 1024))
zip_buffer.seek(0)
zip_path = os.path.join(self.test_dir, "bomb.zip")
with open(zip_path, "wb") as f:
f.write(zip_buffer.getvalue())
# Mock MAX_COMPRESSION_RATIO just for this test to be strict
# The default is 100. 1MB of 0s might compress to ~1KB -> ratio 1000.
try:
PackArchive.extract_pack(zip_path, self.packs_dir)
self.fail("Should have raised PackError for zip bomb")
except PackError as e:
self.assertIn("Compression ratio too high", str(e))
def test_deterministic_manifest(self):
"""Ensure create_manifest produces sorted, deterministic output."""
src_dir = os.path.join(self.test_dir, "src")
os.makedirs(src_dir)
# Create files in random order
with open(os.path.join(src_dir, "b.txt"), "w") as f:
f.write("b")
with open(os.path.join(src_dir, "a.txt"), "w") as f:
f.write("a")
metadata = {"name": "test", "version": "1.0.0", "type": "template", "author": "me"}
manifest_path = create_manifest(src_dir, metadata)
with open(manifest_path, "r") as f:
content = json.load(f)
# Check files are sorted by path
self.assertEqual(content["files"][0]["path"], "a.txt")
self.assertEqual(content["files"][1]["path"], "b.txt")
# Check content is identical if run again
manifest_path_2 = create_manifest(src_dir, metadata)
with open(manifest_path, "rb") as f1, open(manifest_path_2, "rb") as f2:
self.assertEqual(f1.read(), f2.read())
class TestPacksApiAsync(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.packs_dir = os.path.join(self.test_dir, "packs")
os.makedirs(self.packs_dir)
def tearDown(self):
shutil.rmtree(self.test_dir)
@patch("api.packs.web")
async def test_api_import_flow(self, mock_web):
"""Test import handler flow (mocking aiohttp)."""
# Setup handlers
handlers = PacksHandlers(self.packs_dir)
# Mock auth
handlers._check_auth = AsyncMock(return_value=True)
handlers.registry.install_pack = MagicMock(return_value={"name": "mypack", "version": "1.0.0"})
field = MockField("pack.zip", b"dummy_content")
request = MockRequest(reader=MockMultipartReader(field))
response = await handlers.import_pack_handler(request)
self.assertIsNotNone(response)
mock_web.json_response.assert_called_with(
{"ok": True, "pack": {"name": "mypack", "version": "1.0.0"}}
)
handlers.registry.install_pack.assert_called_once()
async def test_cleanup_file_response(self):
"""Test CleanupFileResponse deletes file."""
# Create temp file
fd, path = tempfile.mkstemp()
os.close(fd)
resp = CleanupFileResponse(path)
# Call prepare (MockWeb.FileResponse.prepare is a no-op; cleanup happens in finally)
await resp.prepare(MagicMock())
# Check existence
self.assertFalse(os.path.exists(path), "File should be deleted")
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
import unittest
from unittest.mock import MagicMock, patch, ANY
import threading
import time
from datetime import datetime, timezone
# We need to import the class from the module
# Adjust import to match project structure
# services.scheduler.runner is likely importable if running from root
from services.scheduler.runner import SchedulerRunner
from services.scheduler.models import Schedule, TriggerType
from services.scheduler.storage import ScheduleStore
class TestSchedulerR34(unittest.TestCase):
def setUp(self):
# Mock dependencies patches
self.store_patcher = patch("services.scheduler.runner.get_schedule_store")
self.mock_get_store = self.store_patcher.start()
self.mock_store = MagicMock(spec=ScheduleStore)
self.mock_get_store.return_value = self.mock_store
self.config_patcher = patch("services.scheduler.runner.get_scheduler_config")
self.mock_get_config = self.config_patcher.start()
self.mock_get_config.return_value = {} # Default
self.runner = SchedulerRunner(submit_fn=MagicMock(), tick_interval=0.1)
# Prevent actual thread start in tests unless needed
self.runner._stop_event = MagicMock() # Mock the event to hijack wait
def tearDown(self):
self.store_patcher.stop()
self.config_patcher.stop()
def test_startup_jitter(self):
"""Test startup jitter logic in _run_loop."""
# Setup config
self.mock_get_config.return_value = {"startup_jitter_sec": 10}
# We want to verify `_stop_event.wait` is called with a random float <= 10
# and then loop breaks.
# To break loop: stop_event.is_set() -> True
self.runner._stop_event.is_set.side_effect = [False, True] # Run once then stop
# Mock wait to return False (timeout didn't happen, or did, doesn't matter for first call)
self.runner._stop_event.wait.return_value = False
# Mock random
with patch("services.scheduler.runner.random.uniform") as mock_uniform:
mock_uniform.return_value = 5.5
# Mock _tick to avoid logic error
self.runner._tick = MagicMock()
self.runner._run_loop()
# Assert random called
mock_uniform.assert_called_with(0, 10)
# Assert wait called with delay
# First call should be the jitter wait
# Second call would be tick interval wait
# We check the call args list
calls = self.runner._stop_event.wait.call_args_list
self.assertGreaterEqual(len(calls), 1)
self.assertEqual(calls[0].kwargs.get('timeout'), 5.5)
def test_max_runs_per_tick(self):
"""Test execution capping."""
self.mock_get_config.return_value = {"max_runs_per_tick": 2} # Low capacity
# Setup 5 due schedules
schedules = []
for i in range(5):
s = MagicMock(spec=Schedule)
s.enabled = True
s.trigger_type = TriggerType.INTERVAL
s.interval_sec = 1
s.last_tick_ts = 100 + i # Vary timestamps to test sorting if applicable
schedules.append(s)
self.mock_store.list_all.return_value = schedules
# Mock is_interval_due to return True
with patch("services.scheduler.runner.is_interval_due", return_value=True):
# Mock execute
self.runner._execute_schedule = MagicMock()
self.runner._tick()
# Should be capped at 2
self.assertEqual(self.runner._execute_schedule.call_count, 2)
# R34 says: "Sort by last_tick_ts found ... due_schedules.sort(key=lambda s: s.last_tick_ts or 0)"
# We set last_tick_ts=100..104. Ascending order means 100 and 101 should run.
# Check call args to verify which ones ran
executed_schedules = [call.args[0] for call in self.runner._execute_schedule.call_args_list]
self.assertEqual(len(executed_schedules), 2)
# Verify priority (100 is oldest timestamp)
# Note: 100 < 101. So 100 is "oldest execution" or "oldest successful run"?
# Actually last_tick_ts is execution time. Smallest = ran longest ago = starved.
# Correct.
self.assertEqual(executed_schedules[0].last_tick_ts, 100)
self.assertEqual(executed_schedules[1].last_tick_ts, 101)
def test_skip_missed_intervals_on_startup(self):
"""Test skip logic."""
# Logic is in _skip_missed_ticks
# We simulate it being called (by _run_loop if config set)
s = MagicMock(spec=Schedule)
s.enabled = True
s.trigger_type = TriggerType.INTERVAL
s.interval_sec = 1
s.last_tick_ts = 0 # old
self.mock_store.list_all.return_value = [s]
# Mock is_interval_due -> True
with patch("services.scheduler.runner.is_interval_due", return_value=True):
self.runner._skip_missed_ticks()
# Verify cursor update
s.update_cursor.assert_called_with(ANY, "skipped_startup")
self.mock_store.update.assert_called_with(s)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -17,6 +17,7 @@ import { RefinerTab } from "./tabs/refiner_tab.js";
import { LibraryTab } from "./tabs/library_tab.js"; import { LibraryTab } from "./tabs/library_tab.js";
import { ApprovalsTab } from "./tabs/approvals_tab.js"; import { ApprovalsTab } from "./tabs/approvals_tab.js";
import { ExplorerTab } from "./tabs/explorer_tab.js"; import { ExplorerTab } from "./tabs/explorer_tab.js";
import { PacksTab } from "./tabs/packs_tab.js";
@@ -106,6 +107,7 @@ async function registerSupportedTabs() {
if (fallbackShowAll || features.explorer || features.preflight || features.checkpoints) { if (fallbackShowAll || features.explorer || features.preflight || features.checkpoints) {
tabManager.registerTab(ExplorerTab); // Explorer: inventory + preflight + snapshots tabManager.registerTab(ExplorerTab); // Explorer: inventory + preflight + snapshots
} }
if (fallbackShowAll || features.packs) tabManager.registerTab(PacksTab);
console.log("[OpenClaw] Tabs registered based on capabilities:", Object.keys(tabManager.tabs).length); console.log("[OpenClaw] Tabs registered based on capabilities:", Object.keys(tabManager.tabs).length);
} }
+187
View File
@@ -0,0 +1,187 @@
import { moltbotApi } from "../openclaw_api.js";
import { showError, clearError } from "../openclaw_utils.js";
// Helper for safe HTML escaping
function escapeHtml(text) {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
export const PacksTab = {
id: "packs",
title: "Packs",
icon: "pi pi-box",
render(container) {
// --- 1. Static Layout ---
container.innerHTML = `
<div class="moltbot-panel">
<div class="moltbot-card" style="border-radius:0; border:none; border-bottom:1px solid var(--moltbot-color-border);">
<div class="moltbot-section-header">Asset Packs</div>
<div class="moltbot-error-box" style="display:none"></div>
<div class="moltbot-toolbar" style="margin-top:5px; display:flex; gap:5px; align-items:center;" id="pack-toolbar">
<input type="file" id="pack-import-input" accept=".zip" style="display:none">
<button class="moltbot-btn moltbot-btn-primary" id="pack-import-btn">Import Pack</button>
<button class="moltbot-btn moltbot-btn-sm" id="pack-refresh-btn" style="margin-left: auto;">
Refresh
</button>
</div>
</div>
<div id="pack-list" class="moltbot-scroll-area" style="padding:10px;">
<div class="moltbot-empty-state">Loading...</div>
</div>
</div>
`;
// --- 2. State & References ---
const ui = {
list: container.querySelector("#pack-list"),
importBtn: container.querySelector("#pack-import-btn"),
importInput: container.querySelector("#pack-import-input"),
refreshBtn: container.querySelector("#pack-refresh-btn"),
};
// --- 3. View Logic ---
const renderListItem = (pack) => {
return `
<div class="moltbot-card" style="margin-bottom: 10px; display: flex; justify-content: space-between; align-items: start;">
<div>
<div style="font-weight: bold; font-size: var(--moltbot-font-md); color: var(--moltbot-color-fg);">
${escapeHtml(pack.name)} <span style="font-weight:normal; color:var(--moltbot-color-fg-muted);">v${escapeHtml(pack.version)}</span>
</div>
<div style="font-size: var(--moltbot-font-sm); color: var(--moltbot-color-fg-muted); margin-top: 4px;">
${escapeHtml(pack.description || "No description")}
</div>
<div style="font-size: var(--moltbot-font-xs); color: #666; margin-top: 4px;">
Author: ${escapeHtml(pack.author || "Unknown")} • Type: ${escapeHtml(pack.type)}
</div>
</div>
<div style="display: flex; gap: 5px; flex-direction: column; align-items: flex-end;">
<button class="moltbot-btn moltbot-btn-sm" data-action="export" data-name="${escapeHtml(pack.name)}" data-version="${escapeHtml(pack.version)}">Export</button>
<button class="moltbot-btn moltbot-btn-sm moltbot-btn-danger" data-action="delete" data-name="${escapeHtml(pack.name)}" data-version="${escapeHtml(pack.version)}">Uninstall</button>
</div>
</div>
`;
};
const renderList = (packs) => {
if (!packs || packs.length === 0) {
ui.list.innerHTML = '<div class="moltbot-empty-state">No packs installed.</div>';
return;
}
ui.list.innerHTML = packs.map(renderListItem).join("");
};
// --- 4. Logic ---
const loadPacks = async () => {
clearError(container);
ui.list.innerHTML = '<div style="padding: 10px; text-align: center;">Loading...</div>';
try {
const res = await moltbotApi.getPacks();
if (res.ok) {
renderList(res.data.packs || []);
} else {
ui.list.innerHTML = '';
showError(container, res.error);
}
} catch (e) {
ui.list.innerHTML = '';
showError(container, "Failed to load packs: " + e.message);
}
};
const handleImport = async (file) => {
if (!file) return;
const confirmOverwrite = confirm("Importing pack. Overwrite existing versions if present?");
ui.importBtn.disabled = true;
ui.importBtn.textContent = "Importing...";
try {
const res = await moltbotApi.importPack(file, confirmOverwrite);
if (res.ok) {
alert(`Pack ${res.data.pack.name} v${res.data.pack.version} installed successfully.`);
loadPacks();
} else {
showError(container, `Import failed: ${res.error}`);
}
} catch (e) {
showError(container, `Import failed: ${e.message}`);
} finally {
ui.importBtn.disabled = false;
ui.importBtn.textContent = "Import Pack";
ui.importInput.value = ""; // Reset
}
};
const handleExport = async (name, version) => {
// Trigger download via API client helper (which handles headers/blob)
// Or use createObjectURL
try {
const res = await moltbotApi.exportPack(name, version);
if (res.ok) {
// Create object URL and click
const url = window.URL.createObjectURL(res.data);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
// Filename is usually in Content-Disposition but we can construct one
a.download = `${name}-${version}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
showError(container, `Export failed: ${res.error}`);
}
} catch (e) {
showError(container, `Export failed: ${e.message}`);
}
};
const handleDelete = async (name, version) => {
if (!confirm(`Uninstall pack ${name} v${version}? This cannot be undone.`)) return;
try {
const res = await moltbotApi.deletePack(name, version);
if (res.ok) {
loadPacks();
} else {
showError(container, `Uninstall failed: ${res.error}`);
}
} catch (e) {
showError(container, `Uninstall failed: ${e.message}`);
}
};
// --- 5. Event Binding ---
ui.refreshBtn.onclick = loadPacks;
ui.importBtn.onclick = () => ui.importInput.click();
ui.importInput.onchange = (e) => handleImport(e.target.files[0]);
ui.list.onclick = (e) => {
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const action = btn.dataset.action;
const name = btn.dataset.name;
const version = btn.dataset.version;
if (action === "export") handleExport(name, version);
else if (action === "delete") handleDelete(name, version);
};
// Initial Load
loadPacks();
}
};