Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ jobs:
- name: Install test deps
run: |
python -m pip install --upgrade pip
python -m pip install numpy pillow
# Keep aligned with local pre-push/full-test scripts.
# aiohttp is required by multiple unit-test import paths.
python -m pip install numpy pillow aiohttp
- name: Run unit tests

env:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ __pycache__/
.planning/
.env
.venv/
.venv-wsl/
venv/
env/
.pytest_cache/
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ ComfyUI-OpenClaw is a **security-first** ComfyUI custom node pack that adds:
- **LLM-assisted nodes** (planner/refiner/vision/batch variants)
- **A built-in extension UI** (`OpenClaw` panel)
- **A secure-by-default HTTP API** for automation (webhooks, triggers, schedules, approvals, presets)
- And more exciting features being added continuously
- **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.
Expand All @@ -22,6 +22,33 @@ It is designed to make **ComfyUI a reliable automation target** with an explicit

## Latest Updates - Click to expand

<details>
<summary><strong>Security Hardening: Observability/Auth boundaries, transform isolation, integrity checks, and safe tooling controls</strong></summary>

- Delivered observability tier hardening with explicit sensitivity split:
- Public-safe: `/openclaw/health`
- Observability token: `/openclaw/config`, `/openclaw/events`, `/openclaw/events/stream`
- Admin-only: `/openclaw/logs/tail`, `/openclaw/trace/{prompt_id}`, `/openclaw/secrets/status`, `/openclaw/security/doctor`
- Delivered constrained transform isolation hardening:
- process-boundary execution via `TransformProcessRunner`
- timeout/output caps and network-deny worker posture
- feature-gated default-off behavior for safer rollout
- Delivered approval/checkpoint integrity hardening:
- canonical JSON + SHA-256 integrity envelopes
- tamper detection and fail-closed handling on integrity violations
- migration-safe loading behavior for legacy persistence files
- Delivered external tooling execution policy:
- allowlist-driven tool definitions (`data/tools_allowlist.json`)
- strict argument validation, bounded timeout/output, and redacted output handling
- gated by `OPENCLAW_ENABLE_EXTERNAL_TOOLS` plus admin access policy
- Extended security doctor coverage with wave-2 checks:
- validates transform isolation posture
- reports external tooling posture
- verifies integrity module availability
- Auth-coverage contract tests were updated to include new tool routes and prevent future route-auth drift regressions.

</details>

<details>
<summary><strong>Sprint A: closes out with five concrete reliability and security improvements</strong></summary>

Expand Down
12 changes: 6 additions & 6 deletions api/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse:
)

# Access control (same as logs/tail)
denied = require_observability_access(request)
if denied:
return denied
allowed, error = require_observability_access(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)

store = get_job_event_store()

Expand Down Expand Up @@ -154,9 +154,9 @@ async def events_poll_handler(request: web.Request) -> web.Response:
)

# Access control
denied = require_observability_access(request)
if denied:
return denied
allowed, error = require_observability_access(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)

store = get_job_event_store()

Expand Down
25 changes: 21 additions & 4 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
)
from ..api.security_doctor import security_doctor_handler # S30
from ..api.templates import templates_list_handler
from ..api.tools import tools_list_handler, tools_run_handler # S12
from ..api.webhook import webhook_handler
from ..api.webhook_submit import webhook_submit_handler
from ..api.webhook_validate import webhook_validate_handler
Expand All @@ -67,7 +68,10 @@
# CRITICAL: These imports MUST remain present.
# If edited out, module-level placeholders stay as None and handlers raise at runtime
# (e.g., TypeError: 'NoneType' object is not callable), producing noisy aiohttp tracebacks.
from ..services.access_control import require_observability_access
from ..services.access_control import (
require_admin_token,
require_observability_access,
)
from ..services.log_tail import tail_log
from ..services.metrics import metrics
from ..services.rate_limit import check_rate_limit
Expand Down Expand Up @@ -103,12 +107,14 @@
)
from api.security_doctor import security_doctor_handler # type: ignore # S30
from api.templates import templates_list_handler
from api.tools import tools_list_handler, tools_run_handler # S12
from api.webhook import webhook_handler
from api.webhook_submit import webhook_submit_handler
from api.webhook_validate import webhook_validate_handler

# IMPORTANT: keep PACK_* imports aligned with config.py (VERSION/config_path do not exist).
from config import LOG_FILE, PACK_NAME, PACK_START_TIME, PACK_VERSION
from services.access_control import require_admin_token # type: ignore
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
Expand Down Expand Up @@ -261,8 +267,8 @@ async def logs_tail_handler(request: web.Request) -> web.Response:
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
# S14: Access Control
allowed, error = require_observability_access(request)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)

