public release gates + deploy recipes: add release checklist, threat model, feature-flag policy, and Node18-based validation workflow

This commit is contained in:
rookiestar28
2026-02-09 17:52:16 +08:00
parent 1505fdb0bc
commit 387782a165
26 changed files with 762 additions and 207 deletions
+78
View File
@@ -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)
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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(
+70
View File
@@ -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.
+48
View File
@@ -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.
+60
View File
@@ -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;
}
}
```
+17
View File
@@ -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
+21
View File
@@ -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
+60
View File
@@ -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.
+42
View File
@@ -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).
+37
View File
@@ -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.
+65
View File
@@ -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
View File
@@ -14,4 +14,4 @@
"test:debug": "npx playwright test --debug",
"test:report": "npx playwright show-report"
}
}
}
+23 -21
View File
@@ -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())
+17 -17
View File
@@ -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
+10 -6
View File
@@ -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
+11 -9
View File
@@ -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(
+8 -5
View File
@@ -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()
+33 -32
View File
@@ -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()
+13 -11
View File
@@ -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",
+10 -2
View File
@@ -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
View File
@@ -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()