fix some ci & e2e bugs

This commit is contained in:
rookiestar28
2026-02-06 08:09:33 +08:00
parent 9201c61220
commit 641ea1d076
18 changed files with 448 additions and 247 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ exclude_lines:
- "example.com"
- "your-api-key"
- "<token>"
- "<your.*>"
- "(?i)<your.*>"
- "sk-xxx"
# Exclude files
+9 -2
View File
@@ -4,6 +4,15 @@
# Install: pip install pre-commit && pre-commit install
# Update baseline: detect-secrets scan --baseline .secrets.baseline
exclude: |
(?x)^(
REFERENCE/.*|
node_modules/.*|
test-results/.*|
playwright-report/.*|
playwright/\\.cache/.*
)$
repos:
# Secret detection
- repo: https://github.com/Yelp/detect-secrets
@@ -11,8 +20,6 @@ repos:
hooks:
- id: detect-secrets
args:
- --config
- .detect-secrets.cfg
- --baseline
- .secrets.baseline
exclude: |
+38 -14
View File
@@ -2,6 +2,7 @@
Checkpoints API Handlers (R47).
Exposes endpoints for listing, creating, retrieving, and deleting workflow checkpoints.
"""
from __future__ import annotations
import json
@@ -17,13 +18,23 @@ except ImportError:
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE
from ..services.access_control import is_loopback, require_admin_token
from ..services.checkpoints import create_checkpoint, delete_checkpoint, get_checkpoint, list_checkpoints
from ..services.checkpoints import (
create_checkpoint,
delete_checkpoint,
get_checkpoint,
list_checkpoints,
)
from ..services.rate_limit import check_rate_limit
from ..services.request_ip import get_client_ip
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
from services.access_control import is_loopback, require_admin_token # type: ignore
from services.checkpoints import create_checkpoint, delete_checkpoint, get_checkpoint, list_checkpoints # type: ignore
from services.checkpoints import ( # type: ignore
create_checkpoint,
delete_checkpoint,
get_checkpoint,
list_checkpoints,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.request_ip import get_client_ip # type: ignore
@@ -34,12 +45,17 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.checkpoints")
def _json_resp(data: Dict[str, Any], status: int = 200) -> web.Response:
return web.json_response(data, status=status)
def _remote_admin_allowed() -> bool:
val = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).strip().lower()
(
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
)
.strip()
.lower()
)
return val in ("1", "true", "yes", "on")
@@ -60,7 +76,8 @@ def _deny_remote_admin_if_needed(request: web.Request) -> web.Response | None:
async def list_checkpoints_handler(request: web.Request) -> web.Response:
"""GET /openclaw/checkpoints"""
if web is None: raise RuntimeError("aiohttp not available")
if web is None:
raise RuntimeError("aiohttp not available")
if not check_rate_limit(request, "admin"):
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
@@ -82,20 +99,22 @@ async def list_checkpoints_handler(request: web.Request) -> web.Response:
async def create_checkpoint_handler(request: web.Request) -> web.Response:
"""POST /openclaw/checkpoints"""
if web is None: raise RuntimeError("aiohttp not available")
if web is None:
raise RuntimeError("aiohttp not available")
if not check_rate_limit(request, "admin"):
return _json_resp({"ok": False, "error": "rate_limit_exceeded"}, 429)
# Body Size Check
if request.content_length and request.content_length > MAX_BODY_SIZE:
return _json_resp({"ok": False, "error": "payload_too_large"}, 413)
return _json_resp({"ok": False, "error": "payload_too_large"}, 413)
try:
data = await request.json()
except Exception:
return _json_resp({"ok": False, "error": "invalid_json"}, 400)
# Admin boundary (localhost convenience mode if no token configured)
allowed, error = require_admin_token(request)
if not allowed:
return _json_resp({"ok": False, "error": error or "unauthorized"}, 403)
@@ -103,18 +122,21 @@ async def create_checkpoint_handler(request: web.Request) -> web.Response:
if deny_resp:
return deny_resp
# Extract info
workflow = data.get("workflow") or data.get("prompt")
name = data.get("name", "Untitled Snapshot")
description = data.get("description", "")
if not workflow or not isinstance(workflow, dict):
return _json_resp({"ok": False, "error": "missing_workflow"}, 400)
return _json_resp({"ok": False, "error": "missing_workflow"}, 400)
try:
meta = create_checkpoint(name, workflow, description)
return _json_resp({"ok": True, "checkpoint": meta}, 201)
except ValueError as e:
return _json_resp({"ok": False, "error": "validation_error", "detail": str(e)}, 400)
return _json_resp(
{"ok": False, "error": "validation_error", "detail": str(e)}, 400
)
except Exception as e:
logger.exception("Create checkpoint failed")
return _json_resp({"ok": False, "error": str(e)}, 500)
@@ -122,7 +144,8 @@ async def create_checkpoint_handler(request: web.Request) -> web.Response:
async def get_checkpoint_handler(request: web.Request) -> web.Response:
"""GET /openclaw/checkpoints/{id}"""
if web is None: raise RuntimeError("aiohttp not available")
if web is None:
raise RuntimeError("aiohttp not available")
checkpoint_id = request.match_info.get("id")
if not checkpoint_id:
@@ -147,11 +170,12 @@ async def get_checkpoint_handler(request: web.Request) -> web.Response:
async def delete_checkpoint_handler(request: web.Request) -> web.Response:
"""DELETE /openclaw/checkpoints/{id}"""
if web is None: raise RuntimeError("aiohttp not available")
if web is None:
raise RuntimeError("aiohttp not available")
checkpoint_id = request.match_info.get("id")
if not checkpoint_id:
return _json_resp({"ok": False, "error": "missing_id"}, 400)
return _json_resp({"ok": False, "error": "missing_id"}, 400)
allowed, error = require_admin_token(request)
if not allowed:
+25 -10
View File
@@ -268,8 +268,11 @@ async def llm_models_handler(request: web.Request) -> web.Response:
# Priority: Runtime URL -> Info Default
base_url = runtime_base_url if runtime_base_url else info.base_url
if not base_url:
return web.json_response(
{"ok": False, "error": f"No base URL configured for provider '{provider}'."},
return web.json_response(
{
"ok": False,
"error": f"No base URL configured for provider '{provider}'.",
},
status=400,
)
@@ -346,10 +349,16 @@ async def llm_models_handler(request: web.Request) -> web.Response:
except urllib.error.HTTPError as e:
# Fallback Check
if cached_entry:
ts, models = cached_entry
warning = f"Using cached list (refresh failed: HTTP {e.code} {e.reason})"
return web.json_response(
{"ok": True, "provider": provider, "models": models, "cached": True, "warning": warning}
ts, models = cached_entry
warning = f"Using cached list (refresh failed: HTTP {e.code} {e.reason})"
return web.json_response(
{
"ok": True,
"provider": provider,
"models": models,
"cached": True,
"warning": warning,
}
)
return web.json_response(
{"ok": False, "error": f"HTTP error {e.code}: {e.reason}"}, status=502
@@ -358,10 +367,16 @@ async def llm_models_handler(request: web.Request) -> web.Response:
logger.exception("Failed to fetch model list")
# Fallback Check
if cached_entry:
ts, models = cached_entry
warning = f"Using cached list (refresh failed: {str(e)})"
return web.json_response(
{"ok": True, "provider": provider, "models": models, "cached": True, "warning": warning}
ts, models = cached_entry
warning = f"Using cached list (refresh failed: {str(e)})"
return web.json_response(
{
"ok": True,
"provider": provider,
"models": models,
"cached": True,
"warning": warning,
}
)
return web.json_response({"ok": False, "error": str(e)}, status=500)
+38 -17
View File
@@ -3,6 +3,7 @@ Preflight API Handler (R42).
Exposes POST /openclaw/preflight to run diagnostics on a workflow payload.
"""
from __future__ import annotations
import json
@@ -18,13 +19,21 @@ except ImportError:
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE
from ..services.access_control import is_loopback, require_admin_token
from ..services.preflight import _get_model_inventory, _get_node_class_mappings, run_preflight_check
from ..services.preflight import (
_get_model_inventory,
_get_node_class_mappings,
run_preflight_check,
)
from ..services.rate_limit import check_rate_limit
from ..services.request_ip import get_client_ip
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE # type: ignore
from services.access_control import is_loopback, require_admin_token # type: ignore
from services.preflight import _get_model_inventory, _get_node_class_mappings, run_preflight_check # type: ignore
from services.preflight import ( # type: ignore
_get_model_inventory,
_get_node_class_mappings,
run_preflight_check,
)
from services.rate_limit import check_rate_limit # type: ignore
from services.request_ip import get_client_ip # type: ignore
@@ -33,10 +42,14 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.preflight")
def _remote_admin_allowed() -> bool:
val = (
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
).strip().lower()
(
os.environ.get("OPENCLAW_ALLOW_REMOTE_ADMIN")
or os.environ.get("MOLTBOT_ALLOW_REMOTE_ADMIN")
or ""
)
.strip()
.lower()
)
return val in ("1", "true", "yes", "on")
@@ -72,7 +85,9 @@ async def preflight_handler(request: web.Request) -> web.Response:
# Body Size Check
content_type = request.headers.get("Content-Type", "")
if not content_type.startswith("application/json"):
return web.json_response({"ok": False, "error": "unsupported_media_type"}, status=415)
return web.json_response(
{"ok": False, "error": "unsupported_media_type"}, status=415
)
try:
raw_body = await request.content.read(MAX_BODY_SIZE + 1)
@@ -80,14 +95,16 @@ async def preflight_handler(request: web.Request) -> web.Response:
return web.json_response(
{"ok": False, "error": "payload_too_large"}, status=413
)
data = json.loads(raw_body.decode("utf-8"))
data = json.loads(raw_body)
except Exception:
return web.json_response({"ok": False, "error": "invalid_json"}, status=400)
# Admin boundary (localhost convenience mode if no token configured)
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error or "unauthorized"}, status=403)
return web.json_response(
{"ok": False, "error": error or "unauthorized"}, status=403
)
deny_resp = _deny_remote_admin_if_needed(request)
if deny_resp:
return deny_resp
@@ -98,8 +115,12 @@ async def preflight_handler(request: web.Request) -> web.Response:
if not isinstance(workflow, dict):
return web.json_response(
{"ok": False, "error": "invalid_payload", "detail": "Expected JSON object with workflow data"},
status=400
{
"ok": False,
"error": "invalid_payload",
"detail": "Expected JSON object with workflow data",
},
status=400,
)
# Run Diagnostics
@@ -130,7 +151,9 @@ async def inventory_handler(request: web.Request) -> web.Response:
# Admin boundary (localhost convenience mode if no token configured)
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error or "unauthorized"}, status=403)
return web.json_response(
{"ok": False, "error": error or "unauthorized"}, status=403
)
deny_resp = _deny_remote_admin_if_needed(request)
if deny_resp:
return deny_resp
@@ -143,11 +166,9 @@ async def inventory_handler(request: web.Request) -> web.Response:
# Models
models_map = _get_model_inventory()
return web.json_response({
"ok": True,
"nodes": node_classes,
"models": models_map
})
return web.json_response(
{"ok": True, "nodes": node_classes, "models": models_map}
)
except Exception as e:
logger.exception("Inventory fetch failed")
return web.json_response(
+41 -46
View File
@@ -10,6 +10,29 @@ import os
import sys
import time
try:
from aiohttp import web # type: ignore
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
web = None # type: ignore
PACK_NAME = PACK_VERSION = PACK_START_TIME = LOG_FILE = get_api_key = None # type: ignore
metrics = tail_log = require_observability_access = check_rate_limit = trace_store = None # type: ignore
webhook_handler = webhook_submit_handler = webhook_validate_handler = capabilities_handler = preflight_handler = None # type: ignore
config_get_handler = config_put_handler = llm_test_handler = llm_models_handler = None # type: ignore
secrets_status_handler = secrets_put_handler = secrets_delete_handler = None # type: ignore
list_checkpoints_handler = create_checkpoint_handler = get_checkpoint_handler = delete_checkpoint_handler = None # type: ignore
"""
API routes for observability endpoints.
Registers /openclaw/* endpoints (and legacy /moltbot/*) against ComfyUI PromptServer.
"""
from __future__ import annotations
import json
import os
import sys
import time
try:
from aiohttp import web # type: ignore
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
@@ -29,74 +52,46 @@ if web is not None:
# - Unit tests: allow top-level imports.
if __package__ and "." in __package__:
from ..api.capabilities import capabilities_handler
from ..api.checkpoints_handler import (
create_checkpoint_handler,
delete_checkpoint_handler,
get_checkpoint_handler,
list_checkpoints_handler,
)
from ..api.config import (
config_get_handler,
config_put_handler,
llm_models_handler,
llm_test_handler,
)
from ..api.secrets import (
secrets_delete_handler,
secrets_put_handler,
secrets_status_handler,
)
from ..api.preflight_handler import inventory_handler, preflight_handler
from ..api.secrets import secrets_delete_handler, secrets_put_handler
from ..api.webhook import webhook_handler
from ..api.webhook_submit import webhook_submit_handler
from ..api.webhook_validate import webhook_validate_handler
from ..api.preflight_handler import preflight_handler, inventory_handler
from ..api.checkpoints_handler import (
list_checkpoints_handler,
create_checkpoint_handler,
get_checkpoint_handler,
delete_checkpoint_handler,
)
from ..config import (
LOG_FILE,
PACK_NAME,
PACK_START_TIME,
PACK_VERSION,
get_api_key,
)
from ..services.access_control import require_observability_access
from ..services.log_tail import tail_log
from ..services.metrics import metrics
from ..config import LOG_FILE, PACK_NAME, VERSION, config_path
from ..services.rate_limit import check_rate_limit
from ..services.redaction import redact_text
from ..services.trace import trace
from ..services.trace_store import trace_store
else: # pragma: no cover (test-only import mode)
from api.capabilities import capabilities_handler
from api.checkpoints_handler import (
create_checkpoint_handler,
delete_checkpoint_handler,
get_checkpoint_handler,
list_checkpoints_handler,
)
from api.config import (
config_get_handler,
config_put_handler,
llm_models_handler,
llm_test_handler,
)
from api.secrets import (
secrets_delete_handler,
secrets_put_handler,
secrets_status_handler,
)
from api.preflight_handler import inventory_handler, preflight_handler
from api.secrets import secrets_delete_handler, secrets_put_handler
from api.webhook import webhook_handler
from api.webhook_submit import webhook_submit_handler
from api.webhook_validate import webhook_validate_handler
from api.preflight_handler import preflight_handler, inventory_handler
from api.checkpoints_handler import (
list_checkpoints_handler,
create_checkpoint_handler,
get_checkpoint_handler,
delete_checkpoint_handler,
)
from config import (
LOG_FILE,
PACK_NAME,
PACK_START_TIME,
PACK_VERSION,
get_api_key,
)
from services.access_control import require_observability_access # type: ignore
from services.log_tail import tail_log # type: ignore
from services.metrics import metrics # type: ignore
from services.rate_limit import check_rate_limit # type: ignore
from services.redaction import redact_text # type: ignore
from services.trace_store import trace_store # type: ignore
+1 -1
View File
@@ -134,7 +134,7 @@ async def secrets_put_handler(request: web.Request) -> web.Response:
Save API key to server store.
Body: {"provider": "openai"|"anthropic"|"generic", "api_key": "sk-..."}
Body: {"provider": "openai"|"anthropic"|"generic", "api_key": "<YOUR_API_KEY>"}
Security:
- Admin-gated
+14 -10
View File
@@ -3,6 +3,7 @@ Checkpoints Service (R47).
Manages local workflow snapshots for safe iteration.
Implements limits (count/size) and oldest-eviction policy.
"""
import json
import logging
import os
@@ -70,11 +71,7 @@ def get_checkpoint(checkpoint_id: str) -> Optional[Dict[str, Any]]:
with open(payload_path, "r", encoding="utf-8") as f:
workflow = json.load(f)
return {
"id": checkpoint_id,
"meta": meta,
"workflow": workflow
}
return {"id": checkpoint_id, "meta": meta, "workflow": workflow}
except Exception as e:
logger.error(f"Error reading checkpoint {checkpoint_id}: {e}")
return None
@@ -82,6 +79,7 @@ def get_checkpoint(checkpoint_id: str) -> Optional[Dict[str, Any]]:
import tempfile
def _atomic_write(filepath: str, content: str | bytes):
"""
Write to a temp file then rename to ensure atomicity.
@@ -92,7 +90,9 @@ def _atomic_write(filepath: str, content: str | bytes):
prefix = os.path.basename(filepath) + ".tmp"
# Create temp file in the same directory to ensure atomic rename works (same filesystem)
fd, tmp_path = tempfile.mkstemp(prefix=prefix, dir=folder, text=not isinstance(content, bytes))
fd, tmp_path = tempfile.mkstemp(
prefix=prefix, dir=folder, text=not isinstance(content, bytes)
)
try:
with os.fdopen(fd, mode, encoding="utf-8" if mode == "w" else None) as f:
f.write(content)
@@ -105,7 +105,9 @@ def _atomic_write(filepath: str, content: str | bytes):
raise
def create_checkpoint(name: str, workflow: Dict[str, Any], description: str = "") -> Dict[str, Any]:
def create_checkpoint(
name: str, workflow: Dict[str, Any], description: str = ""
) -> Dict[str, Any]:
"""
Create a new checkpoint.
Enforces size limits and eviction policy.
@@ -140,7 +142,7 @@ def create_checkpoint(name: str, workflow: Dict[str, Any], description: str = ""
"description": description,
"timestamp": timestamp,
"size_bytes": len(workflow_json.encode("utf-8")),
"node_count": len(workflow) if isinstance(workflow, dict) else 0
"node_count": len(workflow) if isinstance(workflow, dict) else 0,
}
meta_path, payload_path = _get_paths(cid)
@@ -150,8 +152,10 @@ def create_checkpoint(name: str, workflow: Dict[str, Any], description: str = ""
_atomic_write(payload_path, workflow_json)
except Exception as e:
# Cleanup on fail (although atomic write minimizes this risk for individual files)
if os.path.exists(meta_path): os.remove(meta_path)
if os.path.exists(payload_path): os.remove(payload_path)
if os.path.exists(meta_path):
os.remove(meta_path)
if os.path.exists(payload_path):
os.remove(payload_path)
raise IOError(f"Failed to save checkpoint: {e}")
return meta
+21 -14
View File
@@ -4,6 +4,7 @@ Preflight Diagnostics Service (R42).
Provides logic to validate a workflow against the local ComfyUI environment,
checking for missing node classes and models.
"""
import logging
import time
from typing import Any, Dict, List, Set, Tuple
@@ -25,6 +26,7 @@ _INPUT_KEY_MAP = {
# Add more as discovered
}
def _get_node_class_mappings() -> Dict[str, Any]:
"""Safely retrieve the global NODE_CLASS_MAPPINGS."""
if nodes and hasattr(nodes, "NODE_CLASS_MAPPINGS"):
@@ -101,15 +103,11 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
"""
report = {
"ok": True,
"summary": {
"missing_nodes": 0,
"missing_models": 0,
"invalid_inputs": 0
},
"summary": {"missing_nodes": 0, "missing_models": 0, "invalid_inputs": 0},
"missing_nodes": [],
"missing_models": [],
"invalid_inputs": [],
"notes": []
"notes": [],
}
if not isinstance(workflow, dict):
@@ -147,17 +145,18 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
report["missing_nodes"].append({"class_type": cls, "count": count})
for key, info in missing_models_counts.items():
report["missing_models"].append({
"type": info["type"],
"name": info["name"],
"count": info["count"]
})
report["missing_models"].append(
{"type": info["type"], "name": info["name"], "count": info["count"]}
)
# Summarize
report["summary"]["missing_nodes"] = len(report["missing_nodes"])
report["summary"]["missing_models"] = len(report["missing_models"])
if report["summary"]["missing_nodes"] > 0 or report["summary"]["missing_models"] > 0:
if (
report["summary"]["missing_nodes"] > 0
or report["summary"]["missing_models"] > 0
):
report["ok"] = False
if not nodes:
@@ -168,7 +167,11 @@ def run_preflight_check(workflow: Dict[str, Any]) -> Dict[str, Any]:
return report
def _check_inputs_for_models(inputs: Dict[str, Any], inventory: Dict[str, List[str]], missing_counts: Dict[str, Dict[str, Any]]):
def _check_inputs_for_models(
inputs: Dict[str, Any],
inventory: Dict[str, List[str]],
missing_counts: Dict[str, Dict[str, Any]],
):
"""
Heuristic to detect missing models in node inputs.
We look for keys that hint at model types (e.g. 'ckpt_name', 'lora_name').
@@ -191,5 +194,9 @@ def _check_inputs_for_models(inputs: Dict[str, Any], inventory: Dict[str, List[s
unique_key = f"{target_type}:{value}"
if unique_key not in missing_counts:
missing_counts[unique_key] = {"type": target_type, "name": value, "count": 0}
missing_counts[unique_key] = {
"type": target_type,
"name": value,
"count": 0,
}
missing_counts[unique_key]["count"] += 1
+3
View File
@@ -45,6 +45,9 @@
presets: true,
approvals: true,
scheduler: true,
explorer: true,
preflight: true,
checkpoints: true,
},
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
+10 -1
View File
@@ -31,7 +31,16 @@ export async function mockComfyUiCore(page) {
}
export async function waitForMoltbotReady(page) {
await page.waitForFunction(() => window.__moltbotTestReady === true, null, { timeout: 30_000 });
await page.waitForFunction(
() => window.__moltbotTestReady === true || window.__moltbotTestError,
null,
{ timeout: 30_000 }
);
const error = await page.evaluate(() => window.__moltbotTestError);
if (error) {
throw new Error(`OpenClaw test harness failed to load: ${error?.message || error}`);
}
// Basic sanity: header + tab bar exists
await expect(page.locator('.moltbot-header')).toBeVisible();
+76 -21
View File
@@ -1,6 +1,6 @@
import unittest
import json
import time
import unittest
from unittest.mock import MagicMock, patch
try:
@@ -9,7 +9,12 @@ except Exception: # pragma: no cover
web = None # type: ignore
if web is not None:
from api.config import llm_models_handler, _MODEL_LIST_CACHE, _extract_models_from_payload
from api.config import (
_MODEL_LIST_CACHE,
_extract_models_from_payload,
llm_models_handler,
)
@unittest.skipIf(web is None, "aiohttp not installed")
class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
@@ -22,18 +27,29 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
@patch("api.config.require_admin_token")
@patch("services.safe_io.validate_outbound_url")
@patch("urllib.request.urlopen")
async def test_handler_success(self, mock_urlopen, mock_validate_url, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config):
async def test_handler_success(
self,
mock_urlopen,
mock_validate_url,
mock_require_admin,
mock_rate_limit,
mock_get_key,
mock_get_config,
):
# Setup mocks
mock_rate_limit.return_value = True
mock_require_admin.return_value = (True, None)
mock_get_config.return_value = ({"provider": "openai", "base_url": "https://api.openai.com"}, {})
mock_get_config.return_value = (
{"provider": "openai", "base_url": "https://api.openai.com"},
{},
)
mock_get_key.return_value = "sk-test"
# Mock API response
mock_response = MagicMock()
mock_response.read.return_value = json.dumps({
"data": [{"id": "gpt-4o"}]
}).encode("utf-8")
mock_response.read.return_value = json.dumps(
{"data": [{"id": "gpt-4o"}]}
).encode("utf-8")
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response
@@ -60,7 +76,14 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
@patch("api.config.check_rate_limit")
@patch("api.config.require_admin_token")
@patch("services.safe_io.validate_outbound_url")
async def test_handler_cached_fallback(self, mock_validate_url, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config):
async def test_handler_cached_fallback(
self,
mock_validate_url,
mock_require_admin,
mock_rate_limit,
mock_get_key,
mock_get_config,
):
# Setup cache with specific base_url - make it STALE (older than TTL)
cache_key = ("openai", "https://api.openai.com")
_MODEL_LIST_CACHE[cache_key] = (time.time() - 7200, ["cached-model"])
@@ -69,7 +92,10 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
mock_rate_limit.return_value = True
mock_require_admin.return_value = (True, None)
# Mock API failure by NOT patching urlopen (it will raise if called, or we can mock it to raise)
mock_get_config.return_value = ({"provider": "openai", "base_url": "https://api.openai.com"}, {})
mock_get_config.return_value = (
{"provider": "openai", "base_url": "https://api.openai.com"},
{},
)
mock_get_key.return_value = "sk-test"
with patch("urllib.request.urlopen", side_effect=Exception("Network fail")):
@@ -93,11 +119,16 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
@patch("services.providers.keys.get_api_key_for_provider")
@patch("api.config.check_rate_limit")
@patch("api.config.require_admin_token")
async def test_handler_base_url_override(self, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config):
async def test_handler_base_url_override(
self, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config
):
mock_rate_limit.return_value = True
mock_require_admin.return_value = (True, None)
# Override base_url in config
mock_get_config.return_value = ({"provider": "custom", "base_url": "http://custom-host:8080/v1"}, {})
mock_get_config.return_value = (
{"provider": "custom", "base_url": "http://custom-host:8080/v1"},
{},
)
mock_get_key.return_value = "sk-custom"
request = MagicMock()
@@ -106,7 +137,9 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
with patch("urllib.request.urlopen") as mock_urlopen:
mock_response = MagicMock()
mock_response.read.return_value = json.dumps({"models": [{"id": "custom-model"}]}).encode("utf-8")
mock_response.read.return_value = json.dumps(
{"models": [{"id": "custom-model"}]}
).encode("utf-8")
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response
@@ -123,7 +156,14 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
@patch("api.config.check_rate_limit")
@patch("api.config.require_admin_token")
@patch("services.providers.catalog.get_provider_info")
async def test_handler_unsupported_provider(self, mock_get_info, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config):
async def test_handler_unsupported_provider(
self,
mock_get_info,
mock_require_admin,
mock_rate_limit,
mock_get_key,
mock_get_config,
):
mock_rate_limit.return_value = True
mock_require_admin.return_value = (True, None)
mock_get_config.return_value = ({"provider": "anthropic"}, {})
@@ -131,7 +171,8 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
# Mock provider info as NOT OpenAI-compat
mock_info = MagicMock()
from services.providers.catalog import ProviderType
mock_info.api_type = ProviderType.ANTHROPIC # Not OpenAI Compat
mock_info.api_type = ProviderType.ANTHROPIC # Not OpenAI Compat
mock_get_info.return_value = mock_info
request = MagicMock()
@@ -139,7 +180,7 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
request.remote = "127.0.0.1"
resp = await llm_models_handler(request)
self.assertEqual(resp.status, 400) # Should be 400 Bad Request
self.assertEqual(resp.status, 400) # Should be 400 Bad Request
data = json.loads(resp.body)
self.assertIn("only supported for OpenAI-compatible", data["error"])
@@ -147,18 +188,25 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
@patch("services.providers.keys.get_api_key_for_provider")
@patch("api.config.check_rate_limit")
@patch("api.config.require_admin_token")
async def test_handler_ssrf_blocked(self, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config):
async def test_handler_ssrf_blocked(
self, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config
):
mock_rate_limit.return_value = True
mock_require_admin.return_value = (True, None)
mock_get_config.return_value = ({"provider": "openai", "base_url": "http://169.254.169.254"}, {})
mock_get_config.return_value = (
{"provider": "openai", "base_url": "http://169.254.169.254"},
{},
)
request = MagicMock()
request.query = {}
request.remote = "127.0.0.1"
# Simulate SSRF failure
with patch("services.safe_io.validate_outbound_url", side_effect=ValueError("Blocked")):
resp = await llm_models_handler(request)
with patch(
"services.safe_io.validate_outbound_url", side_effect=ValueError("Blocked")
):
resp = await llm_models_handler(request)
self.assertEqual(resp.status, 403)
data = json.loads(resp.body)
@@ -169,7 +217,14 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
@patch("api.config.check_rate_limit")
@patch("api.config.require_admin_token")
@patch("api.config.is_loopback_client")
async def test_handler_remote_access_denied(self, mock_is_loopback, mock_require_admin, mock_rate_limit, mock_get_key, mock_get_config):
async def test_handler_remote_access_denied(
self,
mock_is_loopback,
mock_require_admin,
mock_rate_limit,
mock_get_key,
mock_get_config,
):
mock_rate_limit.return_value = True
# Admin token is valid, but request is remote
mock_require_admin.return_value = (True, None)
@@ -185,7 +240,7 @@ class TestModelListAPI(unittest.IsolatedAsyncioTestCase):
# Ensure ENV doesn't allow remote
with patch.dict("os.environ", {}, clear=True):
resp = await llm_models_handler(request)
resp = await llm_models_handler(request)
self.assertEqual(resp.status, 403)
data = json.loads(resp.body)
+13 -6
View File
@@ -1,16 +1,22 @@
import json
import os
import shutil
import unittest
import time
from unittest.mock import patch, MagicMock
import unittest
from unittest.mock import MagicMock, patch
from services import checkpoints
from services.checkpoints import list_checkpoints, get_checkpoint, create_checkpoint, delete_checkpoint
from services.checkpoints import (
create_checkpoint,
delete_checkpoint,
get_checkpoint,
list_checkpoints,
)
# Use a temp dir for testing
TEST_DATA_DIR = os.path.join(os.path.dirname(__file__), "test_data_checkpoints")
class TestCheckpointsService(unittest.TestCase):
def setUp(self):
@@ -56,7 +62,7 @@ class TestCheckpointsService(unittest.TestCase):
try:
# Create 3
meta1 = create_checkpoint("1", {})
time.sleep(0.01) # ensure timestamp diff
time.sleep(0.01) # ensure timestamp diff
meta2 = create_checkpoint("2", {})
time.sleep(0.01)
meta3 = create_checkpoint("3", {})
@@ -67,7 +73,7 @@ class TestCheckpointsService(unittest.TestCase):
ids = [x["id"] for x in lst]
self.assertIn(meta3["id"], ids)
self.assertIn(meta2["id"], ids)
self.assertNotIn(meta1["id"], ids) # Oldest evicted
self.assertNotIn(meta1["id"], ids) # Oldest evicted
finally:
checkpoints.MAX_CHECKPOINTS = original_max
@@ -89,5 +95,6 @@ class TestCheckpointsService(unittest.TestCase):
with self.assertRaises(ValueError):
create_checkpoint("Valid Name", {}, long_desc)
if __name__ == "__main__":
unittest.main()
+5 -1
View File
@@ -14,6 +14,7 @@ except Exception: # pragma: no cover
def unittest_run_loop(fn): # type: ignore
return fn
from api.checkpoints_handler import (
create_checkpoint_handler,
delete_checkpoint_handler,
@@ -85,7 +86,10 @@ class TestCheckpointsAPI(AioHTTPTestCase):
self.assertEqual(resp.status, 404)
@patch("api.checkpoints_handler.check_rate_limit", return_value=True)
@patch("api.checkpoints_handler.require_admin_token", return_value=(False, "invalid_admin_token"))
@patch(
"api.checkpoints_handler.require_admin_token",
return_value=(False, "invalid_admin_token"),
)
@unittest_run_loop
async def test_auth_denied(self, _mock_admin, _mock_rl):
resp = await self.client.get("/openclaw/checkpoints")
+37 -27
View File
@@ -1,6 +1,7 @@
import json
import unittest
from unittest.mock import MagicMock, patch
try:
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop
@@ -11,8 +12,10 @@ except Exception: # pragma: no cover
def unittest_run_loop(fn): # type: ignore
return fn
from api.preflight_handler import preflight_handler
import services.preflight
from api.preflight_handler import preflight_handler
@unittest.skipIf(web is None, "aiohttp not installed")
class TestPreflightBackend(AioHTTPTestCase):
@@ -34,13 +37,19 @@ class TestPreflightBackend(AioHTTPTestCase):
mock_require_admin.return_value = (True, None)
# Use create=True to handle cases where conditional imports resulted in missing attributes
with patch.object(services.preflight, "nodes", MagicMock(), create=True) as mock_nodes, \
patch.object(services.preflight, "folder_paths", MagicMock(), create=True) as mock_folder_paths:
with (
patch.object(
services.preflight, "nodes", MagicMock(), create=True
) as mock_nodes,
patch.object(
services.preflight, "folder_paths", MagicMock(), create=True
) as mock_folder_paths,
):
# Setup Node Inventory
mock_nodes.NODE_CLASS_MAPPINGS = {
"KSampler": object,
"CheckpointLoaderSimple": object
"CheckpointLoaderSimple": object,
}
# Setup Model Inventory
@@ -50,20 +59,16 @@ class TestPreflightBackend(AioHTTPTestCase):
if ftype == "checkpoints":
return ["v1-5-pruned.ckpt"]
return []
mock_folder_paths.get_filename_list.side_effect = get_filenames
# Test Workflow (Valid)
workflow = {
"1": {
"class_type": "KSampler",
"inputs": {}
},
"1": {"class_type": "KSampler", "inputs": {}},
"2": {
"class_type": "CheckpointLoaderSimple",
"inputs": {
"ckpt_name": "v1-5-pruned.ckpt"
}
}
"inputs": {"ckpt_name": "v1-5-pruned.ckpt"},
},
}
resp = await self.client.post("/openclaw/preflight", json=workflow)
@@ -83,38 +88,43 @@ class TestPreflightBackend(AioHTTPTestCase):
mock_rate_limit.return_value = True
mock_require_admin.return_value = (True, None)
with patch.object(services.preflight, "nodes", MagicMock(), create=True) as mock_nodes, \
patch.object(services.preflight, "folder_paths", MagicMock(), create=True) as mock_folder_paths:
with (
patch.object(
services.preflight, "nodes", MagicMock(), create=True
) as mock_nodes,
patch.object(
services.preflight, "folder_paths", MagicMock(), create=True
) as mock_folder_paths,
):
# Setup Inventory (Missing CustomNode and SDXL model)
mock_nodes.NODE_CLASS_MAPPINGS = {"KSampler": object}
mock_folder_paths.get_filename_list.return_value = [] # No models
mock_folder_paths.folder_names_and_paths = {} # Needed for dynamic check
mock_folder_paths.get_filename_list.return_value = [] # No models
mock_folder_paths.folder_names_and_paths = {} # Needed for dynamic check
workflow = {
"1": {
"class_type": "UnknownCustomNode", # Missing
"inputs": {}
},
"1": {"class_type": "UnknownCustomNode", "inputs": {}}, # Missing
"2": {
"class_type": "KSampler",
"inputs": {
"ckpt_name": "sd_xl_base_1.0.safetensors" # Missing
}
}
"inputs": {"ckpt_name": "sd_xl_base_1.0.safetensors"}, # Missing
},
}
resp = await self.client.post("/openclaw/preflight", json=workflow)
self.assertEqual(resp.status, 200)
data = await resp.json()
self.assertFalse(data["ok"]) # Should fail check
self.assertFalse(data["ok"]) # Should fail check
self.assertEqual(data["summary"]["missing_nodes"], 1)
self.assertEqual(data["summary"]["missing_models"], 1)
# Verify details
self.assertEqual(data["missing_nodes"][0]["class_type"], "UnknownCustomNode")
self.assertEqual(data["missing_models"][0]["name"], "sd_xl_base_1.0.safetensors")
self.assertEqual(
data["missing_nodes"][0]["class_type"], "UnknownCustomNode"
)
self.assertEqual(
data["missing_models"][0]["name"], "sd_xl_base_1.0.safetensors"
)
@patch("api.preflight_handler.check_rate_limit")
@patch("api.preflight_handler.require_admin_token")
+40
View File
@@ -3,6 +3,46 @@
* Shared Utilities for Moltbot UI
*/
/**
* Simple DOM factory helper.
* @param {string} tag - HTML tag name
* @param {string} className - Optional class name
* @param {string} text - Optional text content
*/
export function makeEl(tag, className = "", text = "") {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined && text !== null && text !== "") {
el.textContent = text;
}
return el;
}
/**
* Lightweight toast helper for UI feedback.
* @param {string} message
* @param {"info"|"error"|"success"} variant
*/
export function showToast(message, variant = "info") {
const toast = document.createElement("div");
toast.className = `moltbot-toast moltbot-toast-${variant}`;
toast.textContent = message;
toast.style.position = "fixed";
toast.style.right = "16px";
toast.style.bottom = "16px";
toast.style.padding = "8px 12px";
toast.style.borderRadius = "6px";
toast.style.background = variant === "error" ? "#5a1e1e" : (variant === "success" ? "#1e5a2b" : "#2d2d2d");
toast.style.color = "#fff";
toast.style.zIndex = "9999";
toast.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 2500);
}
/**
* Display an error message within a container.
* Looks for an existing .moltbot-error-box, or creates one at the top.
+1 -1
View File
@@ -1,6 +1,6 @@
import { moltbotApi } from "../openclaw_api.js";
import { makeEl, showToast } from "../openclaw_ui.js";
import { makeEl, showToast } from "../openclaw_utils.js";
/**
* F28: Explorer Tab
+2 -2
View File
@@ -546,8 +546,8 @@ function createCollapsibleSection(title, description, defaultExpanded = false) {
<li>ENV keys always take priority over stored keys.</li>
<li>Secrets are stored as plaintext JSON on disk (protected by OS permissions).</li>
</ul>
<p><b>PowerShell</b>: <code>$env:OPENCLAW_LLM_API_KEY="sk-..."</code></p>
<p><b>CMD</b>: <code>set OPENCLAW_LLM_API_KEY=sk-...</code></p>
<p><b>PowerShell</b>: <code>$env:OPENCLAW_LLM_API_KEY="&lt;YOUR_API_KEY&gt;"</code></p>
<p><b>CMD</b>: <code>set OPENCLAW_LLM_API_KEY=&lt;YOUR_API_KEY&gt;</code></p>
`
);
titleWrap.appendChild(helpBtn);