From b1c6426425a4b9f2a0946d007cbac93872e0dc20 Mon Sep 17 00:00:00 2001 From: rookiestar28 <151893693+rookiestar28@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:43:23 +0800 Subject: [PATCH] finalize Asset Packs integrity + API surface with CI-ready verification --- .github/workflows/ci.yml | 13 +- .nvmrc | 1 + README.md | 28 ++- __init__.py | 1 + api/packs.py | 250 +++++++++++++++++-------- connector/__main__.py | 6 + connector/llm_client.py | 41 +++- connector/router.py | 50 ++++- package.json | 5 +- services/packs/pack_archive.py | 36 +++- services/packs/pack_manifest.py | 45 +++++ services/runtime_config.py | 60 +++++- services/scheduler/runner.py | 72 +++++++ tests/connector/test_client_header.py | 29 +++ tests/connector/test_llm_client_f30.py | 73 ++++++++ tests/connector/test_router_admin.py | 69 +++++++ tests/test_packs_integrity.py | 178 ++++++++++++++++++ tests/test_scheduler_r34.py | 128 +++++++++++++ web/openclaw.js | 2 + web/tabs/packs_tab.js | 187 ++++++++++++++++++ 20 files changed, 1170 insertions(+), 104 deletions(-) create mode 100644 .nvmrc create mode 100644 tests/connector/test_client_header.py create mode 100644 tests/connector/test_llm_client_f30.py create mode 100644 tests/connector/test_router_admin.py create mode 100644 tests/test_packs_integrity.py create mode 100644 tests/test_scheduler_r34.py create mode 100644 web/tabs/packs_tab.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 544e614..d2045d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,9 +71,12 @@ jobs: python -m pip install --upgrade pip python -m pip install numpy pillow - name: Run unit tests + env: MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit run: | + python -m unittest discover -s tests -p "test_*.py" -v + security-audit: name: Security Audit (S23) runs-on: ubuntu-latest @@ -85,9 +88,7 @@ jobs: - name: Frontend Audit (npm) run: | # Audit only production dependencies, ignore dev - npm audit --production || true - # Note: || true because audit often fails on harmless things. - # In strict mode, remove || true or configure exceptions. + npm audit --production - uses: actions/setup-python@v5 with: @@ -95,8 +96,4 @@ jobs: - name: Install pip-audit run: pip install pip-audit - name: Backend Audit (pip) - run: | - pip install -r requirements.txt || true - # Scan environment - pip-audit || true - # Again, allowing failure for now to avoid blocking CI on minor findings. + run: pip-audit diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/README.md b/README.md index e310023..277c28b 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # 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) - **A built-in extension UI** (`OpenClaw` panel) - **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets) - 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. - -If you want a “personal assistant OS,” openclaw makes a lot of sense. -If you want **“ComfyUI as a reliable automation target,”** ComfyUI-OpenClaw feels more direct. +- Localhost-first defaults; remote access is opt-in +- Explicit **Admin Token** boundary for write actions +- Webhooks are **deny-by-default** until auth is configured +- 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}` - `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) Sidecar bridge routes are registered under `/openclaw/bridge/*` and `/moltbot/bridge/*`. diff --git a/__init__.py b/__init__.py index 89adc14..0ffd7f0 100644 --- a/__init__.py +++ b/__init__.py @@ -55,6 +55,7 @@ def _register_routes_once(): """Register all Moltbot routes including Bridge and Scheduler.""" from .api.approvals import register_approval_routes from .api.bridge import BridgeHandlers + from .api.presets import register_preset_routes from .api.routes import register_routes from .api.schedules import register_schedule_routes diff --git a/api/packs.py b/api/packs.py index b138b5c..41c2f8e 100644 --- a/api/packs.py +++ b/api/packs.py @@ -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 shutil 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 -from ..services.packs.pack_registry import PackRegistry + +if web: + 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: @@ -13,30 +68,50 @@ class PacksHandlers: self.registry = PackRegistry(state_dir) async def list_packs_handler(self, request: web.Request) -> web.Response: - """GET /moltbot/packs""" - allowed, error = require_admin_token(request) - if not allowed: - return web.json_response({"ok": False, "error": error}, status=403) + """GET /packs - List installed packs.""" + if getattr(web, "_IS_MOCKWEB", False) is True: + raise RuntimeError("aiohttp not available") + + # 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() - return web.json_response({"ok": True, "packs": packs}) + try: + 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: - """POST /moltbot/packs/import (multipart/form-data)""" - allowed, error = require_admin_token(request) - if not allowed: - return web.json_response({"ok": False, "error": error}, status=403) + """POST /packs/import - Install pack from zip upload.""" + if getattr(web, "_IS_MOCKWEB", False) is True: + raise RuntimeError("aiohttp not available") + if not await self._check_auth(request): + return web.json_response({"ok": False, "error": "Unauthorized"}, status=401) + + # Multipart reader reader = await request.multipart() field = await reader.next() - if not field or field.name != "file": - return web.json_response({"ok": False, "error": "missing_file"}, status=400) - - # Write upload to temp file + return web.json_response({"ok": False, "error": "Missing file field"}, status=400) + + filename = field.filename or "pack.zip" + + # Save to temp file fd, temp_path = tempfile.mkstemp(suffix=".zip") os.close(fd) - + try: with open(temp_path, "wb") as f: while True: @@ -44,75 +119,94 @@ class PacksHandlers: if not chunk: break f.write(chunk) - - # Install - overwrite = request.query.get("overwrite", "").lower() == "true" - meta = self.registry.install_pack(temp_path, overwrite=overwrite) - - return web.json_response({"ok": True, "pack": meta}) - - except PackError as e: - return web.json_response({"ok": False, "error": str(e)}, status=400) + + overwrite = request.query.get("overwrite", "false").lower() == "true" + + try: + meta = self.registry.install_pack(temp_path, overwrite=overwrite) + return web.json_response({"ok": True, "pack": meta}) + except PackError as e: + return web.json_response({"ok": False, "error": str(e)}, status=400) + except Exception as e: - return web.json_response( - {"ok": False, "error": f"Internal error: {str(e)}"}, status=500 - ) + return web.json_response({"ok": False, "error": str(e)}, status=500) finally: if os.path.exists(temp_path): os.remove(temp_path) - async def export_pack_handler(self, request: web.Request) -> web.Response: - """GET /moltbot/packs/export/{name}/{version}""" - allowed, error = require_admin_token(request) - if not allowed: - return web.json_response({"ok": False, "error": error}, status=403) + async def delete_pack_handler(self, request: web.Request) -> web.Response: + """DELETE /packs/{name}/{version} - Uninstall pack.""" + if getattr(web, "_IS_MOCKWEB", False) is True: + raise RuntimeError("aiohttp not available") + + 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") - - pack_dir = self.registry.get_pack_path(name, version) - if not pack_dir: - 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) - + + if not name or not version: + return web.json_response({"ok": False, "error": "Missing name/version"}, status=400) + try: - PackArchive.create_pack_archive(pack_dir, temp_path) - - # Serve file - return web.FileResponse( - temp_path, - 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. + success = self.registry.uninstall_pack(name, version) + if success: + return web.json_response({"ok": True}) + else: + return web.json_response({"ok": False, "error": "Not found"}, status=404) 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) - async def delete_pack_handler(self, request: web.Request) -> web.Response: - """DELETE /moltbot/packs/{name}/{version}""" - allowed, error = require_admin_token(request) - if not allowed: - return web.json_response({"ok": False, "error": error}, status=403) + async def export_pack_handler(self, request: web.Request) -> web.Response: + """GET /packs/export/{name}/{version} - Download pack zip.""" + if getattr(web, "_IS_MOCKWEB", False) is True: + raise RuntimeError("aiohttp not available") + + 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") - version = request.match_info.get("version") - - success = self.registry.uninstall_pack(name, version) - if success: - return web.json_response({"ok": True}) - else: - return web.json_response( - {"ok": False, "error": "pack_not_found"}, status=404 - ) + async def _check_auth(self, request: web.Request) -> bool: + # Re-use require_admin_token logic from access_control? + # require_admin_token(request) returns (allowed, error_msg) + # Wait, require_admin_token in access_control might depend on config. + # Let's import it. + try: + allowed, _ = require_admin_token(request) + return allowed + except Exception: + return False diff --git a/connector/__main__.py b/connector/__main__.py index fd4d393..d64e548 100644 --- a/connector/__main__.py +++ b/connector/__main__.py @@ -52,6 +52,12 @@ def _print_security_banner(config): "⚠️ 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(): logger.info("Initializing OpenClaw Connector (Phase 5)...") diff --git a/connector/llm_client.py b/connector/llm_client.py index ad3f75f..30bd234 100644 --- a/connector/llm_client.py +++ b/connector/llm_client.py @@ -9,6 +9,7 @@ Privacy: """ import logging +import time from typing import Dict, List, Optional logger = logging.getLogger(__name__) @@ -24,6 +25,8 @@ class LLMClient: - Never auto-executes commands. """ + CONFIG_TTL = 60 # seconds + def __init__(self, openclaw_client): """ Initialize with OpenClawClient to fetch settings from backend. @@ -33,11 +36,13 @@ class LLMClient: """ self._client = openclaw_client self._config_cache = None + self._last_fetch = 0 self._configured = None async def _fetch_config(self) -> dict: - """Fetch LLM config from OpenClaw backend.""" - if self._config_cache is not None: + """Fetch LLM config from OpenClaw backend (with TTL).""" + now = time.time() + if self._config_cache is not None and (now - self._last_fetch < self.CONFIG_TTL): return self._config_cache res = await self._client.get_openclaw_config() @@ -49,14 +54,25 @@ class LLMClient: self._config_cache = data.get("config", {}) 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: - self._config_cache = {} + # On failure, keep old cache if available (resilience) + if self._config_cache is None: + self._config_cache = {} + return self._config_cache async def is_configured(self) -> bool: """Check if LLM is properly configured in OpenClaw settings.""" 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() provider = config.get("provider") @@ -103,11 +119,22 @@ class LLMClient: if res.get("ok"): data = res.get("text") or res.get("data", {}).get("text") 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: # Log error without user content - logger.error("LLM request failed") - return "[LLM Error] Request failed. Please try again." + logger.error("LLM request failed", exc_info=True) + return "[LLM Error] Request failed. Please check logs." def _get_default_base_url(self, provider: str) -> str: """Get default base URL for provider (matches OpenClaw catalog).""" diff --git a/connector/router.py b/connector/router.py index b5205da..5dd53df 100644 --- a/connector/router.py +++ b/connector/router.py @@ -5,7 +5,7 @@ Dispatches parsed commands to handlers with AST argument parsing. import logging import shlex -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from .config import ConnectorConfig from .contract import CommandRequest, CommandResponse @@ -234,6 +234,17 @@ class CommandRouter: + "\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( self, req: CommandRequest, args: List[str] ) -> CommandResponse: @@ -302,6 +313,14 @@ class CommandRouter: msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}" if "expires_at" in data: 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) else: prompt_id = data.get("prompt_id", "unknown") @@ -356,6 +375,10 @@ class CommandRouter: async def _handle_interrupt( self, req: CommandRequest, args: List[str] ) -> CommandResponse: + # F32 WP3: Guard + if err := self._require_admin_token_configured(): + return err + # Remediation: Global Interrupt res = await self.client.interrupt_output() if res.get("ok"): @@ -366,6 +389,10 @@ class CommandRouter: async def _handle_approvals_list( self, req: CommandRequest, args: List[str] ) -> CommandResponse: + # F32 WP3: Guard + if err := self._require_admin_token_configured(): + return err + res = await self.client.get_approvals() if not res.get("ok"): return CommandResponse( @@ -405,6 +432,10 @@ class CommandRouter: if not args: return CommandResponse(text="Usage: /approve ") + # F32 WP3: Guard + if err := self._require_admin_token_configured(): + return err + # Assuming auto_execute=True by default for chat logic res = await self.client.approve_request(args[0], auto_execute=True) if not res.get("ok"): @@ -434,6 +465,10 @@ class CommandRouter: if not args: return CommandResponse(text="Usage: /reject [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" res = await self.client.reject_request(args[0], reason) if not res.get("ok"): @@ -444,6 +479,10 @@ class CommandRouter: async def _handle_schedules_list( self, req: CommandRequest, args: List[str] ) -> CommandResponse: + # F32 WP3: Guard + if err := self._require_admin_token_configured(): + return err + res = await self.client.get_schedules() if not res.get("ok"): return CommandResponse(text=f"[Error] {res.get('error')}") @@ -467,6 +506,10 @@ class CommandRouter: if len(args) < 2: return CommandResponse(text="Usage: /schedule ") + # F32 WP3: Guard + if err := self._require_admin_token_configured(): + return err + sub = args[0].lower() sid = args[1] @@ -519,6 +562,11 @@ class CommandRouter: ) -> CommandResponse: if not args: return CommandResponse(text="Usage: /trace ") + + # F32 WP3: Guard + if err := self._require_admin_token_configured(): + return err + res = await self.client.get_trace(args[0]) if not res.get("ok"): return CommandResponse(text=f"[Error] {res.get('error')}") diff --git a/package.json b/package.json index 05f6f42..446cc2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "comfyui-openclaw", "private": true, + "engines": { + "node": ">=20.0.0" + }, "devDependencies": { "@playwright/test": "^1.50.0" }, @@ -11,4 +14,4 @@ "test:debug": "npx playwright test --debug", "test:report": "npx playwright show-report" } -} +} \ No newline at end of file diff --git a/services/packs/pack_archive.py b/services/packs/pack_archive.py index 01ace41..59c64de 100644 --- a/services/packs/pack_archive.py +++ b/services/packs/pack_archive.py @@ -14,6 +14,8 @@ from .pack_manifest import ( ) from .pack_types import PackMetadata +MAX_COMPRESSION_RATIO = 100 + class PackArchive: @staticmethod @@ -49,12 +51,20 @@ class PackArchive: if total_size > MAX_FILE_SIZE_MB * 1024 * 1024: 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 for info in infos: if ( info.filename.startswith("/") 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}") @@ -118,8 +128,32 @@ class PackArchive: raise PackError("Source directory does not exist") with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf: + files_to_add = [] for root, _, files in os.walk(source_dir): for file in files: full_path = os.path.join(root, file) 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()) diff --git a/services/packs/pack_manifest.py b/services/packs/pack_manifest.py index f0ec38e..65e25d4 100644 --- a/services/packs/pack_manifest.py +++ b/services/packs/pack_manifest.py @@ -90,3 +90,48 @@ def validate_manifest_integrity(base_dir: str, manifest: PackManifest) -> List[s errors.append(f"Error reading {rel_path}: {str(e)}") 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 diff --git a/services/runtime_config.py b/services/runtime_config.py index f776518..2a16acd 100644 --- a/services/runtime_config.py +++ b/services/runtime_config.py @@ -60,6 +60,12 @@ ALLOWED_LLM_KEYS = { "max_failover_candidates", } +ALLOWED_SCHEDULER_KEYS = { + "startup_jitter_sec", + "max_runs_per_tick", + "skip_missed_intervals", +} + # Default values DEFAULTS = { "llm": { @@ -72,7 +78,12 @@ DEFAULTS = { "fallback_models": [], "fallback_providers": [], "max_failover_candidates": 3, - } + }, + "scheduler": { + "startup_jitter_sec": 30, + "max_runs_per_tick": 5, + "skip_missed_intervals": False, + }, } # Value constraints @@ -82,6 +93,11 @@ CONSTRAINTS = { "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) ENV_MAPPINGS = { "provider": ("OPENCLAW_LLM_PROVIDER", "MOLTBOT_LLM_PROVIDER"), @@ -92,12 +108,19 @@ ENV_MAPPINGS = { # R14: Failover env vars "fallback_models": ("OPENCLAW_FALLBACK_MODELS", "MOLTBOT_FALLBACK_MODELS"), "fallback_providers": ("OPENCLAW_FALLBACK_PROVIDERS", "MOLTBOT_FALLBACK_PROVIDERS"), + "max_failover_candidates": ( "OPENCLAW_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: """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") +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]]: """ Get effective LLM config with precedence: ENV > file > defaults. diff --git a/services/scheduler/runner.py b/services/scheduler/runner.py index ca00b36..81aaba1 100644 --- a/services/scheduler/runner.py +++ b/services/scheduler/runner.py @@ -8,9 +8,11 @@ import hashlib import logging import threading import time +import random from datetime import datetime, timezone from typing import Awaitable, Callable, Optional +from ..runtime_config import get_scheduler_config from .history import RunRecord, get_run_history from .models import Schedule, TriggerType from .storage import get_schedule_store @@ -172,6 +174,29 @@ class SchedulerRunner: """Main scheduler loop (runs in thread).""" 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(): try: self._tick() @@ -183,10 +208,46 @@ class SchedulerRunner: 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: """Process one scheduler tick.""" now = datetime.now(timezone.utc) 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() due_schedules = [] @@ -209,6 +270,17 @@ class SchedulerRunner: if 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: self._execute_schedule(schedule, now_ts) diff --git a/tests/connector/test_client_header.py b/tests/connector/test_client_header.py new file mode 100644 index 0000000..fd28b1f --- /dev/null +++ b/tests/connector/test_client_header.py @@ -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() diff --git a/tests/connector/test_llm_client_f30.py b/tests/connector/test_llm_client_f30.py new file mode 100644 index 0000000..407d6e2 --- /dev/null +++ b/tests/connector/test_llm_client_f30.py @@ -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() diff --git a/tests/connector/test_router_admin.py b/tests/connector/test_router_admin.py new file mode 100644 index 0000000..b4d9535 --- /dev/null +++ b/tests/connector/test_router_admin.py @@ -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() diff --git a/tests/test_packs_integrity.py b/tests/test_packs_integrity.py new file mode 100644 index 0000000..af94fb2 --- /dev/null +++ b/tests/test_packs_integrity.py @@ -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() diff --git a/tests/test_scheduler_r34.py b/tests/test_scheduler_r34.py new file mode 100644 index 0000000..bbe628a --- /dev/null +++ b/tests/test_scheduler_r34.py @@ -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() diff --git a/web/openclaw.js b/web/openclaw.js index 5e98bec..4ee5cc6 100644 --- a/web/openclaw.js +++ b/web/openclaw.js @@ -17,6 +17,7 @@ import { RefinerTab } from "./tabs/refiner_tab.js"; import { LibraryTab } from "./tabs/library_tab.js"; import { ApprovalsTab } from "./tabs/approvals_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) { 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); } diff --git a/web/tabs/packs_tab.js b/web/tabs/packs_tab.js new file mode 100644 index 0000000..3d7b2fe --- /dev/null +++ b/web/tabs/packs_tab.js @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export const PacksTab = { + id: "packs", + title: "Packs", + icon: "pi pi-box", + + render(container) { + // --- 1. Static Layout --- + container.innerHTML = ` +
+
+
Asset Packs
+ +
+ + + +
+
+ +
+
Loading...
+
+
+ `; + + // --- 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 ` +
+
+
+ ${escapeHtml(pack.name)} v${escapeHtml(pack.version)} +
+
+ ${escapeHtml(pack.description || "No description")} +
+
+ Author: ${escapeHtml(pack.author || "Unknown")} • Type: ${escapeHtml(pack.type)} +
+
+
+ + +
+
+ `; + }; + + const renderList = (packs) => { + if (!packs || packs.length === 0) { + ui.list.innerHTML = '
No packs installed.
'; + return; + } + ui.list.innerHTML = packs.map(renderListItem).join(""); + }; + + // --- 4. Logic --- + + const loadPacks = async () => { + clearError(container); + ui.list.innerHTML = '
Loading...
'; + + 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(); + } +};