mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
public release gates + deploy recipes: add release checklist, threat model, feature-flag policy, and Node18-based validation workflow
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Release Checklist (DoD)
|
||||
|
||||
This document contains the authoritative checklist for releasing **ComfyUI-OpenClaw**.
|
||||
A release candidate must pass **Gate A** to be considered for Public Release v1.
|
||||
If the deployment enables remote control or bridge features, it must also pass **Gate B**.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The validation workflow in `tests/TEST_SOP.md` is **mandatory** for all releases.
|
||||
|
||||
---
|
||||
|
||||
## Gate A: Public Release v1 Baseline (Required)
|
||||
|
||||
**Goal**: Safe-by-default for internet-exposed deployments (assuming they follow the deployment recipes in `docs/deploy/`).
|
||||
|
||||
### 1. Security & configuration
|
||||
|
||||
- [ ] **Admin Boundary**: `OPENCLAW_CONNECTOR_ADMIN_TOKEN` is required for sensitive operations if remote access is enabled.
|
||||
- [ ] **Webhooks**: Listening webhooks (Discord/Line/Telegram) are disabled unless their respective tokens are configured (`OPENCLAW_CONNECTOR_DISCORD_TOKEN`, etc.).
|
||||
- [ ] **Observability**: `/openclaw/logs/tail` and `/openclaw/config` require `OPENCLAW_OBSERVABILITY_TOKEN` (legacy: `MOLTBOT_OBSERVABILITY_TOKEN`) if accessed remotely, or are loopback-only.
|
||||
- [ ] **SSRF**: LLM `base_url` defaults to known providers. Custom URLs require `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST=1` or explicit allowlist.
|
||||
- [ ] **Budgets**: `OPENCLAW_MAX_INFLIGHT_SUBMITS_TOTAL` (concurrency) and `OPENCLAW_MAX_RENDERED_WORKFLOW_BYTES` (payloads) are enforced.
|
||||
|
||||
### 2. Documentation & Recipes
|
||||
|
||||
- [ ] **Deployment**: `docs/deploy/` contains recipes for:
|
||||
- [ ] Local-only (Default)
|
||||
- [ ] Tailscale (Recommended Remote)
|
||||
- [ ] LAN (Restricted)
|
||||
- [ ] **Security**: `SECURITY.md` is up-to-date and linked from README.
|
||||
- [ ] **Feature Flags**: `docs/release/feature_flags.md` accurately reflects the codebase defaults.
|
||||
|
||||
### 3. Validation (Must Pass)
|
||||
|
||||
Run the full regression suite:
|
||||
|
||||
```bash
|
||||
# 1. Secret Scanning
|
||||
pre-commit run detect-secrets --all-files
|
||||
|
||||
# 2. Lint & Formatting
|
||||
pre-commit run --all-files --show-diff-on-failure
|
||||
|
||||
# 3. Backend Unit Tests
|
||||
MOLTBOT_STATE_DIR="$(pwd)/moltbot_state/_local_unit" python -m unittest discover -s tests -p "test_*.py" -v
|
||||
|
||||
# 4. Frontend E2E (Unit/Integration)
|
||||
# Ensure Node 18+
|
||||
node -v
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gate B: Bridge / Remote Control Safety (Conditional)
|
||||
|
||||
**Goal**: Safe operation when `OPENCLAW_BRIDGE_ENABLED=1` or remote commands are active.
|
||||
|
||||
- [ ] **Explicit Enable**: Bridge features are off unless `OPENCLAW_BRIDGE_ENABLED=1` is set.
|
||||
- [ ] **Auth**: Bridge endpoints require `OPENCLAW_BRIDGE_TOKEN` (or device pairing).
|
||||
- [ ] **CSRF**: State-changing endpoints (admin/bridge) enforce Origin checks or require Token on loopback.
|
||||
- [ ] **Callback Safety**: Delivery targets are validated against DNS/IP allowlists (no internal network access).
|
||||
- [ ] **DoD**: Operator docs include "Red Lines" (never expose Bridge port directly to internet without auth).
|
||||
|
||||
---
|
||||
|
||||
## Release Metadata
|
||||
|
||||
- [ ] **Version**: `pyproject.toml` version matches git tag.
|
||||
- [ ] **Changelog**: Updated `CHANGELOG.md` (if present) with user-facing changes.
|
||||
- [ ] **Migration**: If config/storage schema changed, explicit migration notes are in `docs/migration/` (Optional).
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
- [ ] **Gate A Passed**: (Date/Initials)
|
||||
- [ ] **Gate B Passed** (if applicable): (Date/Initials)
|
||||
@@ -55,7 +55,6 @@ def _register_routes_once():
|
||||
"""Register all Moltbot routes including Bridge and Scheduler."""
|
||||
from .api.approvals import register_approval_routes
|
||||
from .api.bridge import BridgeHandlers
|
||||
|
||||
from .api.presets import register_preset_routes
|
||||
from .api.routes import register_routes
|
||||
from .api.schedules import register_schedule_routes
|
||||
|
||||
+71
-56
@@ -47,8 +47,10 @@ else:
|
||||
|
||||
|
||||
if web:
|
||||
|
||||
class CleanupFileResponse(web.FileResponse):
|
||||
"""FileResponse that deletes the file after sending."""
|
||||
|
||||
async def prepare(self, request):
|
||||
try:
|
||||
return await super().prepare(request)
|
||||
@@ -59,6 +61,7 @@ if web:
|
||||
os.remove(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else:
|
||||
CleanupFileResponse = None
|
||||
|
||||
@@ -71,17 +74,17 @@ class PacksHandlers:
|
||||
"""GET /packs - List installed packs."""
|
||||
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
|
||||
# S8: Public read or authenticated?
|
||||
# Usually list is fine to be public-read if not strictly protected,
|
||||
# Usually list is fine to be public-read if not strictly protected,
|
||||
# but admin token check is safer for system info.
|
||||
# Plan says "Integrity (Local)", implies authenticated management.
|
||||
# list_packs might be needed for UI.
|
||||
# Let's verify admin token for consistency with other sensitive endpoints.
|
||||
# Actually, let's keep list public-ish for UI discovery?
|
||||
# Actually, let's keep list public-ish for UI discovery?
|
||||
# No, "require_admin_token" for everything per F32 is safer.
|
||||
# But for now, let's just implement listing.
|
||||
|
||||
|
||||
# NOTE: S8/F11 implies rigorous management.
|
||||
if not await self._check_auth(request):
|
||||
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
|
||||
@@ -104,14 +107,16 @@ class PacksHandlers:
|
||||
reader = await request.multipart()
|
||||
field = await reader.next()
|
||||
if not field or field.name != "file":
|
||||
return web.json_response({"ok": False, "error": "Missing file field"}, status=400)
|
||||
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Missing file field"}, status=400
|
||||
)
|
||||
|
||||
filename = field.filename or "pack.zip"
|
||||
|
||||
|
||||
# Save to temp file
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".zip")
|
||||
os.close(fd)
|
||||
|
||||
|
||||
try:
|
||||
with open(temp_path, "wb") as f:
|
||||
while True:
|
||||
@@ -119,17 +124,17 @@ class PacksHandlers:
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
overwrite = request.query.get("overwrite", "false").lower() == "true"
|
||||
|
||||
|
||||
try:
|
||||
meta = self.registry.install_pack(temp_path, overwrite=overwrite)
|
||||
return web.json_response({"ok": True, "pack": meta})
|
||||
except PackError as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
@@ -144,61 +149,71 @@ class PacksHandlers:
|
||||
|
||||
name = request.match_info.get("name")
|
||||
version = request.match_info.get("version")
|
||||
|
||||
|
||||
if not name or not version:
|
||||
return web.json_response({"ok": False, "error": "Missing name/version"}, status=400)
|
||||
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Missing name/version"}, status=400
|
||||
)
|
||||
|
||||
try:
|
||||
success = self.registry.uninstall_pack(name, version)
|
||||
if success:
|
||||
return web.json_response({"ok": True})
|
||||
else:
|
||||
return web.json_response({"ok": False, "error": "Not found"}, status=404)
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Not found"}, status=404
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
async def export_pack_handler(self, request: web.Request) -> web.Response:
|
||||
"""GET /packs/export/{name}/{version} - Download pack zip."""
|
||||
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
if not await self._check_auth(request):
|
||||
"""GET /packs/export/{name}/{version} - Download pack zip."""
|
||||
if getattr(web, "_IS_MOCKWEB", False) is True:
|
||||
raise RuntimeError("aiohttp not available")
|
||||
|
||||
if not await self._check_auth(request):
|
||||
return web.json_response({"ok": False, "error": "Unauthorized"}, status=401)
|
||||
|
||||
name = request.match_info.get("name")
|
||||
version = request.match_info.get("version")
|
||||
|
||||
if not name or not version:
|
||||
return web.json_response({"ok": False, "error": "Missing name/version"}, status=400)
|
||||
|
||||
pack_path = self.registry.get_pack_path(name, version)
|
||||
if not pack_path:
|
||||
return web.json_response({"ok": False, "error": "Pack not found"}, status=404)
|
||||
|
||||
# Create temp zip
|
||||
fd, temp_zip = tempfile.mkstemp(suffix=".zip")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
# Ensure manifest exists (it should for installed packs)
|
||||
if not os.path.exists(os.path.join(pack_path, "manifest.json")):
|
||||
return web.json_response({"ok": False, "error": "Pack manifest missing/corrupt"}, status=500)
|
||||
|
||||
# Create deterministic zip
|
||||
PackArchive.create_pack_archive(pack_path, temp_zip)
|
||||
|
||||
# Stream response
|
||||
return CleanupFileResponse(
|
||||
temp_zip,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{name}-{version}.zip"',
|
||||
"Content-Type": "application/zip",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
if os.path.exists(temp_zip):
|
||||
os.remove(temp_zip)
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
name = request.match_info.get("name")
|
||||
version = request.match_info.get("version")
|
||||
|
||||
if not name or not version:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Missing name/version"}, status=400
|
||||
)
|
||||
|
||||
pack_path = self.registry.get_pack_path(name, version)
|
||||
if not pack_path:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Pack not found"}, status=404
|
||||
)
|
||||
|
||||
# Create temp zip
|
||||
fd, temp_zip = tempfile.mkstemp(suffix=".zip")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
# Ensure manifest exists (it should for installed packs)
|
||||
if not os.path.exists(os.path.join(pack_path, "manifest.json")):
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Pack manifest missing/corrupt"}, status=500
|
||||
)
|
||||
|
||||
# Create deterministic zip
|
||||
PackArchive.create_pack_archive(pack_path, temp_zip)
|
||||
|
||||
# Stream response
|
||||
return CleanupFileResponse(
|
||||
temp_zip,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{name}-{version}.zip"',
|
||||
"Content-Type": "application/zip",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
if os.path.exists(temp_zip):
|
||||
os.remove(temp_zip)
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
async def _check_auth(self, request: web.Request) -> bool:
|
||||
# Re-use require_admin_token logic from access_control?
|
||||
|
||||
+12
-8
@@ -42,7 +42,9 @@ class LLMClient:
|
||||
async def _fetch_config(self) -> dict:
|
||||
"""Fetch LLM config from OpenClaw backend (with TTL)."""
|
||||
now = time.time()
|
||||
if self._config_cache is not None and (now - self._last_fetch < self.CONFIG_TTL):
|
||||
if self._config_cache is not None and (
|
||||
now - self._last_fetch < self.CONFIG_TTL
|
||||
):
|
||||
return self._config_cache
|
||||
|
||||
res = await self._client.get_openclaw_config()
|
||||
@@ -61,7 +63,7 @@ class LLMClient:
|
||||
# On failure, keep old cache if available (resilience)
|
||||
if self._config_cache is None:
|
||||
self._config_cache = {}
|
||||
|
||||
|
||||
return self._config_cache
|
||||
|
||||
async def is_configured(self) -> bool:
|
||||
@@ -119,17 +121,19 @@ class LLMClient:
|
||||
if res.get("ok"):
|
||||
data = res.get("text") or res.get("data", {}).get("text")
|
||||
return data or "[No response]"
|
||||
|
||||
error_msg = res.get('error', 'Request failed')
|
||||
|
||||
|
||||
error_msg = res.get("error", "Request failed")
|
||||
|
||||
# Harden error messages for user
|
||||
if "401" in error_msg or "unauthorized" in error_msg.lower():
|
||||
return "[LLM Error] API Key Invalid or Missing. Please check Settings."
|
||||
if "429" in error_msg or "quota" in error_msg.lower():
|
||||
return "[LLM Error] Rate Limit / Quota Exceeded. Please try again later."
|
||||
return (
|
||||
"[LLM Error] Rate Limit / Quota Exceeded. Please try again later."
|
||||
)
|
||||
if "503" in error_msg or "overloaded" in error_msg.lower():
|
||||
return "[LLM Error] Service Overloaded. Please try again later."
|
||||
|
||||
return "[LLM Error] Service Overloaded. Please try again later."
|
||||
|
||||
return f"[LLM Error] {error_msg}"
|
||||
except Exception:
|
||||
# Log error without user content
|
||||
|
||||
@@ -238,6 +238,13 @@ class CommandRouter:
|
||||
"""
|
||||
F32 WP3: Check if admin token is configured before running admin commands.
|
||||
Fail-fast with clear error message instead of 403/500 later.
|
||||
|
||||
IMPORTANT (recurring CI failure mode):
|
||||
- Admin-only commands are gated by BOTH:
|
||||
(1) sender is an admin user, AND
|
||||
(2) the connector admin token is configured (OPENCLAW_CONNECTOR_ADMIN_TOKEN).
|
||||
- Unit tests that exercise admin command handlers MUST set `config.admin_token`,
|
||||
otherwise they will correctly receive the config error response.
|
||||
"""
|
||||
if not self.config.admin_token:
|
||||
return CommandResponse(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Deployment Recipe 2: LAN (Restricted)
|
||||
|
||||
This recipe allows access from other devices on your local network (e.g., an iPad on WiFi) but **NOT** from the internet.
|
||||
|
||||
> [!WARNING]
|
||||
> This configuration exposes ComfyUI to **everyone** on your WiFi/LAN.
|
||||
> Do NOT use this on public WiFi (cafes, airports) or untrusted networks.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
iPad[iPad/Laptop] -->|LAN WiFi| ComfyUI
|
||||
ComfyUI -->|0.0.0.0:8188| Host
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Bind Address
|
||||
|
||||
You must tell ComfyUI to listen on all interfaces.
|
||||
|
||||
**Command:**
|
||||
|
||||
```bash
|
||||
python main.py --listen 0.0.0.0
|
||||
```
|
||||
|
||||
### 2. OpenClaw Security (Mandatory)
|
||||
|
||||
Since any device on the LAN can access the API, you MUST secure sensitive actions.
|
||||
Set these Environment Variables:
|
||||
|
||||
```ini
|
||||
# Require a token for admin actions (Stop/Approve)
|
||||
OPENCLAW_CONNECTOR_ADMIN_TOKEN=your-strong-secret-token
|
||||
|
||||
# Require a token for Logs/Config viewing
|
||||
MOLTBOT_OBSERVABILITY_TOKEN=observability-secret
|
||||
```
|
||||
|
||||
### 3. Firewall Rules (Host)
|
||||
|
||||
Ensure your host firewall blocks inbound traffic from the Internet (WAN) but allows LAN.
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "ComfyUI LAN" -Direction Inbound -LocalPort 8188 -Protocol TCP -Action Allow -RemoteAddress LocalSubnet
|
||||
```
|
||||
|
||||
*Note: `-RemoteAddress LocalSubnet` restricts access to your local network segment.*
|
||||
|
||||
**Linux (ufw):**
|
||||
|
||||
```bash
|
||||
sudo ufw allow from 192.168.1.0/24 to any port 8188
|
||||
```
|
||||
|
||||
### 4. "Red Lines"
|
||||
|
||||
- ❌ Do not forward port 8188 on your router.
|
||||
- ❌ Do not use `--listen 0.0.0.0` on a laptop connected to public WiFi.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Find your host IP (e.g., `192.168.1.10`).
|
||||
2. From another device on WiFi, visit `http://192.168.1.10:8188`.
|
||||
3. Open OpenClaw Settings.
|
||||
4. Try to view logs. It should challenge you for the `MOLTBOT_OBSERVABILITY_TOKEN` or deny access.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Deployment Recipe 1: Local-only (Default)
|
||||
|
||||
This is the **safest and recommended** configuration for most users.
|
||||
ComfyUI and OpenClaw run on your local machine, and no ports are exposed to the network.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
User[Your Browser] -->|localhost:8188| ComfyUI
|
||||
User -->|localhost:8188| OpenClaw[OpenClaw Extension]
|
||||
OpenClaw -->|localhost:11434| Ollama[Local LLM]
|
||||
OpenClaw -->|https| Providers[Cloud LLM APIs]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Bind Address
|
||||
|
||||
Ensure ComfyUI is bound to `127.0.0.1` (loopback), not `0.0.0.0`.
|
||||
This is the default for ComfyUI.
|
||||
|
||||
**Verification:**
|
||||
Run ComfyUI and check the console output:
|
||||
|
||||
```text
|
||||
Starting server
|
||||
To see the GUI go to: http://127.0.0.1:8188
|
||||
```
|
||||
|
||||
### 2. OpenClaw Settings
|
||||
|
||||
No special configuration is required.
|
||||
|
||||
- **Admin Token**: Not required for loopback-only operations (unless `OPENCLAW_CONNECTOR_ADMIN_TOKEN` is explicitly set).
|
||||
- **Webhooks**: Disabled by default.
|
||||
|
||||
### 3. "Red Lines" (What NOT to do)
|
||||
|
||||
- ❌ Do not run with `--listen 0.0.0.0` or `--listen`.
|
||||
- ❌ Do not port-forward port 8188 on your router.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Open `http://127.0.0.1:8188` in your browser.
|
||||
2. Open the OpenClaw tab in the sidebar.
|
||||
3. Go to **Settings** -> **Health**.
|
||||
4. All checks should be green.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Deployment Recipe 4: Reverse Proxy (Advanced)
|
||||
|
||||
For power users who want to run ComfyUI behind Caddy, Nginx, or Traefik.
|
||||
This adds limits, TLS, and header management.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Block Sensitive Paths**: Prevent external access to admin/debug endpoints if not needed.
|
||||
- Block `/openclaw/logs/*`
|
||||
- Block `/openclaw/config`
|
||||
2. **Timeouts**: ComfyUI generation can take time. Increase timeouts.
|
||||
- `proxy_read_timeout 600s;` (Nginx)
|
||||
3. **Websockets**: ComfyUI requires WS support.
|
||||
- `proxy_set_header Upgrade $http_upgrade;`
|
||||
- `proxy_set_header Connection "Upgrade";`
|
||||
4. **Body Size**: Image uploads can be large.
|
||||
- `client_max_body_size 100M;` (Nginx)
|
||||
|
||||
## Caddyfile Example
|
||||
|
||||
```caddy
|
||||
comfyui.local {
|
||||
reverse_proxy 127.0.0.1:8188 {
|
||||
# WebSocket support is automatic in Caddy
|
||||
}
|
||||
|
||||
# Security: Block sensitive OpenClaw paths from external access
|
||||
@sensitive path /openclaw/logs* /openclaw/config
|
||||
respond @sensitive 403
|
||||
}
|
||||
```
|
||||
|
||||
## Nginx Example
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name comfyui.local;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8188;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# Security: Forward real IP for Rate Limiting
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Timeouts for long generations
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# Block sensitive paths
|
||||
location /openclaw/logs {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
# /etc/default/openclaw.env
|
||||
# Secure environment configuration for OpenClaw
|
||||
|
||||
# Admin Token (Required for remote ops)
|
||||
OPENCLAW_CONNECTOR_ADMIN_TOKEN=change-me-to-a-strong-secret
|
||||
|
||||
# Observability Token (Required for remote logs)
|
||||
# (Legacy: MOLTBOT_OBSERVABILITY_TOKEN)
|
||||
OPENCLAW_OBSERVABILITY_TOKEN=change-me-too
|
||||
|
||||
# Bridge (Default: 0/Disabled)
|
||||
OPENCLAW_BRIDGE_ENABLED=0
|
||||
# OPENCLAW_BRIDGE_TOKEN=
|
||||
|
||||
# Network
|
||||
# Bind to localhost by default
|
||||
COMFYUI_LISTEN=127.0.0.1
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=ComfyUI with OpenClaw
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=comfyuser
|
||||
Group=comfyuser
|
||||
WorkingDirectory=/opt/ComfyUI
|
||||
EnvironmentFile=/etc/default/openclaw.env
|
||||
ExecStart=/opt/ComfyUI/venv/bin/python main.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Hardening
|
||||
ProtectSystem=full
|
||||
PrivateTmp=true
|
||||
NoNewPrivileges=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,60 @@
|
||||
# Deployment Recipe 3: Tailscale Funnel / VPN (Recommended Remote)
|
||||
|
||||
This recipe allows you to access ComfyUI securely from anywhere without opening ports on your router or exposing the service to the public internet.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Remote[Remote Device] -->|WireGuard VPN| Tailscale
|
||||
Tailscale -->|127.0.0.1:8188| ComfyUI[ComfyUI Host]
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Tailscale** installed on the ComfyUI host and your remote device (phone/laptop).
|
||||
2. A Tailscale account.
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. ComfyUI Host
|
||||
|
||||
Keep ComfyUI bound to localhost (`127.0.0.1`). Tailscale will route traffic to it via the tailnet IP.
|
||||
|
||||
**Start ComfyUI:**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
# Do NOT use --listen
|
||||
```
|
||||
|
||||
### 2. Tailscale Serve (Optional)
|
||||
|
||||
If you want to expose ComfyUI on your tailnet with a nice DNS name (e.g., `http://comfyui.monkey-magic.ts.net`), use `tailscale serve`.
|
||||
|
||||
```bash
|
||||
# Expose port 8188 to your Tailnet only
|
||||
tailscale serve --bg 8188
|
||||
```
|
||||
|
||||
Now you can access ComfyUI from any device on your Tailnet.
|
||||
|
||||
### 3. OpenClaw Hardening
|
||||
|
||||
Since traffic comes via Tailscale, it might appear as "remote" or "proxy" traffic depending on configuration.
|
||||
To be safe:
|
||||
|
||||
1. Set `OPENCLAW_CONNECTOR_ADMIN_TOKEN` to a strong secret.
|
||||
2. Enforce `MOLTBOT_OBSERVABILITY_TOKEN` if you plan to view logs remotely.
|
||||
|
||||
### 4. "Red Lines"
|
||||
|
||||
- ❌ Do not use `tailscale funnel` (public internet exposure) unless you have implemented **Gate B** (Bridge Safety) controls from the [Release Checklist](../../RELEASE_CHECKLIST.md).
|
||||
- ❌ Do not share your Tailnet with untrusted users.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Disconnect your phone from WiFi (use 5G/LTE).
|
||||
2. Enable Tailscale on your phone.
|
||||
3. Navigate to `http://100.x.y.z:8188` (your host's Tailscale IP).
|
||||
4. Verify ComfyUI loads and OpenClaw is accessible.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Windows Deployment Notes
|
||||
|
||||
Running ComfyUI + OpenClaw on Windows, especially with the Portable version.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Portable Version (`run_nvidia_gpu.bat`)
|
||||
|
||||
To set OpenClaw security tokens in the portable version, edit your `run_nvidia_gpu.bat` (or create a wrapper `run_openclaw.bat`):
|
||||
|
||||
```bat
|
||||
@echo off
|
||||
:: Security Tokens
|
||||
set OPENCLAW_CONNECTOR_ADMIN_TOKEN=my-secret-token
|
||||
set MOLTBOT_OBSERVABILITY_TOKEN=observability-token
|
||||
|
||||
:: Run ComfyUI
|
||||
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build
|
||||
pause
|
||||
```
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
$env:OPENCLAW_CONNECTOR_ADMIN_TOKEN="my-secret-token"
|
||||
./python_embeded/python.exe -s ComfyUI/main.py
|
||||
```
|
||||
|
||||
## Service Mode (NSSM)
|
||||
|
||||
If you want to run ComfyUI as a background service, use **NSSM** (Non-Sucking Service Manager).
|
||||
|
||||
1. Download NSSM.
|
||||
2. `nssm install ComfyUI`
|
||||
3. **Application**: Path to python.exe (or bat file).
|
||||
4. **Environment**: Add tokens here in the Environment tab (Input: `KEY=VALUE` per line).
|
||||
5. **I/O**: Redirect stdout/stderr to logs so you can debug startup issues.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Permissions**: Services run as `SYSTEM` by default. It is safer to create a dedicated user and set the service to Log On as that user.
|
||||
- **GPU Access**: Ensure the user running the service has access to the GPU driver context (usually fine for logged-in users, tricky for headless services).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Feature Flag Policy
|
||||
|
||||
This document lists the feature flags that control **risky capabilities** in ComfyUI-OpenClaw.
|
||||
Users should audit these flags before deploying to a public or untrusted network.
|
||||
|
||||
> [!WARNING]
|
||||
> Enabling these flags increases the attack surface. Ensure you have read the [Security Policy](../../SECURITY.md) and use appropriate network controls (e.g., Tailscale, Reverse Proxy with Auth).
|
||||
|
||||
## Risk Levels
|
||||
|
||||
- **Low**: Safe for most deployments.
|
||||
- **Medium**: Exposure risk if misconfigured; requires token auth.
|
||||
- **High**: Significant risk; enables remote execution or bypasses safety checks.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Flags
|
||||
|
||||
| Flag | Default | Risk | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `OPENCLAW_CONNECTOR_ADMIN_TOKEN` | *None* | **Medium** | Required for admin commands (stop/approve/trace) if server auth is enabled. If missing, admin commands fail safe. |
|
||||
| `OPENCLAW_ALLOW_REMOTE_ADMIN` | `0` | **High** | Be careful! Allows admin actions from non-loopback IPs if token is present. Default is loopback-only for admin. |
|
||||
| `OPENCLAW_BRIDGE_ENABLED` | `0` | **High** | Enables the sidecar bridge for remote orchestration. Requires `OPENCLAW_BRIDGE_TOKEN`. |
|
||||
| `OPENCLAW_ALLOW_ANY_PUBLIC_LLM_HOST` | `0` | **High** | Bypasses the known-host allowlist for LLM `base_url`. Allows SSRF to public IPs. |
|
||||
| `OPENCLAW_ALLOW_INSECURE_BASE_URL` | `0` | **Critical** | Allows HTTP (non-HTTPS) or private IP `base_url` for LLM. Risk of internal network scanning (SSRF). |
|
||||
| `OPENCLAW_CONNECTOR_DISCORD_TOKEN` | *None* | **Medium** | Presence enables Discord Bot gateway. |
|
||||
| `OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET` | *None* | **Medium** | Presence enables LINE webhook listener. Requires a public HTTPS endpoint. |
|
||||
| `OPENCLAW_CONNECTOR_TELEGRAM_TOKEN` | *None* | **Low** | Presence enables Telegram long-polling. Outbound only. |
|
||||
| `OPENCLAW_OBSERVABILITY_TOKEN` | *None* | **Low** | Protects `/openclaw/logs/tail` and `/openclaw/config`. Recommended for all remote deployments. |
|
||||
|
||||
---
|
||||
|
||||
## Enabling Policy
|
||||
|
||||
1. **Documentation**: Any PR adding a new risky flag MUST update this table.
|
||||
2. **Default**: All high-risk flags MUST default to `0` (Disabled).
|
||||
3. **Validation**: CI tests should run with default flags (secure) and verify risky features are unreachable.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Threat Model & Trust Boundaries
|
||||
|
||||
This document outlines the security assumptions and trust boundaries for **ComfyUI-OpenClaw**.
|
||||
Operators should use this to understand the risks of deployment.
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
### 1. The "Admin" Boundary
|
||||
|
||||
* **Who**: The person running ComfyUI (you).
|
||||
* **Access**: Full filesystem access, process execution, and secret management.
|
||||
* **Mechanism**: OS-level permissions + `OPENCLAW_CONNECTOR_ADMIN_TOKEN` (if remote).
|
||||
* **Risk**: If compromised, attacker owns the machine.
|
||||
|
||||
### 2. The "Observability" Boundary
|
||||
|
||||
* **Who**: Monitoring tools or trusted dashboards.
|
||||
* **Access**: Read-only logs (`/openclaw/logs/tail`), config (`/openclaw/config`), health.
|
||||
* **Mechanism**: `OPENCLAW_OBSERVABILITY_TOKEN`.
|
||||
* **Redaction**: Logs/Config are redacted by default to prevent secret leakage.
|
||||
|
||||
### 3. The "Connector" Boundary (ChatOps)
|
||||
|
||||
* **Who**: Chat users (Telegram/Discord/LINE).
|
||||
* **Access**:
|
||||
* **User**: `submit_job` (via Allowlisted templates), `query_status`.
|
||||
* **Admin (Chat)**: `approve_request`, `cancel_job`, `trace`.
|
||||
* **Mechanism**: Chat platform auth + OpenClaw User Allowlist (or `require_approval` policy).
|
||||
* **Risk**: Spam/DoS (mitigated by Budgets + Rate Limits), or Prompt Injection (mitigated by Template Constraints).
|
||||
|
||||
---
|
||||
|
||||
## Attack Surfaces
|
||||
|
||||
### Inbound (Server)
|
||||
|
||||
* **HTTP API**: `/openclaw/*`, `/moltbot/*`.
|
||||
* *Mitigation*: Loopback-only by default. Token auth for remote admin/observability.
|
||||
* **Webhooks**: `/openclaw/webhook/*`.
|
||||
* *Mitigation*: Signature verification (HMAC) + Replay protection + Auth Token.
|
||||
|
||||
### Outbound (Client)
|
||||
|
||||
* **LLM Requests**: `POST` to `base_url`.
|
||||
* *Risk*: SSRF (Server-Side Request Forgery) to internal network.
|
||||
* *Mitigation*: Known-host allowlist by default. Custom URLs need explicit opt-in + DNS validation.
|
||||
* **Callback Delivery**: `POST` results to webhook targets.
|
||||
* *Risk*: SSRF / Information Leakage.
|
||||
* *Mitigation*: DNS-safe validation (no private IPs).
|
||||
* **Image Fetching**: `image_url` inputs.
|
||||
* *Mitigation*: SafeIO module (size limits, no file://).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. **Transport Security**: We assume HTTPS (TLS) is provided by a reverse proxy or tunnel (Tailscale/Cloudflare). OpenClaw serves HTTP.
|
||||
2. **Local Host Security**: We assume the host machine is not already compromised.
|
||||
3. **Secret Integrity**: Secrets in `os.environ` or `.env` are secure from non-admin users.
|
||||
|
||||
## "Red Lines" (Do Not Cross)
|
||||
|
||||
* **Never** expose the raw ComfyUI port (8188) to the public internet.
|
||||
* **Never** run OpenClaw as `root` / Administrator.
|
||||
* **Never** disable `OPENCLAW_CONNECTOR_ADMIN_TOKEN` on a publicly accessible instance.
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
"test:debug": "npx playwright test --debug",
|
||||
"test:report": "npx playwright show-report"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,9 @@ class PackArchive:
|
||||
if compressed_size > 0:
|
||||
ratio = total_size / compressed_size
|
||||
if ratio > MAX_COMPRESSION_RATIO:
|
||||
raise PackError(f"Compression ratio too high ({ratio:.1f} > {MAX_COMPRESSION_RATIO})")
|
||||
raise PackError(
|
||||
f"Compression ratio too high ({ratio:.1f} > {MAX_COMPRESSION_RATIO})"
|
||||
)
|
||||
|
||||
# 2. Safety Check
|
||||
for info in infos:
|
||||
@@ -64,7 +66,7 @@ class PackArchive:
|
||||
info.filename.startswith("/")
|
||||
or ".." in info.filename
|
||||
or "\\" in info.filename
|
||||
or any(c < ' ' for c in info.filename) # Control chars
|
||||
or any(c < " " for c in info.filename) # Control chars
|
||||
):
|
||||
raise PackError(f"Unsafe filename: {info.filename}")
|
||||
|
||||
@@ -134,26 +136,26 @@ class PackArchive:
|
||||
full_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(full_path, source_dir)
|
||||
files_to_add.append((full_path, rel_path))
|
||||
|
||||
|
||||
# Deterministic order
|
||||
files_to_add.sort(key=lambda x: x[1])
|
||||
|
||||
for full_path, rel_path in files_to_add:
|
||||
# Deterministic metadata (timestamp)
|
||||
# ZipInfo requires a tuple (year, month, day, hour, min, sec)
|
||||
# We use a fixed epoch for reproducibility, or file mtime?
|
||||
# Plan says "regenerate manifest deterministically".
|
||||
# If we use file mtime, it changes if we touch files.
|
||||
# Using fixed timestamp ensures identical binary hash for identical content.
|
||||
# But standard zip tools use mtime.
|
||||
# Let's use 1980-01-01 00:00:00 (DOS epoch)
|
||||
zinfo = zipfile.ZipInfo(rel_path)
|
||||
zinfo.date_time = (1980, 1, 1, 0, 0, 0)
|
||||
zinfo.compress_type = zipfile.ZIP_DEFLATED
|
||||
|
||||
# Set regular file permissions (0o644)
|
||||
# External attr: (0o100644 << 16) = 0x81A40000
|
||||
zinfo.external_attr = 0x81A40000
|
||||
|
||||
with open(full_path, "rb") as f:
|
||||
zf.writestr(zinfo, f.read())
|
||||
# Deterministic metadata (timestamp)
|
||||
# ZipInfo requires a tuple (year, month, day, hour, min, sec)
|
||||
# We use a fixed epoch for reproducibility, or file mtime?
|
||||
# Plan says "regenerate manifest deterministically".
|
||||
# If we use file mtime, it changes if we touch files.
|
||||
# Using fixed timestamp ensures identical binary hash for identical content.
|
||||
# But standard zip tools use mtime.
|
||||
# Let's use 1980-01-01 00:00:00 (DOS epoch)
|
||||
zinfo = zipfile.ZipInfo(rel_path)
|
||||
zinfo.date_time = (1980, 1, 1, 0, 0, 0)
|
||||
zinfo.compress_type = zipfile.ZIP_DEFLATED
|
||||
|
||||
# Set regular file permissions (0o644)
|
||||
# External attr: (0o100644 << 16) = 0x81A40000
|
||||
zinfo.external_attr = 0x81A40000
|
||||
|
||||
with open(full_path, "rb") as f:
|
||||
zf.writestr(zinfo, f.read())
|
||||
|
||||
@@ -98,40 +98,40 @@ def create_manifest(base_dir: str, metadata: Dict[str, Any]) -> str:
|
||||
Returns the path to the written manifest.
|
||||
"""
|
||||
manifest_path = os.path.join(base_dir, "manifest.json")
|
||||
|
||||
|
||||
# 1. Collect all files and compute hashes
|
||||
files_list = []
|
||||
for root, _, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
if file == "manifest.json":
|
||||
continue # Do not include manifest in manifest
|
||||
|
||||
continue # Do not include manifest in manifest
|
||||
|
||||
full_path = os.path.join(root, file)
|
||||
rel_path = os.path.relpath(full_path, base_dir).replace("\\", "/") # Normalize separators
|
||||
|
||||
rel_path = os.path.relpath(full_path, base_dir).replace(
|
||||
"\\", "/"
|
||||
) # Normalize separators
|
||||
|
||||
sha = compute_sha256(full_path)
|
||||
|
||||
|
||||
# Use deterministic dictionary structure
|
||||
files_list.append({
|
||||
"path": rel_path,
|
||||
"sha256": sha,
|
||||
"size": os.path.getsize(full_path)
|
||||
})
|
||||
|
||||
files_list.append(
|
||||
{"path": rel_path, "sha256": sha, "size": os.path.getsize(full_path)}
|
||||
)
|
||||
|
||||
# 2. Sort files by path (Important for determinism)
|
||||
files_list.sort(key=lambda x: x["path"])
|
||||
|
||||
|
||||
# 3. Create manifest object
|
||||
manifest = {
|
||||
"version": metadata.get("version", "0.0.0"),
|
||||
"files": files_list,
|
||||
# Add metadata keys sorted?
|
||||
**{k: v for k, v in sorted(metadata.items()) if k != "version"}
|
||||
**{k: v for k, v in sorted(metadata.items()) if k != "version"},
|
||||
}
|
||||
|
||||
|
||||
# 4. Write with sort_keys=True
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2, sort_keys=True)
|
||||
f.write("\n") # POSIX newline
|
||||
|
||||
f.write("\n") # POSIX newline
|
||||
|
||||
return manifest_path
|
||||
|
||||
@@ -108,7 +108,6 @@ ENV_MAPPINGS = {
|
||||
# R14: Failover env vars
|
||||
"fallback_models": ("OPENCLAW_FALLBACK_MODELS", "MOLTBOT_FALLBACK_MODELS"),
|
||||
"fallback_providers": ("OPENCLAW_FALLBACK_PROVIDERS", "MOLTBOT_FALLBACK_PROVIDERS"),
|
||||
|
||||
"max_failover_candidates": (
|
||||
"OPENCLAW_MAX_FAILOVER_CANDIDATES",
|
||||
"MOLTBOT_MAX_FAILOVER_CANDIDATES",
|
||||
@@ -209,24 +208,29 @@ def get_scheduler_config() -> Dict[str, Any]:
|
||||
if env_vars:
|
||||
primary, _ = env_vars
|
||||
val = os.environ.get(primary)
|
||||
|
||||
|
||||
if val is not None:
|
||||
# Parse
|
||||
if key == "skip_missed_intervals":
|
||||
effective[key] = str(val).strip().lower() in ("1", "true", "yes", "on")
|
||||
effective[key] = str(val).strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
elif key in SCHEDULER_CONSTRAINTS:
|
||||
try:
|
||||
val_int = int(val)
|
||||
effective[key] = _clamp(val_int, *SCHEDULER_CONSTRAINTS[key])
|
||||
except ValueError:
|
||||
effective[key] = defaults[key]
|
||||
effective[key] = defaults[key]
|
||||
else:
|
||||
effective[key] = val
|
||||
continue
|
||||
|
||||
|
||||
# Use default
|
||||
effective[key] = defaults.get(key)
|
||||
|
||||
|
||||
return effective
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ Background tick loop for executing due schedules.
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
@@ -176,7 +176,7 @@ class SchedulerRunner:
|
||||
|
||||
# R34: Read config once at startup for jitter/skip behavior
|
||||
config = get_scheduler_config()
|
||||
|
||||
|
||||
# 1. Startup Jitter
|
||||
jitter_sec = config.get("startup_jitter_sec", 0)
|
||||
if jitter_sec > 0:
|
||||
@@ -216,12 +216,12 @@ class SchedulerRunner:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_ts = now.timestamp()
|
||||
schedules = self._store.list_all()
|
||||
|
||||
|
||||
skipped_count = 0
|
||||
for schedule in schedules:
|
||||
if not schedule.enabled:
|
||||
continue
|
||||
|
||||
|
||||
is_due = False
|
||||
if schedule.trigger_type == TriggerType.CRON:
|
||||
is_due = is_cron_due(schedule.cron_expr, schedule.last_tick_ts, now)
|
||||
@@ -229,22 +229,24 @@ class SchedulerRunner:
|
||||
is_due = is_interval_due(
|
||||
schedule.interval_sec, schedule.last_tick_ts, now_ts
|
||||
)
|
||||
|
||||
|
||||
if is_due:
|
||||
# Update cursor without running
|
||||
# Use a special run_id to indicate skip
|
||||
schedule.update_cursor(now_ts, "skipped_startup")
|
||||
self._store.update(schedule)
|
||||
skipped_count += 1
|
||||
|
||||
|
||||
if skipped_count > 0:
|
||||
logger.info(f"Skipped {skipped_count} missed schedules due to startup policy.")
|
||||
logger.info(
|
||||
f"Skipped {skipped_count} missed schedules due to startup policy."
|
||||
)
|
||||
|
||||
def _tick(self) -> None:
|
||||
"""Process one scheduler tick."""
|
||||
now = datetime.now(timezone.utc)
|
||||
now_ts = now.timestamp()
|
||||
|
||||
|
||||
# R34: Dynamic config read for runtime tuning
|
||||
config = get_scheduler_config()
|
||||
max_runs = config.get("max_runs_per_tick", 5)
|
||||
@@ -270,7 +272,7 @@ class SchedulerRunner:
|
||||
|
||||
if due_schedules:
|
||||
logger.debug(f"Found {len(due_schedules)} due schedules")
|
||||
|
||||
|
||||
# R34: Cap max runs per tick
|
||||
if len(due_schedules) > max_runs:
|
||||
logger.warning(
|
||||
|
||||
@@ -16,6 +16,12 @@ class TestCommandRouterPhase2(unittest.TestCase):
|
||||
self.config = ConnectorConfig()
|
||||
# Admin setup
|
||||
self.config.admin_users = ["999", "admin_user"]
|
||||
# IMPORTANT (F32 WP3):
|
||||
# Admin commands in the connector require the *connector* admin token to be configured,
|
||||
# even if the sender is an admin user. Otherwise the router will fail fast with:
|
||||
# "[Error] Admin token not configured. Set OPENCLAW_CONNECTOR_ADMIN_TOKEN ..."
|
||||
# These unit tests are meant to exercise the admin command handlers, so we set it here.
|
||||
self.config.admin_token = "test-admin-token"
|
||||
|
||||
self.client = MagicMock()
|
||||
self.client.get_health = AsyncMock(
|
||||
|
||||
@@ -15,6 +15,10 @@ class TestCommandRouterPhase3(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.config = ConnectorConfig()
|
||||
self.config.admin_users = ["999"]
|
||||
# IMPORTANT (F32 WP3):
|
||||
# Admin-only connector commands (e.g., /trace) require the connector admin token
|
||||
# to be configured. Otherwise the router will fail fast with a config error.
|
||||
self.config.admin_token = "test-admin-token"
|
||||
self.client = MagicMock()
|
||||
self.client.get_health = AsyncMock(return_value={"ok": True})
|
||||
self.client.get_prompt_queue = AsyncMock(
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import unittest
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
from connector.openclaw_client import OpenClawClient
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from connector.config import ConnectorConfig
|
||||
from connector.openclaw_client import OpenClawClient
|
||||
|
||||
# Mock aiohttp
|
||||
sys.modules["aiohttp"] = MagicMock()
|
||||
|
||||
|
||||
class TestClientHeader(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_admin_header_present(self):
|
||||
"""Verify X-OpenClaw-Admin-Token is set when config has token."""
|
||||
config = ConnectorConfig()
|
||||
config.admin_token = "my-secret-token"
|
||||
|
||||
|
||||
client = OpenClawClient(config)
|
||||
self.assertIn("X-OpenClaw-Admin-Token", client.headers)
|
||||
self.assertEqual(client.headers["X-OpenClaw-Admin-Token"], "my-secret-token")
|
||||
@@ -21,9 +23,10 @@ class TestClientHeader(unittest.IsolatedAsyncioTestCase):
|
||||
"""Verify X-OpenClaw-Admin-Token is NOT set when token is empty."""
|
||||
config = ConnectorConfig()
|
||||
config.admin_token = ""
|
||||
|
||||
|
||||
client = OpenClawClient(config)
|
||||
self.assertNotIn("X-OpenClaw-Admin-Token", client.headers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from connector.llm_client import LLMClient
|
||||
|
||||
|
||||
class TestLLMClientF30(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
self.mock_client = MagicMock()
|
||||
@@ -12,62 +14,61 @@ class TestLLMClientF30(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_config_ttl(self, mock_time):
|
||||
# 1. Initial fetch
|
||||
mock_time.return_value = 1000
|
||||
self.mock_client.get_openclaw_config = AsyncMock(return_value={
|
||||
"ok": True,
|
||||
"data": {"config": {"provider": "p1"}}
|
||||
})
|
||||
|
||||
self.mock_client.get_openclaw_config = AsyncMock(
|
||||
return_value={"ok": True, "data": {"config": {"provider": "p1"}}}
|
||||
)
|
||||
|
||||
cfg1 = await self.llm._fetch_config()
|
||||
self.assertEqual(cfg1["provider"], "p1")
|
||||
self.assertEqual(self.mock_client.get_openclaw_config.call_count, 1)
|
||||
|
||||
|
||||
# 2. Cached fetch (time < TTL)
|
||||
mock_time.return_value = 1010 # +10s
|
||||
mock_time.return_value = 1010 # +10s
|
||||
cfg2 = await self.llm._fetch_config()
|
||||
self.assertEqual(self.mock_client.get_openclaw_config.call_count, 1) # Still 1
|
||||
|
||||
self.assertEqual(self.mock_client.get_openclaw_config.call_count, 1) # Still 1
|
||||
|
||||
# 3. Expired fetch (time > TTL)
|
||||
mock_time.return_value = 1070 # +70s (>60s)
|
||||
mock_time.return_value = 1070 # +70s (>60s)
|
||||
self.mock_client.get_openclaw_config.return_value = {
|
||||
"ok": True,
|
||||
"data": {"config": {"provider": "p2"}}
|
||||
"ok": True,
|
||||
"data": {"config": {"provider": "p2"}},
|
||||
}
|
||||
|
||||
|
||||
cfg3 = await self.llm._fetch_config()
|
||||
self.assertEqual(cfg3["provider"], "p2")
|
||||
self.assertEqual(self.mock_client.get_openclaw_config.call_count, 2) # Incremented
|
||||
self.assertEqual(
|
||||
self.mock_client.get_openclaw_config.call_count, 2
|
||||
) # Incremented
|
||||
|
||||
async def test_error_handling_401(self):
|
||||
self.llm.is_configured = AsyncMock(return_value=True)
|
||||
self.mock_client.chat_llm = AsyncMock(return_value={
|
||||
"ok": False,
|
||||
"error": "HTTP 401: Unauthorized"
|
||||
})
|
||||
|
||||
self.mock_client.chat_llm = AsyncMock(
|
||||
return_value={"ok": False, "error": "HTTP 401: Unauthorized"}
|
||||
)
|
||||
|
||||
resp = await self.llm.chat("sys", "user")
|
||||
self.assertIn("API Key Invalid", resp)
|
||||
self.assertIn("Settings", resp)
|
||||
|
||||
async def test_error_handling_429(self):
|
||||
self.llm.is_configured = AsyncMock(return_value=True)
|
||||
self.mock_client.chat_llm = AsyncMock(return_value={
|
||||
"ok": False,
|
||||
"error": "HTTP 429: Too Many Requests"
|
||||
})
|
||||
|
||||
self.mock_client.chat_llm = AsyncMock(
|
||||
return_value={"ok": False, "error": "HTTP 429: Too Many Requests"}
|
||||
)
|
||||
|
||||
resp = await self.llm.chat("sys", "user")
|
||||
self.assertIn("Rate Limit", resp)
|
||||
self.assertIn("Quota Exceeded", resp)
|
||||
|
||||
|
||||
async def test_generic_error(self):
|
||||
self.llm.is_configured = AsyncMock(return_value=True)
|
||||
self.mock_client.chat_llm = AsyncMock(return_value={
|
||||
"ok": False,
|
||||
"error": "Something went wrong"
|
||||
})
|
||||
|
||||
self.mock_client.chat_llm = AsyncMock(
|
||||
return_value={"ok": False, "error": "Something went wrong"}
|
||||
)
|
||||
|
||||
resp = await self.llm.chat("sys", "user")
|
||||
self.assertEqual(resp, "[LLM Error] Something went wrong")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
from connector.router import CommandRouter
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from connector.config import ConnectorConfig
|
||||
from connector.contract import CommandRequest, CommandResponse
|
||||
from connector.router import CommandRouter
|
||||
|
||||
|
||||
class TestRouterAdminEnforcement(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self):
|
||||
@@ -11,12 +13,12 @@ class TestRouterAdminEnforcement(unittest.IsolatedAsyncioTestCase):
|
||||
self.config.admin_users = {"12345"}
|
||||
# IMPORTANT: Initially unset admin_token to test failure
|
||||
self.config.admin_token = ""
|
||||
|
||||
|
||||
self.client = MagicMock()
|
||||
self.client.interrupt_output = AsyncMock(return_value={"ok": True})
|
||||
self.client.get_approvals = AsyncMock(return_value={"ok": True, "items": []})
|
||||
self.client.approve_request = AsyncMock(return_value={"ok": True})
|
||||
|
||||
|
||||
self.router = CommandRouter(self.config, self.client)
|
||||
|
||||
async def test_admin_commands_fail_without_token(self):
|
||||
@@ -24,10 +26,10 @@ class TestRouterAdminEnforcement(unittest.IsolatedAsyncioTestCase):
|
||||
req = CommandRequest(
|
||||
platform="telegram",
|
||||
channel_id="100",
|
||||
sender_id="12345", # is admin
|
||||
sender_id="12345", # is admin
|
||||
username="tester",
|
||||
message_id="msg1",
|
||||
text="", # set in loop
|
||||
text="", # set in loop
|
||||
timestamp=123.456,
|
||||
)
|
||||
|
||||
@@ -38,26 +40,26 @@ class TestRouterAdminEnforcement(unittest.IsolatedAsyncioTestCase):
|
||||
"/reject 123",
|
||||
"/schedules",
|
||||
"/schedule run 1",
|
||||
"/trace 123"
|
||||
"/trace 123",
|
||||
]
|
||||
|
||||
for cmd in test_commands:
|
||||
req.text = cmd
|
||||
res = await self.router.handle(req)
|
||||
self.assertIn(
|
||||
"[Error] Admin token not configured",
|
||||
"[Error] Admin token not configured",
|
||||
res.text,
|
||||
f"Command '{cmd}' should fail with config error"
|
||||
f"Command '{cmd}' should fail with config error",
|
||||
)
|
||||
|
||||
async def test_admin_commands_succeed_with_token(self):
|
||||
"""Verify admin commands proceed when token is set."""
|
||||
self.config.admin_token = "secret-token"
|
||||
|
||||
|
||||
req = CommandRequest(
|
||||
platform="telegram",
|
||||
channel_id="100",
|
||||
sender_id="12345", # is admin
|
||||
sender_id="12345", # is admin
|
||||
username="tester",
|
||||
message_id="msg2",
|
||||
text="/stop",
|
||||
|
||||
@@ -11,6 +11,7 @@ from api.packs import CleanupFileResponse, PacksHandlers
|
||||
from services.packs.pack_archive import PackArchive, PackError
|
||||
from services.packs.pack_manifest import create_manifest
|
||||
|
||||
|
||||
class MockMultipartReader:
|
||||
def __init__(self, field):
|
||||
self._field = field
|
||||
@@ -111,7 +112,12 @@ class TestPacksIntegrity(unittest.TestCase):
|
||||
with open(os.path.join(src_dir, "a.txt"), "w") as f:
|
||||
f.write("a")
|
||||
|
||||
metadata = {"name": "test", "version": "1.0.0", "type": "template", "author": "me"}
|
||||
metadata = {
|
||||
"name": "test",
|
||||
"version": "1.0.0",
|
||||
"type": "template",
|
||||
"author": "me",
|
||||
}
|
||||
|
||||
manifest_path = create_manifest(src_dir, metadata)
|
||||
|
||||
@@ -146,7 +152,9 @@ class TestPacksApiAsync(unittest.IsolatedAsyncioTestCase):
|
||||
# Mock auth
|
||||
handlers._check_auth = AsyncMock(return_value=True)
|
||||
|
||||
handlers.registry.install_pack = MagicMock(return_value={"name": "mypack", "version": "1.0.0"})
|
||||
handlers.registry.install_pack = MagicMock(
|
||||
return_value={"name": "mypack", "version": "1.0.0"}
|
||||
)
|
||||
|
||||
field = MockField("pack.zip", b"dummy_content")
|
||||
request = MockRequest(reader=MockMultipartReader(field))
|
||||
|
||||
+38
-38
@@ -1,17 +1,14 @@
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch, ANY
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
# We need to import the class from the module
|
||||
# Adjust import to match project structure
|
||||
# services.scheduler.runner is likely importable if running from root
|
||||
from services.scheduler.runner import SchedulerRunner
|
||||
from services.scheduler.models import Schedule, TriggerType
|
||||
from services.scheduler.runner import SchedulerRunner
|
||||
from services.scheduler.storage import ScheduleStore
|
||||
|
||||
|
||||
class TestSchedulerR34(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Mock dependencies patches
|
||||
@@ -19,14 +16,14 @@ class TestSchedulerR34(unittest.TestCase):
|
||||
self.mock_get_store = self.store_patcher.start()
|
||||
self.mock_store = MagicMock(spec=ScheduleStore)
|
||||
self.mock_get_store.return_value = self.mock_store
|
||||
|
||||
|
||||
self.config_patcher = patch("services.scheduler.runner.get_scheduler_config")
|
||||
self.mock_get_config = self.config_patcher.start()
|
||||
self.mock_get_config.return_value = {} # Default
|
||||
|
||||
self.mock_get_config.return_value = {} # Default
|
||||
|
||||
self.runner = SchedulerRunner(submit_fn=MagicMock(), tick_interval=0.1)
|
||||
# Prevent actual thread start in tests unless needed
|
||||
self.runner._stop_event = MagicMock() # Mock the event to hijack wait
|
||||
self.runner._stop_event = MagicMock() # Mock the event to hijack wait
|
||||
|
||||
def tearDown(self):
|
||||
self.store_patcher.stop()
|
||||
@@ -36,39 +33,39 @@ class TestSchedulerR34(unittest.TestCase):
|
||||
"""Test startup jitter logic in _run_loop."""
|
||||
# Setup config
|
||||
self.mock_get_config.return_value = {"startup_jitter_sec": 10}
|
||||
|
||||
|
||||
# We want to verify `_stop_event.wait` is called with a random float <= 10
|
||||
# and then loop breaks.
|
||||
|
||||
|
||||
# To break loop: stop_event.is_set() -> True
|
||||
self.runner._stop_event.is_set.side_effect = [False, True] # Run once then stop
|
||||
self.runner._stop_event.is_set.side_effect = [False, True] # Run once then stop
|
||||
# Mock wait to return False (timeout didn't happen, or did, doesn't matter for first call)
|
||||
self.runner._stop_event.wait.return_value = False
|
||||
|
||||
|
||||
# Mock random
|
||||
with patch("services.scheduler.runner.random.uniform") as mock_uniform:
|
||||
mock_uniform.return_value = 5.5
|
||||
|
||||
|
||||
# Mock _tick to avoid logic error
|
||||
self.runner._tick = MagicMock()
|
||||
|
||||
|
||||
self.runner._run_loop()
|
||||
|
||||
|
||||
# Assert random called
|
||||
mock_uniform.assert_called_with(0, 10)
|
||||
|
||||
|
||||
# Assert wait called with delay
|
||||
# First call should be the jitter wait
|
||||
# Second call would be tick interval wait
|
||||
# We check the call args list
|
||||
calls = self.runner._stop_event.wait.call_args_list
|
||||
self.assertGreaterEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0].kwargs.get('timeout'), 5.5)
|
||||
self.assertEqual(calls[0].kwargs.get("timeout"), 5.5)
|
||||
|
||||
def test_max_runs_per_tick(self):
|
||||
"""Test execution capping."""
|
||||
self.mock_get_config.return_value = {"max_runs_per_tick": 2} # Low capacity
|
||||
|
||||
self.mock_get_config.return_value = {"max_runs_per_tick": 2} # Low capacity
|
||||
|
||||
# Setup 5 due schedules
|
||||
schedules = []
|
||||
for i in range(5):
|
||||
@@ -76,25 +73,27 @@ class TestSchedulerR34(unittest.TestCase):
|
||||
s.enabled = True
|
||||
s.trigger_type = TriggerType.INTERVAL
|
||||
s.interval_sec = 1
|
||||
s.last_tick_ts = 100 + i # Vary timestamps to test sorting if applicable
|
||||
s.last_tick_ts = 100 + i # Vary timestamps to test sorting if applicable
|
||||
schedules.append(s)
|
||||
|
||||
|
||||
self.mock_store.list_all.return_value = schedules
|
||||
|
||||
|
||||
# Mock is_interval_due to return True
|
||||
with patch("services.scheduler.runner.is_interval_due", return_value=True):
|
||||
# Mock execute
|
||||
self.runner._execute_schedule = MagicMock()
|
||||
|
||||
|
||||
self.runner._tick()
|
||||
|
||||
|
||||
# Should be capped at 2
|
||||
self.assertEqual(self.runner._execute_schedule.call_count, 2)
|
||||
|
||||
|
||||
# R34 says: "Sort by last_tick_ts found ... due_schedules.sort(key=lambda s: s.last_tick_ts or 0)"
|
||||
# We set last_tick_ts=100..104. Ascending order means 100 and 101 should run.
|
||||
# Check call args to verify which ones ran
|
||||
executed_schedules = [call.args[0] for call in self.runner._execute_schedule.call_args_list]
|
||||
executed_schedules = [
|
||||
call.args[0] for call in self.runner._execute_schedule.call_args_list
|
||||
]
|
||||
self.assertEqual(len(executed_schedules), 2)
|
||||
# Verify priority (100 is oldest timestamp)
|
||||
# Note: 100 < 101. So 100 is "oldest execution" or "oldest successful run"?
|
||||
@@ -107,22 +106,23 @@ class TestSchedulerR34(unittest.TestCase):
|
||||
"""Test skip logic."""
|
||||
# Logic is in _skip_missed_ticks
|
||||
# We simulate it being called (by _run_loop if config set)
|
||||
|
||||
|
||||
s = MagicMock(spec=Schedule)
|
||||
s.enabled = True
|
||||
s.trigger_type = TriggerType.INTERVAL
|
||||
s.interval_sec = 1
|
||||
s.last_tick_ts = 0 # old
|
||||
|
||||
s.last_tick_ts = 0 # old
|
||||
|
||||
self.mock_store.list_all.return_value = [s]
|
||||
|
||||
|
||||
# Mock is_interval_due -> True
|
||||
with patch("services.scheduler.runner.is_interval_due", return_value=True):
|
||||
self.runner._skip_missed_ticks()
|
||||
|
||||
# Verify cursor update
|
||||
s.update_cursor.assert_called_with(ANY, "skipped_startup")
|
||||
self.mock_store.update.assert_called_with(s)
|
||||
self.runner._skip_missed_ticks()
|
||||
|
||||
# Verify cursor update
|
||||
s.update_cursor.assert_called_with(ANY, "skipped_startup")
|
||||
self.mock_store.update.assert_called_with(s)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user