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 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
+1
View File
@@ -0,0 +1 @@
18
+21 -7
View File
@@ -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/*`.
+1
View File
@@ -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
+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 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
+6
View File
@@ -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)...")
+34 -7
View File
@@ -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)."""
+49 -1
View File
@@ -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 <id>")
# 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 <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"
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 <run|toggle> <id>")
# 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 <prompt_id>")
# 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')}")
+4 -1
View File
@@ -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"
}
}
}
+35 -1
View File
@@ -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())
+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)}")
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",
}
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.
+72
View File
@@ -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)
+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 { 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);
}
+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();
}
};