Expand Down Expand Up @@ -364,7 +370,8 @@ async def trace_handler(request: web.Request) -> web.Response:
ok, init_error = _ensure_observability_deps_ready()
if not ok:
return web.json_response({"ok": False, "error": init_error}, status=500)
allowed, error = require_observability_access(request)
# S34: Trace/Log data is high sensitivity -> Require Admin Token
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)

Expand Down Expand Up @@ -533,6 +540,16 @@ def register_routes(server) -> None:
f"{prefix}/security/doctor",
security_doctor_handler,
), # S30: Security Doctor diagnostics
(
"GET",
f"{prefix}/tools",
tools_list_handler,
), # S12: List allowed tools
(
"POST",
f"{prefix}/tools/{{name}}/run",
tools_run_handler,
), # S12: Execute tool (admin only)
]

for method, path, handler in core_routes:
Expand Down
103 changes: 103 additions & 0 deletions api/tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""
S12: API Handlers for External Tools.
Protected by Admin Token and Feature Flag.
"""

import json
import logging

from aiohttp import web

try:
from ..services.access_control import require_admin_token
from ..services.tool_runner import get_tool_runner, is_tools_enabled
except ImportError:
from services.access_control import require_admin_token
from services.tool_runner import get_tool_runner, is_tools_enabled

logger = logging.getLogger("ComfyUI-OpenClaw.api.tools")


async def tools_list_handler(request: web.Request) -> web.Response:
"""
GET /openclaw/tools
List allowed external tools.
Requires: Admin Token.
"""
if not is_tools_enabled():
return web.json_response(
{"ok": False, "error": "External tooling is disabled (feature flag off)."},
status=404, # Not Found or Forbidden? 404 implies feature doesn't exist.
)

# Admin check
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)

runner = get_tool_runner()
tools = runner.list_tools()

return web.json_response({"ok": True, "tools": tools})


async def tools_run_handler(request: web.Request) -> web.Response:
"""
POST /openclaw/tools/{name}/run
Execute an external tool.
Body: {"args": {"arg1": "val1", ...}}
Requires: Admin Token.
"""
if not is_tools_enabled():
return web.json_response(
{"ok": False, "error": "External tooling is disabled."}, status=404
)

# Admin check
allowed, error = require_admin_token(request)
if not allowed:
return web.json_response({"ok": False, "error": error}, status=403)

tool_name = request.match_info.get("name")
if not tool_name:
return web.json_response(
{"ok": False, "error": "Tool name required"}, status=400
)

try:
body = await request.json()
except json.JSONDecodeError:
return web.json_response(
{"ok": False, "error": "Invalid JSON body"}, status=400
)

args = body.get("args", {})
if not isinstance(args, dict):
return web.json_response(
{"ok": False, "error": "'args' must be a dictionary"}, status=400
)

runner = get_tool_runner()
result = runner.execute_tool(tool_name, args)

if not result.success:
return web.json_response(
{
"ok": False,
"tool": tool_name,
"error": result.error,
"output": result.output, # Redacted output might contain useful error info
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
},
status=500 if result.error else 400,
) # 500 for runtime error, 400 for validation?

return web.json_response(
{
"ok": True,
"tool": tool_name,
"output": result.output,
"duration_ms": result.duration_ms,
}
)
39 changes: 37 additions & 2 deletions connector/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
from .config import load_config
from .openclaw_client import OpenClawClient
from .platforms.discord_gateway import DiscordGateway
from .platforms.kakao_webhook import KakaoWebhookServer
from .platforms.line_webhook import LINEWebhookServer
from .platforms.telegram_polling import TelegramPolling
from .platforms.wechat_webhook import WeChatWebhookServer
from .platforms.whatsapp_webhook import WhatsAppWebhookServer
from .results_poller import ResultsPoller
from .router import CommandRouter
Expand All @@ -38,6 +40,8 @@ def _print_security_banner(config):
or config.line_allowed_users
or config.line_allowed_groups
or config.whatsapp_allowed_users
or config.wechat_allowed_users
or config.kakao_allowed_users
)
has_admins = bool(config.admin_users)

Expand Down Expand Up @@ -98,6 +102,8 @@ async def main():

line_server = None
whatsapp_server = None
wechat_server = None
kakao_server = None

# 3. Platforms
if config.telegram_bot_token:
Expand Down Expand Up @@ -146,9 +152,34 @@ async def main():
"WhatsApp not configured (OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN missing)"
)

if not tasks and not line_server and not whatsapp_server:
if config.wechat_token:
wechat_server = WeChatWebhookServer(config, router)
platforms["wechat"] = wechat_server
await wechat_server.start()
# If only WeChat is active, add sleeper
if not tasks:
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
else:
logger.info("WeChat not configured (OPENCLAW_CONNECTOR_WECHAT_TOKEN missing)")

if config.kakao_enabled:
kakao_server = KakaoWebhookServer(config, router)
platforms["kakao"] = kakao_server
await kakao_server.start()
if not tasks:
tasks.append(asyncio.create_task(asyncio.sleep(3600 * 24 * 365)))
else:
logger.info("Kakao adapter disabled.")

if (
not tasks
and not line_server
and not whatsapp_server
and not wechat_server
and not kakao_server
):
logger.error(
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, LINE_SECRET, or WHATSAPP_ACCESS_TOKEN."
"No platforms configured! Set TELEGRAM_TOKEN, DISCORD_TOKEN, LINE_SECRET, WHATSAPP_ACCESS_TOKEN, WECHAT_TOKEN or KAKAO_ENABLED."
)
await client.close()
return
Expand All @@ -171,6 +202,10 @@ async def main():
await line_server.stop()
if whatsapp_server:
await whatsapp_server.stop()
if wechat_server:
await wechat_server.stop()
if kakao_server:
await kakao_server.stop()
if poller:
await poller.stop()
await client.close()
Expand Down
44 changes: 44 additions & 0 deletions connector/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ class ConnectorConfig:
whatsapp_bind_port: int = 8098
whatsapp_webhook_path: str = "/whatsapp/webhook"

# WeChat Official Account (R74/S31/F43)
wechat_token: Optional[str] = None
wechat_app_id: Optional[str] = None
wechat_app_secret: Optional[str] = None
wechat_allowed_users: List[str] = field(default_factory=list)
wechat_bind_host: str = "127.0.0.1"
wechat_bind_port: int = 8097
wechat_webhook_path: str = "/wechat/webhook"

# KakaoTalk (F44 Phase A)
kakao_enabled: bool = False
kakao_bind_host: str = "127.0.0.1"
kakao_bind_port: int = 8096
kakao_webhook_path: str = "/kakao/webhook"
kakao_allowed_users: List[str] = field(default_factory=list)

# Privileged Access (ID match across platforms; Telegram Int vs Discord Str handled by router)
admin_users: List[str] = field(default_factory=list)

Expand Down Expand Up @@ -157,6 +173,34 @@ def load_config() -> ConnectorConfig:
"OPENCLAW_CONNECTOR_WHATSAPP_PATH", "/whatsapp/webhook"
)

# WeChat Official Account (R74/S31/F43)
cfg.wechat_token = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_TOKEN")
cfg.wechat_app_id = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_APP_ID")
cfg.wechat_app_secret = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_APP_SECRET")
if wc_users := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_ALLOWED_USERS"):
cfg.wechat_allowed_users = [u.strip() for u in wc_users.split(",") if u.strip()]
cfg.wechat_bind_host = os.environ.get("OPENCLAW_CONNECTOR_WECHAT_BIND", "127.0.0.1")
if wc_port := os.environ.get("OPENCLAW_CONNECTOR_WECHAT_PORT"):
if wc_port.isdigit():
cfg.wechat_bind_port = int(wc_port)
cfg.wechat_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_WECHAT_PATH", "/wechat/webhook"
)

# KakaoTalk (F44)
if os.environ.get("OPENCLAW_CONNECTOR_KAKAO_ENABLED", "").lower() == "true":
cfg.kakao_enabled = True

cfg.kakao_bind_host = os.environ.get("OPENCLAW_CONNECTOR_KAKAO_BIND", "127.0.0.1")
if kp := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_PORT"):
if kp.isdigit():
cfg.kakao_bind_port = int(kp)
cfg.kakao_webhook_path = os.environ.get(
"OPENCLAW_CONNECTOR_KAKAO_PATH", "/kakao/webhook"
)
if ku := os.environ.get("OPENCLAW_CONNECTOR_KAKAO_ALLOWED_USERS"):
cfg.kakao_allowed_users = [u.strip() for u in ku.split(",") if u.strip()]

# Admin
if admins := os.environ.get("OPENCLAW_CONNECTOR_ADMIN_USERS"):
cfg.admin_users = [u.strip() for u in admins.split(",") if u.strip()]
Expand Down
Loading