mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
finalize Asset Packs integrity + API surface with CI-ready verification
This commit is contained in:
@@ -71,9 +71,12 @@ jobs:
|
|||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install numpy pillow
|
python -m pip install numpy pillow
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
|
|
||||||
env:
|
env:
|
||||||
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit
|
MOLTBOT_STATE_DIR: ${{ github.workspace }}/moltbot_state/_ci_unit
|
||||||
run: |
|
run: |
|
||||||
|
python -m unittest discover -s tests -p "test_*.py" -v
|
||||||
|
|
||||||
security-audit:
|
security-audit:
|
||||||
name: Security Audit (S23)
|
name: Security Audit (S23)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -85,9 +88,7 @@ jobs:
|
|||||||
- name: Frontend Audit (npm)
|
- name: Frontend Audit (npm)
|
||||||
run: |
|
run: |
|
||||||
# Audit only production dependencies, ignore dev
|
# Audit only production dependencies, ignore dev
|
||||||
npm audit --production || true
|
npm audit --production
|
||||||
# Note: || true because audit often fails on harmless things.
|
|
||||||
# In strict mode, remove || true or configure exceptions.
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -95,8 +96,4 @@ jobs:
|
|||||||
- name: Install pip-audit
|
- name: Install pip-audit
|
||||||
run: pip install pip-audit
|
run: pip install pip-audit
|
||||||
- name: Backend Audit (pip)
|
- name: Backend Audit (pip)
|
||||||
run: |
|
run: pip-audit
|
||||||
pip install -r requirements.txt || true
|
|
||||||
# Scan environment
|
|
||||||
pip-audit || true
|
|
||||||
# Again, allowing failure for now to avoid blocking CI on minor findings.
|
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
# ComfyUI-OpenClaw
|
# ComfyUI-OpenClaw
|
||||||
|
|
||||||
ComfyUI-OpenClaw is a ComfyUI custom node pack that adds:
|

|
||||||
|
|
||||||
|
ComfyUI-OpenClaw is a **security-first** ComfyUI custom node pack that adds:
|
||||||
|
|
||||||
- **LLM-assisted nodes** (planner/refiner/vision/batch variants)
|
- **LLM-assisted nodes** (planner/refiner/vision/batch variants)
|
||||||
- **A built-in extension UI** (`OpenClaw` panel)
|
- **A built-in extension UI** (`OpenClaw` panel)
|
||||||
- **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets)
|
- **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets)
|
||||||
- And more exciting features being added continuously
|
- And more exciting features being added continuously
|
||||||
|
|
||||||

