mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
fix some ci & e2e bugs
This commit is contained in:
+1
-1
@@ -36,7 +36,7 @@ exclude_lines:
|
||||
- "example.com"
|
||||
- "your-api-key"
|
||||
- "<token>"
|
||||
- "<your.*>"
|
||||
- "(?i)<your.*>"
|
||||
- "sk-xxx"
|
||||
|
||||
# Exclude files
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
presets: true,
|
||||
approvals: true,
|
||||
scheduler: true,
|
||||
explorer: true,
|
||||
preflight: true,
|
||||
checkpoints: true,
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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="<YOUR_API_KEY>"</code></p>
|
||||
<p><b>CMD</b>: <code>set OPENCLAW_LLM_API_KEY=<YOUR_API_KEY></code></p>
|
||||
`
|
||||
);
|
||||
titleWrap.appendChild(helpBtn);
|
||||
|
||||
Reference in New Issue
Block a user