|
This project is intentionally **not** a general-purpose “assistant platform” with broad remote execution surfaces.
|
||||||
|
It is designed to make **ComfyUI a reliable automation target** with an explicit admin boundary and hardened defaults.
|
||||||
|
|
||||||
- **openclaw**: a broad assistant platform with skills, channels, gateway control plane, etc.
|
**Security stance (how this project differs from convenience-first automation packs):**
|
||||||
|
|
||||||
- **ComfyUI-OpenClaw**: a ComfyUI-first automation layer that uses LLMs as helpers, with an API surface designed to be safe and manageable.
|
- Localhost-first defaults; remote access is opt-in
|
||||||
|
- Explicit **Admin Token** boundary for write actions
|
||||||
If you want a “personal assistant OS,” openclaw makes a lot of sense.
|
- Webhooks are **deny-by-default** until auth is configured
|
||||||
If you want **“ComfyUI as a reliable automation target,”** ComfyUI-OpenClaw feels more direct.
|
- Strict outbound SSRF policy (callbacks + custom LLM base URLs)
|
||||||
|
- Secrets are never stored in browser storage (optional server-side key store is local-only convenience)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -238,6 +242,16 @@ Admin boundary:
|
|||||||
- `GET /openclaw/packs/export/{name}/{version}`
|
- `GET /openclaw/packs/export/{name}/{version}`
|
||||||
- `DELETE /openclaw/packs/{name}/{version}`
|
- `DELETE /openclaw/packs/{name}/{version}`
|
||||||
|
|
||||||
|
Packs are **versioned zip bundles** (templates/presets/profiles) with an integrity manifest (file hashes).
|
||||||
|
Import/export is designed to be **reproducible** and hardened against common archive attacks (path traversal, zip bombs).
|
||||||
|
|
||||||
|
Operational notes:
|
||||||
|
|
||||||
|
- Packs are **local-only by default** (no auto-download).
|
||||||
|
- Packs management requires the Admin Token boundary (or localhost-only convenience mode).
|
||||||
|
- UI: `OpenClaw` panel → `Packs` tab.
|
||||||
|
- Verification: `python -m unittest tests.test_packs_integrity -v`
|
||||||
|
|
||||||
### Bridge (sidecar; optional)
|
### Bridge (sidecar; optional)
|
||||||
|
|
||||||
Sidecar bridge routes are registered under `/openclaw/bridge/*` and `/moltbot/bridge/*`.
|
Sidecar bridge routes are registered under `/openclaw/bridge/*` and `/moltbot/bridge/*`.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def _register_routes_once():
|
|||||||
"""Register all Moltbot routes including Bridge and Scheduler."""
|
"""Register all Moltbot routes including Bridge and Scheduler."""
|
||||||
from .api.approvals import register_approval_routes
|
from .api.approvals import register_approval_routes
|
||||||
from .api.bridge import BridgeHandlers
|
from .api.bridge import BridgeHandlers
|
||||||
|
|
||||||
from .api.presets import register_preset_routes
|
from .api.presets import register_preset_routes
|
||||||
from .api.routes import register_routes
|
from .api.routes import register_routes
|
||||||
from .api.schedules import register_schedule_routes
|
from .api.schedules import register_schedule_routes
|
||||||
|
|||||||
+172
-78
@@ -1,11 +1,66 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from aiohttp import web
|
||||||
|
except ImportError:
|
||||||
|
# NOTE:
|
||||||
|
# - ComfyUI runtime always has `aiohttp` available.
|
||||||
|
# - Unit tests may run in a minimal Python environment without `aiohttp`.
|
||||||
|
# We still want this module to be importable, but any HTTP handlers must
|
||||||
|
# fail fast if invoked without real `aiohttp.web`.
|
||||||
|
class MockWeb:
|
||||||
|
_IS_MOCKWEB = True
|
||||||
|
|
||||||
|
class Request:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FileResponse:
|
||||||
|
def __init__(self, path: str, headers: Optional[dict] = None):
|
||||||
|
self._path = path
|
||||||
|
self.headers = headers or {}
|
||||||
|
|
||||||
|
async def prepare(self, request): # pragma: no cover
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json_response(*args, **kwargs): # pragma: no cover
|
||||||
|
raise RuntimeError("aiohttp not available")
|
||||||
|
|
||||||
|
web = MockWeb()
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
from aiohttp import web
|
# OpenClaw imports
|
||||||
|
if __package__ and "." in __package__:
|
||||||
|
from ..services.access_control import require_admin_token
|
||||||
|
from ..services.packs.pack_archive import PackArchive, PackError
|
||||||
|
from ..services.packs.pack_registry import PackRegistry
|
||||||
|
else:
|
||||||
|
from services.access_control import require_admin_token
|
||||||
|
from services.packs.pack_archive import PackArchive, PackError
|
||||||
|
from services.packs.pack_registry import PackRegistry
|
||||||
|
|
||||||
from ..services.access_control import require_admin_token
|
|
||||||
from ..services.packs.pack_archive import PackArchive, PackError
|
if web:
|
||||||
from ..services.packs.pack_registry import PackRegistry
|
class CleanupFileResponse(web.FileResponse):
|
||||||
|
"""FileResponse that deletes the file after sending."""
|
||||||
|
async def prepare(self, request):
|
||||||
|
try:
|
||||||
|
return await super().prepare(request)
|
||||||
|
finally:
|
||||||
|
path = self._path
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
CleanupFileResponse = None
|
||||||
|
|
||||||
|
|
||||||
class PacksHandlers:
|
class PacksHandlers:
|
||||||
@@ -13,30 +68,50 @@ class PacksHandlers:
|
|||||||
self.registry = PackRegistry(state_dir)
|
self.registry = PackRegistry(state_dir)
|
||||||
|
|
||||||
async def list_packs_handler(self, request: web.Request) -> web.Response:
|
async def list_packs_handler(self, request: web.Request) -> web.Response:
|
||||||
"""GET /moltbot/packs"""
|
"""GET /packs - List installed packs."""
|
||||||
allowed, error = require_admin_token(request)
|
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||||
if not allowed:
|
raise RuntimeError("aiohttp not available")
|
||||||
return web.json_response({"ok": False, "error": error}, status=403)
|
|
||||||
|
# S8: Public read or authenticated?
|
||||||
|
# Usually list is fine to be public-read if not strictly protected,
|
||||||
|
# but admin token check is safer for system info.
|
||||||
|
# Plan says "Integrity (Local)", implies authenticated management.
|
||||||
|
# list_packs might be needed for UI.
|
||||||
|
# Let's verify admin token for consistency with other sensitive endpoints.
|
||||||
|
# Actually, let's keep list public-ish for UI discovery?
|
||||||
|
# No, "require_admin_token" for everything per F32 is safer.
|
||||||
|
# But for now, let's just implement listing.
|
||||||
|
|
||||||
|
# NOTE: S8/F11 implies rigorous management.
|
||||||
|
if not await self._check_auth(request):
|
||||||
|
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
|
||||||
|
|
||||||
packs = self.registry.list_packs()
|
try:
|
||||||
return web.json_response({"ok": True, "packs": packs})
|
packs = self.registry.list_packs()
|
||||||
|
return web.json_response({"ok": True, "packs": packs})
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
async def import_pack_handler(self, request: web.Request) -> web.Response:
|
async def import_pack_handler(self, request: web.Request) -> web.Response:
|
||||||
"""POST /moltbot/packs/import (multipart/form-data)"""
|
"""POST /packs/import - Install pack from zip upload."""
|
||||||
allowed, error = require_admin_token(request)
|
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||||
if not allowed:
|
raise RuntimeError("aiohttp not available")
|
||||||
return web.json_response({"ok": False, "error": error}, status=403)
|
|
||||||
|
|
||||||
|
if not await self._check_auth(request):
|
||||||
|
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
|
||||||
|
|
||||||
|
# Multipart reader
|
||||||
reader = await request.multipart()
|
reader = await request.multipart()
|
||||||
field = await reader.next()
|
field = await reader.next()
|
||||||
|
|
||||||
if not field or field.name != "file":
|
if not field or field.name != "file":
|
||||||
return web.json_response({"ok": False, "error": "missing_file"}, status=400)
|
return web.json_response({"ok": False, "error": "Missing file field"}, status=400)
|
||||||
|
|
||||||
# Write upload to temp file
|
filename = field.filename or "pack.zip"
|
||||||
|
|
||||||
|
# Save to temp file
|
||||||
fd, temp_path = tempfile.mkstemp(suffix=".zip")
|
fd, temp_path = tempfile.mkstemp(suffix=".zip")
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(temp_path, "wb") as f:
|
with open(temp_path, "wb") as f:
|
||||||
while True:
|
while True:
|
||||||
@@ -44,75 +119,94 @@ class PacksHandlers:
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
|
||||||
# Install
|
overwrite = request.query.get("overwrite", "false").lower() == "true"
|
||||||
overwrite = request.query.get("overwrite", "").lower() == "true"
|
|
||||||
meta = self.registry.install_pack(temp_path, overwrite=overwrite)
|
try:
|
||||||
|
meta = self.registry.install_pack(temp_path, overwrite=overwrite)
|
||||||
return web.json_response({"ok": True, "pack": meta})
|
return web.json_response({"ok": True, "pack": meta})
|
||||||
|
except PackError as e:
|
||||||
except PackError as e:
|
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response(
|
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||||
{"ok": False, "error": f"Internal error: {str(e)}"}, status=500
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
os.remove(temp_path)
|
os.remove(temp_path)
|
||||||
|
|
||||||
async def export_pack_handler(self, request: web.Request) -> web.Response:
|
async def delete_pack_handler(self, request: web.Request) -> web.Response:
|
||||||
"""GET /moltbot/packs/export/{name}/{version}"""
|
"""DELETE /packs/{name}/{version} - Uninstall pack."""
|
||||||
allowed, error = require_admin_token(request)
|
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||||
if not allowed:
|
raise RuntimeError("aiohttp not available")
|
||||||
return web.json_response({"ok": False, "error": error}, status=403)
|
|
||||||
|
if not await self._check_auth(request):
|
||||||
|
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
|
||||||
|
|
||||||
name = request.match_info.get("name")
|
name = request.match_info.get("name")
|
||||||
version = request.match_info.get("version")
|
version = request.match_info.get("version")
|
||||||
|
|
||||||
pack_dir = self.registry.get_pack_path(name, version)
|
if not name or not version:
|
||||||
if not pack_dir:
|
return web.json_response({"ok": False, "error": "Missing name/version"}, status=400)
|
||||||
return web.json_response(
|
|
||||||
{"ok": False, "error": "pack_not_found"}, status=404
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create temp zip
|
|
||||||
fd, temp_path = tempfile.mkstemp(suffix=f"-{name}-{version}.zip")
|
|
||||||
os.close(fd)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
PackArchive.create_pack_archive(pack_dir, temp_path)
|
success = self.registry.uninstall_pack(name, version)
|
||||||
|
if success:
|
||||||
# Serve file
|
return web.json_response({"ok": True})
|
||||||
return web.FileResponse(
|
else:
|
||||||
temp_path,
|
return web.json_response({"ok": False, "error": "Not found"}, status=404)
|
||||||
headers={
|
|
||||||
"Content-Disposition": f'attachment; filename="{name}-{version}.moltpack"'
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# Note: FileResponse might not clean up temp file automatically.
|
|
||||||
# In a real system, we'd want a cleanup mechanism (e.g. background task).
|
|
||||||
# For now, we rely on OS temp cleanup or add a cleanup callback if aiohttp supports it.
|
|
||||||
# A safer way allows a streaming response that deletes after.
|
|
||||||
# But standard ComfyUI extensions often simple-serve.
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if os.path.exists(temp_path):
|
|
||||||
os.remove(temp_path)
|
|
||||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
async def delete_pack_handler(self, request: web.Request) -> web.Response:
|
async def export_pack_handler(self, request: web.Request) -> web.Response:
|
||||||
"""DELETE /moltbot/packs/{name}/{version}"""
|
"""GET /packs/export/{name}/{version} - Download pack zip."""
|
||||||
allowed, error = require_admin_token(request)
|
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||||
if not allowed:
|
raise RuntimeError("aiohttp not available")
|
||||||
return web.json_response({"ok": False, "error": error}, status=403)
|
|
||||||
|
if not await self._check_auth(request):
|
||||||
|
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
|
||||||
|
|
||||||
|
name = request.match_info.get("name")
|
||||||
|
version = request.match_info.get("version")
|
||||||
|
|
||||||
|
if not name or not version:
|
||||||
|
return web.json_response({"ok": False, "error": "Missing name/version"}, status=400)
|
||||||
|
|
||||||
|
pack_path = self.registry.get_pack_path(name, version)
|
||||||
|
if not pack_path:
|
||||||
|
return web.json_response({"ok": False, "error": "Pack not found"}, status=404)
|
||||||
|
|
||||||
|
# Create temp zip
|
||||||
|
fd, temp_zip = tempfile.mkstemp(suffix=".zip")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure manifest exists (it should for installed packs)
|
||||||
|
if not os.path.exists(os.path.join(pack_path, "manifest.json")):
|
||||||
|
return web.json_response({"ok": False, "error": "Pack manifest missing/corrupt"}, status=500)
|
||||||
|
|
||||||
|
# Create deterministic zip
|
||||||
|
PackArchive.create_pack_archive(pack_path, temp_zip)
|
||||||
|
|
||||||
|
# Stream response
|
||||||
|
return CleanupFileResponse(
|
||||||
|
temp_zip,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{name}-{version}.zip"',
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if os.path.exists(temp_zip):
|
||||||
|
os.remove(temp_zip)
|
||||||
|
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||||
|
|
||||||
name = request.match_info.get("name")
|
async def _check_auth(self, request: web.Request) -> bool:
|
||||||
version = request.match_info.get("version")
|
# Re-use require_admin_token logic from access_control?
|
||||||
|
# require_admin_token(request) returns (allowed, error_msg)
|
||||||
success = self.registry.uninstall_pack(name, version)
|
# Wait, require_admin_token in access_control might depend on config.
|
||||||
if success:
|
# Let's import it.
|
||||||
return web.json_response({"ok": True})
|
try:
|
||||||
else:
|
allowed, _ = require_admin_token(request)
|
||||||
return web.json_response(
|
return allowed
|
||||||
{"ok": False, "error": "pack_not_found"}, status=404
|
except Exception:
|
||||||
)
|
return False
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ def _print_security_banner(config):
|
|||||||
"⚠️ Admin commands (/approve, /reject, etc.) will be unavailable."
|
"⚠️ Admin commands (/approve, /reject, etc.) will be unavailable."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not config.admin_token:
|
||||||
|
logger.warning("⚠️ No admin token configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).")
|
||||||
|
logger.warning(
|
||||||
|
"⚠️ Admin commands will fail if OpenClaw Server requires authentication."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
logger.info("Initializing OpenClaw Connector (Phase 5)...")
|
logger.info("Initializing OpenClaw Connector (Phase 5)...")
|
||||||
|
|||||||
+34
-7
@@ -9,6 +9,7 @@ Privacy:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -24,6 +25,8 @@ class LLMClient:
|
|||||||
- Never auto-executes commands.
|
- Never auto-executes commands.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CONFIG_TTL = 60 # seconds
|
||||||
|
|
||||||
def __init__(self, openclaw_client):
|
def __init__(self, openclaw_client):
|
||||||
"""
|
"""
|
||||||
Initialize with OpenClawClient to fetch settings from backend.
|
Initialize with OpenClawClient to fetch settings from backend.
|
||||||
@@ -33,11 +36,13 @@ class LLMClient:
|
|||||||
"""
|
"""
|
||||||
self._client = openclaw_client
|
self._client = openclaw_client
|
||||||
self._config_cache = None
|
self._config_cache = None
|
||||||
|
self._last_fetch = 0
|
||||||
self._configured = None
|
self._configured = None
|
||||||
|
|
||||||
async def _fetch_config(self) -> dict:
|
async def _fetch_config(self) -> dict:
|
||||||
"""Fetch LLM config from OpenClaw backend."""
|
"""Fetch LLM config from OpenClaw backend (with TTL)."""
|
||||||
if self._config_cache is not None:
|
now = time.time()
|
||||||
|
if self._config_cache is not None and (now - self._last_fetch < self.CONFIG_TTL):
|
||||||
return self._config_cache
|
return self._config_cache
|
||||||
|
|
||||||
res = await self._client.get_openclaw_config()
|
res = await self._client.get_openclaw_config()
|
||||||
@@ -49,14 +54,25 @@ class LLMClient:
|
|||||||
self._config_cache = data.get("config", {})
|
self._config_cache = data.get("config", {})
|
||||||
else:
|
else:
|
||||||
self._config_cache = data if isinstance(data, dict) else {}
|
self._config_cache = data if isinstance(data, dict) else {}
|
||||||
|
self._last_fetch = now
|
||||||
|
# Reset configured state to force re-evaluation
|
||||||
|
self._configured = None
|
||||||
else:
|
else:
|
||||||
self._config_cache = {}
|
# On failure, keep old cache if available (resilience)
|
||||||
|
if self._config_cache is None:
|
||||||
|
self._config_cache = {}
|
||||||
|
|
||||||
return self._config_cache
|
return self._config_cache
|
||||||
|
|
||||||
async def is_configured(self) -> bool:
|
async def is_configured(self) -> bool:
|
||||||
"""Check if LLM is properly configured in OpenClaw settings."""
|
"""Check if LLM is properly configured in OpenClaw settings."""
|
||||||
if self._configured is not None:
|
if self._configured is not None:
|
||||||
return self._configured
|
# Re-check TTL on is_configured access too?
|
||||||
|
# _fetch_config handles it.
|
||||||
|
# But if config didn't change, _configured is valid.
|
||||||
|
# If TTL expired, we need to re-fetch and re-evaluate.
|
||||||
|
if time.time() - self._last_fetch < self.CONFIG_TTL:
|
||||||
|
return self._configured
|
||||||
|
|
||||||
config = await self._fetch_config()
|
config = await self._fetch_config()
|
||||||
provider = config.get("provider")
|
provider = config.get("provider")
|
||||||
@@ -103,11 +119,22 @@ class LLMClient:
|
|||||||
if res.get("ok"):
|
if res.get("ok"):
|
||||||
data = res.get("text") or res.get("data", {}).get("text")
|
data = res.get("text") or res.get("data", {}).get("text")
|
||||||
return data or "[No response]"
|
return data or "[No response]"
|
||||||
return f"[LLM Error] {res.get('error', 'Request failed')}"
|
|
||||||
|
error_msg = res.get('error', 'Request failed')
|
||||||
|
|
||||||
|
# Harden error messages for user
|
||||||
|
if "401" in error_msg or "unauthorized" in error_msg.lower():
|
||||||
|
return "[LLM Error] API Key Invalid or Missing. Please check Settings."
|
||||||
|
if "429" in error_msg or "quota" in error_msg.lower():
|
||||||
|
return "[LLM Error] Rate Limit / Quota Exceeded. Please try again later."
|
||||||
|
if "503" in error_msg or "overloaded" in error_msg.lower():
|
||||||
|
return "[LLM Error] Service Overloaded. Please try again later."
|
||||||
|
|
||||||
|
return f"[LLM Error] {error_msg}"
|
||||||
except Exception:
|
except Exception:
|
||||||
# Log error without user content
|
# Log error without user content
|
||||||
logger.error("LLM request failed")
|
logger.error("LLM request failed", exc_info=True)
|
||||||
return "[LLM Error] Request failed. Please try again."
|
return "[LLM Error] Request failed. Please check logs."
|
||||||
|
|
||||||
def _get_default_base_url(self, provider: str) -> str:
|
def _get_default_base_url(self, provider: str) -> str:
|
||||||
"""Get default base URL for provider (matches OpenClaw catalog)."""
|
"""Get default base URL for provider (matches OpenClaw catalog)."""
|
||||||
|
|||||||
+49
-1
@@ -5,7 +5,7 @@ Dispatches parsed commands to handlers with AST argument parsing.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .config import ConnectorConfig
|
from .config import ConnectorConfig
|
||||||
from .contract import CommandRequest, CommandResponse
|
from .contract import CommandRequest, CommandResponse
|
||||||
@@ -234,6 +234,17 @@ class CommandRouter:
|
|||||||
+ "\n".join(f"- {d}" for d in details)
|
+ "\n".join(f"- {d}" for d in details)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _require_admin_token_configured(self) -> Optional[CommandResponse]:
|
||||||
|
"""
|
||||||
|
F32 WP3: Check if admin token is configured before running admin commands.
|
||||||
|
Fail-fast with clear error message instead of 403/500 later.
|
||||||
|
"""
|
||||||
|
if not self.config.admin_token:
|
||||||
|
return CommandResponse(
|
||||||
|
text="[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN and restart connector."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _handle_run(
|
async def _handle_run(
|
||||||
self, req: CommandRequest, args: List[str]
|
self, req: CommandRequest, args: List[str]
|
||||||
) -> CommandResponse:
|
) -> CommandResponse:
|
||||||
@@ -302,6 +313,14 @@ class CommandRouter:
|
|||||||
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
|
msg = f"[Approval Requested]\nID: {approval_id}\nTrace: {trace_id}"
|
||||||
if "expires_at" in data:
|
if "expires_at" in data:
|
||||||
msg += f"\nExpires: {data['expires_at']}"
|
msg += f"\nExpires: {data['expires_at']}"
|
||||||
|
if self.poller:
|
||||||
|
# IMPORTANT:
|
||||||
|
# For untrusted users, approvals are done in the OpenClaw UI.
|
||||||
|
# We must start tracking the approval_id so we can map
|
||||||
|
# approval_id -> executed_prompt_id later and auto-deliver images.
|
||||||
|
self.poller.track_approval(
|
||||||
|
approval_id, req.platform, req.channel_id, req.sender_id
|
||||||
|
)
|
||||||
return CommandResponse(text=msg)
|
return CommandResponse(text=msg)
|
||||||
else:
|
else:
|
||||||
prompt_id = data.get("prompt_id", "unknown")
|
prompt_id = data.get("prompt_id", "unknown")
|
||||||
@@ -356,6 +375,10 @@ class CommandRouter:
|
|||||||
async def _handle_interrupt(
|
async def _handle_interrupt(
|
||||||
self, req: CommandRequest, args: List[str]
|
self, req: CommandRequest, args: List[str]
|
||||||
) -> CommandResponse:
|
) -> CommandResponse:
|
||||||
|
# F32 WP3: Guard
|
||||||
|
if err := self._require_admin_token_configured():
|
||||||
|
return err
|
||||||
|
|
||||||
# Remediation: Global Interrupt
|
# Remediation: Global Interrupt
|
||||||
res = await self.client.interrupt_output()
|
res = await self.client.interrupt_output()
|
||||||
if res.get("ok"):
|
if res.get("ok"):
|
||||||
@@ -366,6 +389,10 @@ class CommandRouter:
|
|||||||
async def _handle_approvals_list(
|
async def _handle_approvals_list(
|
||||||
self, req: CommandRequest, args: List[str]
|
self, req: CommandRequest, args: List[str]
|
||||||
) -> CommandResponse:
|
) -> CommandResponse:
|
||||||
|
# F32 WP3: Guard
|
||||||
|
if err := self._require_admin_token_configured():
|
||||||
|
return err
|
||||||
|
|
||||||
res = await self.client.get_approvals()
|
res = await self.client.get_approvals()
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
return CommandResponse(
|
return CommandResponse(
|
||||||
@@ -405,6 +432,10 @@ class CommandRouter:
|
|||||||
if not args:
|
if not args:
|
||||||
return CommandResponse(text="Usage: /approve <id>")
|
return CommandResponse(text="Usage: /approve <id>")
|
||||||
|
|
||||||
|
# F32 WP3: Guard
|
||||||
|
if err := self._require_admin_token_configured():
|
||||||
|
return err
|
||||||
|
|
||||||
# Assuming auto_execute=True by default for chat logic
|
# Assuming auto_execute=True by default for chat logic
|
||||||
res = await self.client.approve_request(args[0], auto_execute=True)
|
res = await self.client.approve_request(args[0], auto_execute=True)
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
@@ -434,6 +465,10 @@ class CommandRouter:
|
|||||||
if not args:
|
if not args:
|
||||||
return CommandResponse(text="Usage: /reject <id> [reason]")
|
return CommandResponse(text="Usage: /reject <id> [reason]")
|
||||||
|
|
||||||
|
# F32 WP3: Guard
|
||||||
|
if err := self._require_admin_token_configured():
|
||||||
|
return err
|
||||||
|
|
||||||
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
|
reason = " ".join(args[1:]) if len(args) > 1 else "Rejected via chat"
|
||||||
res = await self.client.reject_request(args[0], reason)
|
res = await self.client.reject_request(args[0], reason)
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
@@ -444,6 +479,10 @@ class CommandRouter:
|
|||||||
async def _handle_schedules_list(
|
async def _handle_schedules_list(
|
||||||
self, req: CommandRequest, args: List[str]
|
self, req: CommandRequest, args: List[str]
|
||||||
) -> CommandResponse:
|
) -> CommandResponse:
|
||||||
|
# F32 WP3: Guard
|
||||||
|
if err := self._require_admin_token_configured():
|
||||||
|
return err
|
||||||
|
|
||||||
res = await self.client.get_schedules()
|
res = await self.client.get_schedules()
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
return CommandResponse(text=f"[Error] {res.get('error')}")
|
return CommandResponse(text=f"[Error] {res.get('error')}")
|
||||||
@@ -467,6 +506,10 @@ class CommandRouter:
|
|||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
|
return CommandResponse(text="Usage: /schedule <run|toggle> <id>")
|
||||||
|
|
||||||
|
# F32 WP3: Guard
|
||||||
|
if err := self._require_admin_token_configured():
|
||||||
|
return err
|
||||||
|
|
||||||
sub = args[0].lower()
|
sub = args[0].lower()
|
||||||
sid = args[1]
|
sid = args[1]
|
||||||
|
|
||||||
@@ -519,6 +562,11 @@ class CommandRouter:
|
|||||||
) -> CommandResponse:
|
) -> CommandResponse:
|
||||||
if not args:
|
if not args:
|
||||||
return CommandResponse(text="Usage: /trace <prompt_id>")
|
return CommandResponse(text="Usage: /trace <prompt_id>")
|
||||||
|
|
||||||
|
# F32 WP3: Guard
|
||||||
|
if err := self._require_admin_token_configured():
|
||||||
|
return err
|
||||||
|
|
||||||
res = await self.client.get_trace(args[0])
|
res = await self.client.get_trace(args[0])
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
return CommandResponse(text=f"[Error] {res.get('error')}")
|
return CommandResponse(text=f"[Error] {res.get('error')}")
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "comfyui-openclaw",
|
"name": "comfyui-openclaw",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.50.0"
|
"@playwright/test": "^1.50.0"
|
||||||
},
|
},
|
||||||
@@ -11,4 +14,4 @@
|
|||||||
"test:debug": "npx playwright test --debug",
|
"test:debug": "npx playwright test --debug",
|
||||||
"test:report": "npx playwright show-report"
|
"test:report": "npx playwright show-report"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ from .pack_manifest import (
|
|||||||
)
|
)
|
||||||
from .pack_types import PackMetadata
|
from .pack_types import PackMetadata
|
||||||
|
|
||||||
|
MAX_COMPRESSION_RATIO = 100
|
||||||
|
|
||||||
|
|
||||||
class PackArchive:
|
class PackArchive:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -49,12 +51,20 @@ class PackArchive:
|
|||||||
if total_size > MAX_FILE_SIZE_MB * 1024 * 1024:
|
if total_size > MAX_FILE_SIZE_MB * 1024 * 1024:
|
||||||
raise PackError(f"Archive content too large ({total_size} bytes)")
|
raise PackError(f"Archive content too large ({total_size} bytes)")
|
||||||
|
|
||||||
|
# Check compression ratio
|
||||||
|
compressed_size = sum(i.compress_size for i in infos)
|
||||||
|
if compressed_size > 0:
|
||||||
|
ratio = total_size / compressed_size
|
||||||
|
if ratio > MAX_COMPRESSION_RATIO:
|
||||||
|
raise PackError(f"Compression ratio too high ({ratio:.1f} > {MAX_COMPRESSION_RATIO})")
|
||||||
|
|
||||||
# 2. Safety Check
|
# 2. Safety Check
|
||||||
for info in infos:
|
for info in infos:
|
||||||
if (
|
if (
|
||||||
info.filename.startswith("/")
|
info.filename.startswith("/")
|
||||||
or ".." in info.filename
|
or ".." in info.filename
|
||||||
or "\\" in info.filename
|
or "\\" in info.filename
|
||||||
|
or any(c < ' ' for c in info.filename) # Control chars
|
||||||
):
|
):
|
||||||
raise PackError(f"Unsafe filename: {info.filename}")
|
raise PackError(f"Unsafe filename: {info.filename}")
|
||||||
|
|
||||||
@@ -118,8 +128,32 @@ class PackArchive:
|
|||||||
raise PackError("Source directory does not exist")
|
raise PackError("Source directory does not exist")
|
||||||
|
|
||||||
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
files_to_add = []
|
||||||
for root, _, files in os.walk(source_dir):
|
for root, _, files in os.walk(source_dir):
|
||||||
for file in files:
|
for file in files:
|
||||||
full_path = os.path.join(root, file)
|
full_path = os.path.join(root, file)
|
||||||
rel_path = os.path.relpath(full_path, source_dir)
|
rel_path = os.path.relpath(full_path, source_dir)
|
||||||
zf.write(full_path, rel_path)
|
files_to_add.append((full_path, rel_path))
|
||||||
|
|
||||||
|
# Deterministic order
|
||||||
|
files_to_add.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
for full_path, rel_path in files_to_add:
|
||||||
|
# Deterministic metadata (timestamp)
|
||||||
|
# ZipInfo requires a tuple (year, month, day, hour, min, sec)
|
||||||
|
# We use a fixed epoch for reproducibility, or file mtime?
|
||||||
|
# Plan says "regenerate manifest deterministically".
|
||||||
|
# If we use file mtime, it changes if we touch files.
|
||||||
|
# Using fixed timestamp ensures identical binary hash for identical content.
|
||||||
|
# But standard zip tools use mtime.
|
||||||
|
# Let's use 1980-01-01 00:00:00 (DOS epoch)
|
||||||
|
zinfo = zipfile.ZipInfo(rel_path)
|
||||||
|
zinfo.date_time = (1980, 1, 1, 0, 0, 0)
|
||||||
|
zinfo.compress_type = zipfile.ZIP_DEFLATED
|
||||||
|
|
||||||
|
# Set regular file permissions (0o644)
|
||||||
|
# External attr: (0o100644 << 16) = 0x81A40000
|
||||||
|
zinfo.external_attr = 0x81A40000
|
||||||
|
|
||||||
|
with open(full_path, "rb") as f:
|
||||||
|
zf.writestr(zinfo, f.read())
|
||||||
|
|||||||
@@ -90,3 +90,48 @@ def validate_manifest_integrity(base_dir: str, manifest: PackManifest) -> List[s
|
|||||||
errors.append(f"Error reading {rel_path}: {str(e)}")
|
errors.append(f"Error reading {rel_path}: {str(e)}")
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def create_manifest(base_dir: str, metadata: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Generates and writes a deterministic manifest.json for the given value.
|
||||||
|
Returns the path to the written manifest.
|
||||||
|
"""
|
||||||
|
manifest_path = os.path.join(base_dir, "manifest.json")
|
||||||
|
|
||||||
|
# 1. Collect all files and compute hashes
|
||||||
|
files_list = []
|
||||||
|
for root, _, files in os.walk(base_dir):
|
||||||
|
for file in files:
|
||||||
|
if file == "manifest.json":
|
||||||
|
continue # Do not include manifest in manifest
|
||||||
|
|
||||||
|
full_path = os.path.join(root, file)
|
||||||
|
rel_path = os.path.relpath(full_path, base_dir).replace("\\", "/") # Normalize separators
|
||||||
|
|
||||||
|
sha = compute_sha256(full_path)
|
||||||
|
|
||||||
|
# Use deterministic dictionary structure
|
||||||
|
files_list.append({
|
||||||
|
"path": rel_path,
|
||||||
|
"sha256": sha,
|
||||||
|
"size": os.path.getsize(full_path)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Sort files by path (Important for determinism)
|
||||||
|
files_list.sort(key=lambda x: x["path"])
|
||||||
|
|
||||||
|
# 3. Create manifest object
|
||||||
|
manifest = {
|
||||||
|
"version": metadata.get("version", "0.0.0"),
|
||||||
|
"files": files_list,
|
||||||
|
# Add metadata keys sorted?
|
||||||
|
**{k: v for k, v in sorted(metadata.items()) if k != "version"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Write with sort_keys=True
|
||||||
|
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(manifest, f, indent=2, sort_keys=True)
|
||||||
|
f.write("\n") # POSIX newline
|
||||||
|
|
||||||
|
return manifest_path
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ ALLOWED_LLM_KEYS = {
|
|||||||
"max_failover_candidates",
|
"max_failover_candidates",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ALLOWED_SCHEDULER_KEYS = {
|
||||||
|
"startup_jitter_sec",
|
||||||
|
"max_runs_per_tick",
|
||||||
|
"skip_missed_intervals",
|
||||||
|
}
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"llm": {
|
"llm": {
|
||||||
@@ -72,7 +78,12 @@ DEFAULTS = {
|
|||||||
"fallback_models": [],
|
"fallback_models": [],
|
||||||
"fallback_providers": [],
|
"fallback_providers": [],
|
||||||
"max_failover_candidates": 3,
|
"max_failover_candidates": 3,
|
||||||
}
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"startup_jitter_sec": 30,
|
||||||
|
"max_runs_per_tick": 5,
|
||||||
|
"skip_missed_intervals": False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Value constraints
|
# Value constraints
|
||||||
@@ -82,6 +93,11 @@ CONSTRAINTS = {
|
|||||||
"max_failover_candidates": (1, 5), # R14: Limit total candidates
|
"max_failover_candidates": (1, 5), # R14: Limit total candidates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SCHEDULER_CONSTRAINTS = {
|
||||||
|
"startup_jitter_sec": (0, 300),
|
||||||
|
"max_runs_per_tick": (1, 100),
|
||||||
|
}
|
||||||
|
|
||||||
# Environment variable mappings (new, legacy)
|
# Environment variable mappings (new, legacy)
|
||||||
ENV_MAPPINGS = {
|
ENV_MAPPINGS = {
|
||||||
"provider": ("OPENCLAW_LLM_PROVIDER", "MOLTBOT_LLM_PROVIDER"),
|
"provider": ("OPENCLAW_LLM_PROVIDER", "MOLTBOT_LLM_PROVIDER"),
|
||||||
@@ -92,12 +108,19 @@ ENV_MAPPINGS = {
|
|||||||
# R14: Failover env vars
|
# R14: Failover env vars
|
||||||
"fallback_models": ("OPENCLAW_FALLBACK_MODELS", "MOLTBOT_FALLBACK_MODELS"),
|
"fallback_models": ("OPENCLAW_FALLBACK_MODELS", "MOLTBOT_FALLBACK_MODELS"),
|
||||||
"fallback_providers": ("OPENCLAW_FALLBACK_PROVIDERS", "MOLTBOT_FALLBACK_PROVIDERS"),
|
"fallback_providers": ("OPENCLAW_FALLBACK_PROVIDERS", "MOLTBOT_FALLBACK_PROVIDERS"),
|
||||||
|
|
||||||
"max_failover_candidates": (
|
"max_failover_candidates": (
|
||||||
"OPENCLAW_MAX_FAILOVER_CANDIDATES",
|
"OPENCLAW_MAX_FAILOVER_CANDIDATES",
|
||||||
"MOLTBOT_MAX_FAILOVER_CANDIDATES",
|
"MOLTBOT_MAX_FAILOVER_CANDIDATES",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SCHEDULER_ENV_MAPPINGS = {
|
||||||
|
"startup_jitter_sec": ("OPENCLAW_SCHEDULER_STARTUP_JITTER_SEC", ""),
|
||||||
|
"max_runs_per_tick": ("OPENCLAW_SCHEDULER_MAX_RUNS_PER_TICK", ""),
|
||||||
|
"skip_missed_intervals": ("OPENCLAW_SCHEDULER_SKIP_MISSED", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _clamp(value: int, min_val: int, max_val: int) -> int:
|
def _clamp(value: int, min_val: int, max_val: int) -> int:
|
||||||
"""Clamp an integer to a range."""
|
"""Clamp an integer to a range."""
|
||||||
@@ -172,6 +195,41 @@ def _env_flag(primary: str, legacy: str, default: bool = False) -> bool:
|
|||||||
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler_config() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get effective Scheduler config (Env > Defaults).
|
||||||
|
Note: Scheduler config is currently not persisted to file (Env only).
|
||||||
|
"""
|
||||||
|
effective = {}
|
||||||
|
defaults = DEFAULTS["scheduler"]
|
||||||
|
|
||||||
|
for key in ALLOWED_SCHEDULER_KEYS:
|
||||||
|
# Check ENV
|
||||||
|
env_vars = SCHEDULER_ENV_MAPPINGS.get(key)
|
||||||
|
if env_vars:
|
||||||
|
primary, _ = env_vars
|
||||||
|
val = os.environ.get(primary)
|
||||||
|
|
||||||
|
if val is not None:
|
||||||
|
# Parse
|
||||||
|
if key == "skip_missed_intervals":
|
||||||
|
effective[key] = str(val).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
elif key in SCHEDULER_CONSTRAINTS:
|
||||||
|
try:
|
||||||
|
val_int = int(val)
|
||||||
|
effective[key] = _clamp(val_int, *SCHEDULER_CONSTRAINTS[key])
|
||||||
|
except ValueError:
|
||||||
|
effective[key] = defaults[key]
|
||||||
|
else:
|
||||||
|
effective[key] = val
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use default
|
||||||
|
effective[key] = defaults.get(key)
|
||||||
|
|
||||||
|
return effective
|
||||||
|
|
||||||
|
|
||||||
def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]:
|
def get_effective_config() -> Tuple[Dict[str, Any], Dict[str, str]]:
|
||||||
"""
|
"""
|
||||||
Get effective LLM config with precedence: ENV > file > defaults.
|
Get effective LLM config with precedence: ENV > file > defaults.
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import random
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Awaitable, Callable, Optional
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
|
from ..runtime_config import get_scheduler_config
|
||||||
from .history import RunRecord, get_run_history
|
from .history import RunRecord, get_run_history
|
||||||
from .models import Schedule, TriggerType
|
from .models import Schedule, TriggerType
|
||||||
from .storage import get_schedule_store
|
from .storage import get_schedule_store
|
||||||
@@ -172,6 +174,29 @@ class SchedulerRunner:
|
|||||||
"""Main scheduler loop (runs in thread)."""
|
"""Main scheduler loop (runs in thread)."""
|
||||||
logger.debug("Scheduler loop started")
|
logger.debug("Scheduler loop started")
|
||||||
|
|
||||||
|
# R34: Read config once at startup for jitter/skip behavior
|
||||||
|
config = get_scheduler_config()
|
||||||
|
|
||||||
|
# 1. Startup Jitter
|
||||||
|
jitter_sec = config.get("startup_jitter_sec", 0)
|
||||||
|
if jitter_sec > 0:
|
||||||
|
# Clamp to safe range just in case
|
||||||
|
jitter_sec = min(300, max(0, jitter_sec))
|
||||||
|
delay = random.uniform(0, jitter_sec)
|
||||||
|
logger.info(f"Startup jitter enabled: sleeping {delay:.2f}s")
|
||||||
|
# Wait with stop_event check to be interruptible
|
||||||
|
if self._stop_event.wait(timeout=delay):
|
||||||
|
logger.debug("Scheduler stopped during jitter wait")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Skip Missed Intervals
|
||||||
|
if config.get("skip_missed_intervals"):
|
||||||
|
logger.info("Skip Missed Intervals enabled: advancing cursors...")
|
||||||
|
try:
|
||||||
|
self._skip_missed_ticks()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to skip missed ticks: {e}")
|
||||||
|
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self._tick()
|
self._tick()
|
||||||
@@ -183,10 +208,46 @@ class SchedulerRunner:
|
|||||||
|
|
||||||
logger.debug("Scheduler loop exited")
|
logger.debug("Scheduler loop exited")
|
||||||
|
|
||||||
|
def _skip_missed_ticks(self) -> None:
|
||||||
|
"""
|
||||||
|
Advance all due schedules to now without executing them.
|
||||||
|
Prevents backlog burst after downtime.
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
now_ts = now.timestamp()
|
||||||
|
schedules = self._store.list_all()
|
||||||
|
|
||||||
|
skipped_count = 0
|
||||||
|
for schedule in schedules:
|
||||||
|
if not schedule.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_due = False
|
||||||
|
if schedule.trigger_type == TriggerType.CRON:
|
||||||
|
is_due = is_cron_due(schedule.cron_expr, schedule.last_tick_ts, now)
|
||||||
|
elif schedule.trigger_type == TriggerType.INTERVAL:
|
||||||
|
is_due = is_interval_due(
|
||||||
|
schedule.interval_sec, schedule.last_tick_ts, now_ts
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_due:
|
||||||
|
# Update cursor without running
|
||||||
|
# Use a special run_id to indicate skip
|
||||||
|
schedule.update_cursor(now_ts, "skipped_startup")
|
||||||
|
self._store.update(schedule)
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
if skipped_count > 0:
|
||||||
|
logger.info(f"Skipped {skipped_count} missed schedules due to startup policy.")
|
||||||
|
|
||||||
def _tick(self) -> None:
|
def _tick(self) -> None:
|
||||||
"""Process one scheduler tick."""
|
"""Process one scheduler tick."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
now_ts = now.timestamp()
|
now_ts = now.timestamp()
|
||||||
|
|
||||||
|
# R34: Dynamic config read for runtime tuning
|
||||||
|
config = get_scheduler_config()
|
||||||
|
max_runs = config.get("max_runs_per_tick", 5)
|
||||||
|
|
||||||
schedules = self._store.list_all()
|
schedules = self._store.list_all()
|
||||||
due_schedules = []
|
due_schedules = []
|
||||||
@@ -209,6 +270,17 @@ class SchedulerRunner:
|
|||||||
|
|
||||||
if due_schedules:
|
if due_schedules:
|
||||||
logger.debug(f"Found {len(due_schedules)} due schedules")
|
logger.debug(f"Found {len(due_schedules)} due schedules")
|
||||||
|
|
||||||
|
# R34: Cap max runs per tick
|
||||||
|
if len(due_schedules) > max_runs:
|
||||||
|
logger.warning(
|
||||||
|
f"Throttling scheduler: {len(due_schedules)} due, "
|
||||||
|
f"capping to {max_runs} (max_runs_per_tick)."
|
||||||
|
)
|
||||||
|
# Sort by last_tick_ts to prioritize oldest starved schedules
|
||||||
|
# If last_tick_ts is None, treat as 0 (very old)
|
||||||
|
due_schedules.sort(key=lambda s: s.last_tick_ts or 0)
|
||||||
|
due_schedules = due_schedules[:max_runs]
|
||||||
|
|
||||||
for schedule in due_schedules:
|
for schedule in due_schedules:
|
||||||
self._execute_schedule(schedule, now_ts)
|
self._execute_schedule(schedule, now_ts)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -17,6 +17,7 @@ import { RefinerTab } from "./tabs/refiner_tab.js";
|
|||||||
import { LibraryTab } from "./tabs/library_tab.js";
|
import { LibraryTab } from "./tabs/library_tab.js";
|
||||||
import { ApprovalsTab } from "./tabs/approvals_tab.js";
|
import { ApprovalsTab } from "./tabs/approvals_tab.js";
|
||||||
import { ExplorerTab } from "./tabs/explorer_tab.js";
|
import { ExplorerTab } from "./tabs/explorer_tab.js";
|
||||||
|
import { PacksTab } from "./tabs/packs_tab.js";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ async function registerSupportedTabs() {
|
|||||||
if (fallbackShowAll || features.explorer || features.preflight || features.checkpoints) {
|
if (fallbackShowAll || features.explorer || features.preflight || features.checkpoints) {
|
||||||
tabManager.registerTab(ExplorerTab); // Explorer: inventory + preflight + snapshots
|
tabManager.registerTab(ExplorerTab); // Explorer: inventory + preflight + snapshots
|
||||||
}
|
}
|
||||||
|
if (fallbackShowAll || features.packs) tabManager.registerTab(PacksTab);
|
||||||
|
|
||||||
console.log("[OpenClaw] Tabs registered based on capabilities:", Object.keys(tabManager.tabs).length);
|
console.log("[OpenClaw] Tabs registered based on capabilities:", Object.keys(tabManager.tabs).length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user