Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9594ec428 | ||
|
|
b6b2356389 | ||
|
|
c3375c61af | ||
|
|
d39c0c89fe | ||
|
|
e1a0187f8f | ||
|
|
ac181210d1 | ||
|
|
e47ce1dd43 |
@@ -5,6 +5,70 @@ All notable changes to `ComfyUI-LeafFlow` will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.3.1] - 2026-09-22
|
||||
|
||||
### Security & Hardening
|
||||
- **Universal CSRF Protection Across All Mutation Routes**:
|
||||
- Protected `POST /leafflow/prompt_iterator/reset_node`, `POST /leafflow/prompt_iterator/clear`, and `POST /leafflow/prompt_iterator/open_file` with `is_authenticated_local_request`.
|
||||
- Protected `POST /leafflow/decision` with `is_authenticated_local_request`.
|
||||
- Protected queue-mutating endpoints (`POST /pause_queue/toggle`, `POST /pause_queue/mode`, `POST /pause_queue/continue`, `POST /persistent_queue/claim`, `POST /leafflow/batch_queue/sync`, `POST /leafflow/assets/restore`) with `is_authenticated_local_request`.
|
||||
- Updated all frontend callers in `web/` to send the `X-LeafFlow-CSRF-Token` session header via `authenticatedFetch`.
|
||||
- **SSRF Protection & Scheme Filtering for Preview Scrapers**:
|
||||
- Added `is_safe_external_image_url` validating that downloaded image URLs use standard `https` or `http` schemes.
|
||||
- Strictly blocked internal loopback (`127.0.0.1`, `localhost`), private networks (`10.*`, `172.16.*`, `192.168.*`), and link-local cloud metadata endpoints (`169.254.*`).
|
||||
- **Safe Write & Permission Error Handling**:
|
||||
- Wrapped downloaded preview image disk writes in `try / except (PermissionError, OSError)` with clear logging to prevent crashes on read-only directories or shared network mounts.
|
||||
- **Canonical Confinement for OS Application Launches**:
|
||||
- Verified that `open_prompt_iterator_file` can only open files strictly confined within the authorized ComfyUI user directory (`USER_DIR`) via `os.path.commonpath`.
|
||||
- **Conditional Core Queue Monkey-Patching**:
|
||||
- Guarded `PersistentQueueManager.patch_server()` so core ComfyUI `prompt_queue` methods are not touched when `ENABLE_PERSISTENT_QUEUE=false`.
|
||||
- **Explicit Scripts Confinement Documentation & UI Canvas Indicators**:
|
||||
- Added explicit italic indicator `📂 Script Location: ComfyUI/scripts/` to `RunLocalFileNode` canvas foreground.
|
||||
- Added widget tooltips and settings descriptions clarifying that scripts and working directories must reside exclusively in `ComfyUI/scripts/`.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] - 2026-09-20
|
||||
|
||||
### Added
|
||||
- **🍃 ⚡ "Run Local File" Node (`RunLocalFileNode`)**:
|
||||
- Executes local scripts and binaries (`.bat`, `.cmd`, `.ps1`, `.exe` on Windows; `.sh` and executables on Linux/macOS) directly within ComfyUI workflows.
|
||||
- Parameter arguments supporting multi-line or standard quotes (`shlex` tokenized, safe non-shell execution).
|
||||
- Configurable working directory (`cwd`), defaulting to the script's directory if unspecified.
|
||||
- **Synchronous Mode**: Captures `stdout`, `stderr`, `exit_code`, and `success` boolean outputs, with real-time ComfyUI cancel button interrupt handling and configurable execution timeout.
|
||||
- **Asynchronous Mode**: Detaches process to run in the background without blocking the ComfyUI generation queue.
|
||||
- Optional `trigger` wildcard input and `passthrough` wildcard output for sequencing file execution in any pipeline.
|
||||
- **Multi-Layered Security Sandbox**:
|
||||
- Confined strictly to `ComfyUI/scripts/` via canonical `realpath` and `commonpath` validation.
|
||||
- Strict rejection of absolute paths (`C:\`, `/etc/`, `\\server`) and directory traversal sequences (`..`).
|
||||
- Never invokes `shell=True` (eliminating command chaining and command injection vulnerabilities).
|
||||
- Recursive process tree termination on timeout or cancellation (uses `taskkill /F /T` on Windows to prevent orphaned background processes).
|
||||
- **In-Memory Operator Authorization Gate**: Removed `security_consent` from `INPUT_TYPES` so consent never serializes into workflow JSON or dropped image metadata. Requires active canvas button click `[⚡ Authorize Run]` with 5-minute single-use memory token.
|
||||
- Visual node status indicator (`[ARMED]` in emerald green vs `[DISARMED]` in red).
|
||||
- Global toggle setting (`LeafFlow.9 - 🛡️ Security.01_AllowLocalFileExecution`, default `false`).
|
||||
- **🛡️ CSRF Defense & Request Authentication**:
|
||||
- Added `is_authenticated_local_request` validating loopback origin, `Sec-Fetch-Site` header (blocking `cross-site` calls from visited web pages), and `Origin`/`Referer` headers.
|
||||
- Cryptographically secure per-session CSRF token (`X-LeafFlow-CSRF-Token`) generated via `secrets.token_hex(32)` and validated with `secrets.compare_digest`.
|
||||
- Added frontend auth helper (`web/js/auth_helper.js`) automatically fetching and attaching CSRF tokens to administrative fetch requests.
|
||||
- **⚡ Two-Step Power Action Handshake**:
|
||||
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` plus a two-step confirmation handshake (`POST /leafflow/power/request_token` to issue a 30s single-use ticket, followed by `POST /leafflow/power/confirm_action` to consume and execute).
|
||||
|
||||
### Fixed & Compliance
|
||||
- **Complete Removal of `ALLOW_ANY_SCRIPT_PATH`**: Eliminated all path escape settings to guarantee execution confinement to `ComfyUI/scripts/`.
|
||||
- **Dynamic Node Count**: Replaced hardcoded node count in startup banner with dynamic `len(NODE_CLASS_MAPPINGS)` (now reporting all 16 registered nodes).
|
||||
- **Universal Synchronized Versioning**: Version `2.3.0` synchronized across `__init__.py`, `pyproject.toml`, `/leafflow/debug/export`, `README.md`, and `CHANGELOG.md`.
|
||||
- **Process Management Security Toggle**: Server restart and shutdown controls are disabled by default (`ALLOW_PROCESS_MANAGEMENT=false`). If disabled, attempts to trigger restart/shutdown via the UI or API return HTTP 403 with clear instructions.
|
||||
- **Optional `pystray` Dependency**: Made `pystray` an optional extra (`[project.optional-dependencies] tray = ["pystray"]`) and removed all runtime `pip install` subprocess calls, ensuring zero crashes on headless cloud servers, Docker, or Google Colab.
|
||||
- **Universal Loopback Protection**: Added `is_local_request(request)` validation to all legacy queue endpoints (`/pause_queue/*` and `/persistent_queue/claim`).
|
||||
- **Windows Console Unicode Safety**: Added `UnicodeEncodeError` guard around startup emoji printing for legacy Windows terminals.
|
||||
- **Clean Dynamic Imports**: Replaced static imports of desktop private `app.*` in `assets_restore.py` with dynamic `importlib.import_module` calls.
|
||||
- **Cleaned Dead Code**: Removed orphaned canvas classes from `queue_control.py`.
|
||||
|
||||
### Testing
|
||||
- Comprehensive automated test coverage expanded to **105 unit tests** covering all nodes, CSRF authentication, power action tickets, in-memory runner authorization, and path confinement.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] - 2026-09-08
|
||||
|
||||
> ⚠️ **Important Frontend Compatibility Notice**:
|
||||
|
||||
@@ -217,19 +217,37 @@ Scans input prompt text for `<lora:name:strength>` tags or names matching a fold
|
||||
<summary><b>🍃 🔄 Prompt Queue Iterator</b> (<code>PromptQueueIterator</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Parses multiline prompts or batch text blocks separated by double newlines (`\n\n`), automatically popping the top prompt per queue iteration and keeping remaining prompts in memory.
|
||||
Deterministically iterates over multiline prompt text blocks per queue run with live progress display, counter reset controls, and index tracking. Supports splitting prompts by empty lines, newlines, or a custom regular expression delimiter.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`prompts`** (`STRING`, Multiline): Queue of prompt blocks.
|
||||
- **`mode`** (`COMBO`): `Double Newline (\n\n)` vs `Single Line (\n)`.
|
||||
- **`delete_after_queue`** (`BOOLEAN`): Pop executed block from widget text.
|
||||
- **`pop_mode`** (`COMBO`): `Sequential (Loop on End)`, `Sequential (Stop on End)`, `Random (Keep)`, `Random (Cycle)`.
|
||||
- **`separator`** (`COMBO`): Split prompts by `>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`.
|
||||
- **`custom_regex`** (`STRING`): Custom regular expression delimiter (e.g. `\n---\n`). Automatically enabled when `separator` is set to `Custom Regex` and disabled otherwise.
|
||||
- **`text`** (`STRING`, Multiline): Multiline prompt text containing your queued blocks.
|
||||
- **`prompt`** (`STRING`, Optional Input): Connect an external multiline string node (such as *Prompt Counter*).
|
||||
|
||||
#### Outputs
|
||||
- **`active_prompt`** (`STRING`): The current prompt block for this run.
|
||||
- **`remaining_prompts`** (`STRING`): The queue of remaining prompts.
|
||||
- **`prompt`** (`STRING`): The selected prompt block for the current queue run.
|
||||
- **`remaining_text`** (`STRING`): All remaining prompt blocks joined by the delimiter.
|
||||
- **`remaining_count`** (`INT`): Count of remaining items in queue.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 📝 Prompt Counter</b> (<code>PromptCounter</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Multiline text prompt input node that functions identically to a standard multiline string node, with real-time prompt counting according to your chosen delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`). Displays the live prompt count directly on the node (e.g. `🍃 24 Prompts`) on every keystroke, allowing you to easily determine and set the exact ComfyUI batch size before running your queue.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`text`** (`STRING`, Multiline): Input prompt text block.
|
||||
- **`separator`** (`COMBO`, *Advanced*): Prompt delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, `Custom Regex`). Hidden by default under Advanced Options.
|
||||
- **`custom_regex`** (`STRING`, *Advanced*): Custom regex delimiter pattern (e.g. `\n---\n`). Dynamically enabled/disabled based on delimiter selection.
|
||||
|
||||
#### Outputs
|
||||
- **`STRING`**: The unmodified prompt text (ready to connect into samplers or *Prompt Queue Iterator*).
|
||||
- **`count`** (`INT`): The registered prompt count integer.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 🔤 Multi Text Replacer</b> (<code>MultiTextReplacer</code>)</summary>
|
||||
|
||||
@@ -266,6 +284,32 @@ Splits text into two parts at a specified delimiter. Supports forward (from star
|
||||
- **`delimiter_found`** (`BOOLEAN`): True if delimiter was found, False otherwise.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 ⚡ Run Local File</b> (<code>RunLocalFileNode</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Safely executes a local script or executable (`.bat`, `.cmd`, `.ps1`, `.exe` on Windows; `.sh` or binaries on Linux/macOS) with command-line parameters and working directory control. Built with multi-layered security guards to ensure malicious workflows or dropped images cannot execute code without explicit authorization.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Scripts Directory Restriction**: For operator security, this node can **ONLY execute scripts and binaries located inside the `ComfyUI/scripts/` directory** (e.g. `ComfyUI/scripts/my_script.bat`). Relative subfolders (such as `subfolder/process.sh`) are supported as long as they resolve inside `ComfyUI/scripts/`. Absolute paths (e.g. `C:\...` or `/bin/...`) and directory traversal sequences (`..`) are permanently blocked.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`file_path`** (`STRING`): Relative path to the executable or script (e.g. `process.bat`, `subfolder/script.sh`). Strictly confined to `ComfyUI/scripts/` with absolute path and traversal (`..`) rejection.
|
||||
- **`parameters`** (`STRING`, Multiline, Optional): Command-line arguments. Supports spaces and standard quoting (parsed safely via `shlex` without shell expansion).
|
||||
- **`working_directory`** (`STRING`, Optional): Execution working directory (`cwd`). Defaults to the folder containing the script if left blank.
|
||||
- **`run_mode`** (`COMBO`): `Synchronous (Wait for Output)` vs `Asynchronous (Background)`.
|
||||
- **`timeout`** (`INT`): Maximum execution time in seconds for synchronous mode (`0` = no timeout). If exceeded, the process tree is cleanly terminated via OS process management.
|
||||
- **`trigger`** (`*`, Optional Wildcard): Input wire allowing you to connect any workflow data to order execution.
|
||||
- **⚡ In-Memory Authorization**: Does NOT use an input widget (preventing malicious authorization smuggling inside shared workflow JSON or image metadata). Instead, the operator must click the interactive `[⚡ Authorize Run]` button directly on the canvas node to grant a 5-minute single-use execution token.
|
||||
|
||||
#### Outputs
|
||||
- **`stdout`** (`STRING`): Captured standard output stream.
|
||||
- **`stderr`** (`STRING`): Captured standard error stream.
|
||||
- **`exit_code`** (`INT`): Process exit return code (`0` = success, `-1` = blocked / timed out).
|
||||
- **`success`** (`BOOLEAN`): `True` if `exit_code == 0`, `False` otherwise.
|
||||
- **`passthrough`** (`*`): Passes input `trigger` data through untouched to downstream nodes.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ ComfyUI Settings Menu Reference
|
||||
@@ -309,6 +353,7 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **`Toolbar Button Unpaused Color`** (`text`, *Default: `#16a34a`*): Hex color for the toolbar unpaused/running state.
|
||||
- **`Toolbar Button Paused Color`** (`text`, *Default: `#ea580c`*): Hex color for the toolbar paused state.
|
||||
- **`Enable System Tray Icon`** (`boolean`, *Default: false*): Displays an OS system tray icon with real-time queue status colors and outside-browser controls.
|
||||
- **`Allow Process Management (Restart / Shutdown)`** (`boolean`, *Default: false*): Opt-in authorization allowing server restart and shutdown actions from the LeafFlow sidebar and API. Disabled by default for maximum security.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
@@ -341,14 +386,25 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **Works Standalone or with PersistentQueue:** Operates client-side via `localStorage` when standalone, and syncs with `PersistentQueue` to retain batch relationships after server restarts.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>9. 🛡️ Security & Script Execution</b></summary>
|
||||
|
||||
- **`Allow Local File Execution`** (`boolean`, *Default: false*): Master toggle controlling whether the *Run Local File* node is permitted to execute local scripts or executables. Disabled by default for operator safety.
|
||||
- **Strict Confinement**: Execution is strictly limited to the `ComfyUI/scripts/` directory. Absolute paths and directory traversal (`..`) are permanently blocked.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Privacy
|
||||
|
||||
- **Local-Only Enforced Endpoints:** Power actions (`/leafflow/power/*`), settings updates (`/leafflow/settings`), and queue sync routes are strictly protected with loopback verification (`127.0.0.1` / `::1`), rejecting external network requests with `403 Forbidden`.
|
||||
- **Directory Confinement:** Image loading and thumbnail routes are strictly confined to ComfyUI `input`, `output`, and `temp` directories with traversal guards.
|
||||
- **Local Runner Sandbox & Operator Authorization Gate:** The `Run Local File` node enforces single-use interactive operator authorization (`[⚡ Authorize Run]`), strictly confines execution to `ComfyUI/scripts/`, never uses `shell=True`, terminates whole process trees on timeout or cancellation, and **never persists authorization inside workflow JSON or image metadata**.
|
||||
- **CSRF Defense & Fetch Metadata Validation:** All administrative endpoints (`/leafflow/settings`, `/leafflow/power/*`, `/leafflow/local_runner/*`, `/leafflow/scrapes/clear`) enforce loopback checks, `Sec-Fetch-Site` header checks (blocking cross-site requests from visited web pages), and cryptographic per-session `X-LeafFlow-CSRF-Token` headers.
|
||||
- **Two-Step Ephemeral Ticket Handshake for Power Actions:** Process restart and shutdown controls are disabled by default (`ALLOW_PROCESS_MANAGEMENT=false`), require CSRF authentication, and execute only after a single-use 30-second ephemeral ticket confirmation handshake.
|
||||
- **Universal Loopback Protection:** All administrative and queue endpoints strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting external network requests with `403 Forbidden`.
|
||||
- **Directory Traversal Protection:** Image loading and thumbnail routes are strictly confined to ComfyUI `input`, `output`, and `temp` directories with traversal guards.
|
||||
- **Optional Headless Dependencies:** System tray desktop integration (`pystray`) is an optional dependency, ensuring cloud containers (Docker, Colab, RunPod) run with zero dependency errors.
|
||||
- **Opt-In Scraping:** Civitai and TMDB network scraping are opt-in and disabled by default.
|
||||
- **Zero Runtime Package Installs:** No `install.py` or dynamic `pip` execution; all standard dependencies (`Pillow`, `piexif`, `numpy`, `pystray`) are cleanly declared in `requirements.txt`.
|
||||
- **Zero Runtime Package Installs:** No `install.py` or dynamic `pip` execution; all standard dependencies are cleanly declared in `requirements.txt`.
|
||||
- **Local Environment:** All API keys and settings are stored locally in `.env` (excluded from git commits via `.gitignore`).
|
||||
- **Atomic File Writes:** Safe atomic writes (`.tmp` + `os.replace`) prevent JSON corruption during sudden crashes.
|
||||
|
||||
|
||||
+19
-1
@@ -15,9 +15,27 @@ Instead, please report security vulnerabilities directly to the maintainer via G
|
||||
|
||||
## Security Architecture & Best Practices
|
||||
|
||||
- **Loopback Enforcement for Server Control**: All administrative endpoints (power controls `/leafflow/power/*`, settings modification `/leafflow/settings`, and internal state synchronization `/leafflow/batch_queue/*`) strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting remote network requests with `403 Forbidden`.
|
||||
- **CSRF Defense & Fetch Metadata Validation**: All administrative endpoints (`/leafflow/settings`, `/leafflow/power/*`, `/leafflow/local_runner/*`, `/leafflow/scrapes/clear`) enforce multi-layer CSRF protection via `is_authenticated_local_request(request)`.
|
||||
- Enforces loopback origin checks (`127.0.0.1` / `::1`), rejecting remote calls with `403 Forbidden`.
|
||||
- Rejects `cross-site` requests via `Sec-Fetch-Site` header checks, preventing external malicious web pages visited by the operator from triggering actions.
|
||||
- Verifies `Origin` and `Referer` headers against loopback hostnames.
|
||||
- Requires cryptographically random session tokens in the `X-LeafFlow-CSRF-Token` header (`secrets.compare_digest`), prompting CORS preflight rejection if an external origin attempts unauthorized requests.
|
||||
- **Two-Step Ephemeral Ticket Handshake for Power Actions**:
|
||||
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` in settings.
|
||||
- Execution requires a two-step handshake: the client requests a single-use ephemeral ticket (`POST /leafflow/power/request_token`) valid for 30 seconds.
|
||||
- The confirmation endpoint (`POST /leafflow/power/confirm_action`) consumes the single-use ticket in server memory before executing restart or shutdown, preventing arbitrary or cross-site triggers.
|
||||
- **Strict Local Script Confinement**:
|
||||
- `RunLocalFileNode` strictly confines executable scripts and binaries to `ComfyUI/scripts/`.
|
||||
- Absolute paths (e.g. `C:\`, `/etc/`, `\\server`) and directory traversal sequences (`..`) are strictly rejected.
|
||||
- Paths are verified via canonical `os.path.realpath` and `os.path.commonpath` checks against the scripts directory. Unsafe path bypasses have been completely removed.
|
||||
- **In-Memory Operator Authorization Gate**:
|
||||
- Local file execution defaults to disabled (`ENABLE_LOCAL_FILE_EXECUTION = false`).
|
||||
- `security_consent` input widget was removed from `INPUT_TYPES` to ensure authorization parameters NEVER travel within workflow JSON or embedded PNG metadata.
|
||||
- Workflows loaded from the internet or images dropped onto the canvas cannot execute local scripts without explicit manual authorization.
|
||||
- Execution requires an interactive user action on the canvas node via `[⚡ Authorize Run]`, which registers single-use authorization exclusively in server RAM with a 5-minute TTL.
|
||||
- **Filesystem Path Confinement**: Image loading (`VisualImageLoader`) and thumbnail file streaming endpoints are strictly confined to standard ComfyUI `input`, `output`, and `temp` directories with path canonicalization and directory traversal guards.
|
||||
- **Opt-In Network Requests**: External metadata and preview scraping (Civitai / TMDB) are completely opt-in and disabled by default.
|
||||
- **No Dynamic Package Installation**: Runtime `pip` execution (`install.py` / `subprocess`) is eliminated; all dependencies are transparently listed in `requirements.txt`.
|
||||
- **Local Environment**: All API keys (Civitai / TMDB) are stored locally in `.env` and are strictly excluded from git tracking via `.gitignore`. Sensitive keys are masked in server log outputs.
|
||||
- **Atomic File Writes**: Atomic file operations (`.tmp` + `os.replace`) prevent JSON file corruption on sudden system shutdown or crash.
|
||||
|
||||
|
||||
@@ -85,6 +85,16 @@ Tired of guessing what a LoRA does based on its filename?
|
||||
### 🍃 ⏱️ Recent Outputs
|
||||
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🍃 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
|
||||
|
||||
### 🍃 📝 Prompt Counter & 🔄 Prompt Queue Iterator
|
||||
Managing multi-prompt queues and setting the exact ComfyUI batch size has never been easier:
|
||||
1. **`🍃 📝 Prompt Counter`**: Place this multiline text node in your workflow and paste all your prompts.
|
||||
- As you type or paste, the live badge instantly updates (e.g. `🍃 24 Prompts`), so you know exactly what batch size to set before clicking Queue!
|
||||
- Under Advanced Options, select your delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`).
|
||||
- If `Custom Regex` is selected, enter any custom delimiter pattern (such as `\n---\n`). The regex field is dynamically enabled only when Custom Regex is active.
|
||||
2. **`🍃 🔄 Prompt Queue Iterator`**: Connect the `STRING` output of the Prompt Counter into the Prompt Queue Iterator to sequentially pop and execute each prompt run-by-run.
|
||||
- Now supports `Custom Regex` in addition to empty line and newline separators.
|
||||
- Includes live progress counter widget (`🍃 Run X / Y`), instant index reset button (`🔄 Reset Counter (0)`), and direct state file access (`📂 Open Queue File`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Formatting Folder Paths
|
||||
|
||||
+60
-7
@@ -4,6 +4,8 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
__version__ = "2.3.1"
|
||||
|
||||
from .nodes.queue_control import setup_queue_control_routes, tray_manager
|
||||
from .nodes.lora_loader import (
|
||||
FolderLoraLoader,
|
||||
@@ -19,13 +21,17 @@ from .nodes.decision_node import LeafFlowDecision
|
||||
from .nodes.aspect_ratio import TextAspectRatioFinder, AspectRatioFinder, PreviewImageSizeAspectRatio
|
||||
from .nodes.lora_finder import TextLoraFinder, LoraTextFinder
|
||||
from .nodes.prompt_iterator import PromptQueueIterator
|
||||
from .nodes.prompt_counter import PromptCounter
|
||||
from .nodes.text_replacer import MultiTextReplacer
|
||||
from .nodes.text_split import LeafFlowTextSplit
|
||||
from .nodes.local_runner import RunLocalFileNode, setup_local_runner_routes
|
||||
from .nodes.utils import (
|
||||
get_leafflow_user_dir,
|
||||
get_env_setting,
|
||||
is_local_request,
|
||||
is_safe_path
|
||||
is_safe_path,
|
||||
get_csrf_token,
|
||||
is_authenticated_local_request
|
||||
)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -41,8 +47,10 @@ NODE_CLASS_MAPPINGS = {
|
||||
"PreviewImageSizeAspectRatio": PreviewImageSizeAspectRatio,
|
||||
"TextLoraFinder": TextLoraFinder,
|
||||
"PromptQueueIterator": PromptQueueIterator,
|
||||
"PromptCounter": PromptCounter,
|
||||
"MultiTextReplacer": MultiTextReplacer,
|
||||
"LeafFlowTextSplit": LeafFlowTextSplit,
|
||||
"RunLocalFileNode": RunLocalFileNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -58,8 +66,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PreviewImageSizeAspectRatio": "🍃 📐 Preview Image Size & Aspect Ratio",
|
||||
"TextLoraFinder": "🍃 🔎 Text LoRA Finder & Loader",
|
||||
"PromptQueueIterator": "🍃 🔄 Prompt Queue Iterator",
|
||||
"PromptCounter": "🍃 📝 Prompt Counter",
|
||||
"MultiTextReplacer": "🍃 🔤 Multi Text Replacer",
|
||||
"LeafFlowTextSplit": "🍃 ✂️ Text Split"
|
||||
"LeafFlowTextSplit": "🍃 ✂️ Text Split",
|
||||
"RunLocalFileNode": "🍃 ⚡ Run Local File"
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
@@ -67,6 +77,7 @@ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
server = PromptServer.instance
|
||||
setup_queue_control_routes(server)
|
||||
setup_local_runner_routes(server)
|
||||
|
||||
try:
|
||||
from .prompt_bookmarks.api import register_routes as register_prompt_bookmarks_routes
|
||||
@@ -80,7 +91,16 @@ ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
|
||||
routes = server.routes
|
||||
|
||||
print("[ComfyUI-LeafFlow] 🍃 Loaded 13 nodes & visual endpoints successfully.")
|
||||
try:
|
||||
print(f"[ComfyUI-LeafFlow] 🍃 Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
|
||||
except UnicodeEncodeError:
|
||||
print(f"[ComfyUI-LeafFlow] Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
|
||||
|
||||
@routes.get("/leafflow/auth/token")
|
||||
async def get_csrf_token_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response({"csrf_token": get_csrf_token()})
|
||||
|
||||
@routes.get("/leafflow/get_image_prompt")
|
||||
@routes.get("/leafflow/view_image_prompt")
|
||||
@@ -135,6 +155,8 @@ async def get_settings(request):
|
||||
enable_tray = os.getenv("ENABLE_TRAY_ICON", "false").lower() in ["true", "1", "yes"]
|
||||
enable_assets_restore = os.getenv("ENABLE_ASSETS_RESTORE", "true").lower() in ["true", "1", "yes"]
|
||||
restore_assets_count = int(os.getenv("RESTORE_ASSETS_COUNT", "64"))
|
||||
enable_process_management = os.getenv("ALLOW_PROCESS_MANAGEMENT", "false").lower() in ["true", "1", "yes"]
|
||||
enable_local_file_execution = os.getenv("ENABLE_LOCAL_FILE_EXECUTION", "false").lower() in ["true", "1", "yes"]
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
@@ -153,6 +175,10 @@ async def get_settings(request):
|
||||
restore_assets_count = int(line.split("=", 1)[1].strip())
|
||||
except Exception:
|
||||
pass
|
||||
elif line.startswith("ALLOW_PROCESS_MANAGEMENT="):
|
||||
enable_process_management = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
|
||||
elif line.startswith("ENABLE_LOCAL_FILE_EXECUTION="):
|
||||
enable_local_file_execution = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({
|
||||
@@ -160,7 +186,9 @@ async def get_settings(request):
|
||||
"tmdb_api_key": tmdb_key,
|
||||
"enable_tray_icon": enable_tray,
|
||||
"enable_assets_restore": enable_assets_restore,
|
||||
"restore_assets_count": restore_assets_count
|
||||
"restore_assets_count": restore_assets_count,
|
||||
"enable_process_management": enable_process_management,
|
||||
"enable_local_file_execution": enable_local_file_execution
|
||||
})
|
||||
|
||||
def _clean_env_val(v):
|
||||
@@ -171,8 +199,8 @@ def _clean_env_val(v):
|
||||
@routes.post("/leafflow/settings")
|
||||
@routes.post("/flow_control/settings")
|
||||
async def save_settings(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
civitai_key = _clean_env_val(data.get("civitai_api_key"))
|
||||
@@ -188,6 +216,8 @@ async def save_settings(request):
|
||||
restore_assets_count = _clean_env_val(data.get("restore_assets_count"))
|
||||
clear_prompt_iterator_on_launch = _clean_env_val(data.get("clear_prompt_iterator_on_launch"))
|
||||
restored_state = _clean_env_val(data.get("persistent_queue_restored_state"))
|
||||
enable_process_management = _clean_env_val(data.get("enable_process_management"))
|
||||
enable_local_file_execution = _clean_env_val(data.get("enable_local_file_execution"))
|
||||
|
||||
lines = []
|
||||
if os.path.exists(ENV_FILE):
|
||||
@@ -211,6 +241,8 @@ async def save_settings(request):
|
||||
has_restore_count = False
|
||||
has_clear_prompt_iterator = False
|
||||
has_restored_state = False
|
||||
has_process_mgmt = False
|
||||
has_local_file_exec = False
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith("CIVITAI_API_KEY=") and civitai_key is not None:
|
||||
@@ -252,6 +284,15 @@ async def save_settings(request):
|
||||
elif line.strip().startswith("PERSISTENT_QUEUE_RESTORED_STATE=") and restored_state is not None:
|
||||
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
|
||||
has_restored_state = True
|
||||
elif line.strip().startswith("ALLOW_PROCESS_MANAGEMENT=") and enable_process_management is not None:
|
||||
new_lines.append(f"ALLOW_PROCESS_MANAGEMENT={enable_process_management}\n")
|
||||
has_process_mgmt = True
|
||||
elif line.strip().startswith("ENABLE_LOCAL_FILE_EXECUTION=") and enable_local_file_execution is not None:
|
||||
new_lines.append(f"ENABLE_LOCAL_FILE_EXECUTION={enable_local_file_execution}\n")
|
||||
has_local_file_exec = True
|
||||
elif line.strip().startswith("ALLOW_ANY_SCRIPT_PATH="):
|
||||
# Deprecated and removed for security; drop from .env
|
||||
continue
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
@@ -281,6 +322,10 @@ async def save_settings(request):
|
||||
new_lines.append(f"CLEAR_PROMPT_ITERATOR_ON_LAUNCH={clear_prompt_iterator_on_launch}\n")
|
||||
if not has_restored_state and restored_state is not None:
|
||||
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
|
||||
if not has_process_mgmt and enable_process_management is not None:
|
||||
new_lines.append(f"ALLOW_PROCESS_MANAGEMENT={enable_process_management}\n")
|
||||
if not has_local_file_exec and enable_local_file_execution is not None:
|
||||
new_lines.append(f"ENABLE_LOCAL_FILE_EXECUTION={enable_local_file_execution}\n")
|
||||
|
||||
with open(ENV_FILE, "w", encoding="utf-8") as f:
|
||||
f.writelines(new_lines)
|
||||
@@ -297,6 +342,10 @@ async def save_settings(request):
|
||||
tray_manager.set_enabled(tray_enabled_bool)
|
||||
if enable_assets_restore is not None:
|
||||
os.environ["ENABLE_ASSETS_RESTORE"] = str(enable_assets_restore)
|
||||
if enable_process_management is not None:
|
||||
os.environ["ALLOW_PROCESS_MANAGEMENT"] = str(enable_process_management)
|
||||
if enable_local_file_execution is not None:
|
||||
os.environ["ENABLE_LOCAL_FILE_EXECUTION"] = str(enable_local_file_execution)
|
||||
if restore_assets_count is not None:
|
||||
os.environ["RESTORE_ASSETS_COUNT"] = str(restore_assets_count)
|
||||
if clear_prompt_iterator_on_launch is not None:
|
||||
@@ -309,6 +358,8 @@ async def save_settings(request):
|
||||
|
||||
@routes.post("/leafflow/scrapes/clear")
|
||||
async def clear_scrapes_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
failed_file = os.path.join(USER_DIR, "failed_scrapes.json")
|
||||
with open(failed_file, "w", encoding="utf-8") as f:
|
||||
@@ -359,7 +410,7 @@ async def export_debug_profile(request):
|
||||
debug_profile = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"package": "ComfyUI-LeafFlow",
|
||||
"version": "2.1.0",
|
||||
"version": __version__,
|
||||
"system": {
|
||||
"os": platform.system(),
|
||||
"os_release": platform.release(),
|
||||
@@ -383,6 +434,8 @@ async def export_debug_profile(request):
|
||||
"restore_assets_count": get_env_setting("RESTORE_ASSETS_COUNT", "64"),
|
||||
"clear_prompt_iterator_on_launch": get_env_setting("CLEAR_PROMPT_ITERATOR_ON_LAUNCH", "false"),
|
||||
"persistent_queue_restored_state": get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default"),
|
||||
"allow_process_management": get_env_setting("ALLOW_PROCESS_MANAGEMENT", "false"),
|
||||
"enable_local_file_execution": get_env_setting("ENABLE_LOCAL_FILE_EXECUTION", "false"),
|
||||
"civitai_api_key_configured": bool(civitai_key and civitai_key.strip()),
|
||||
"tmdb_api_key_configured": bool(tmdb_key and tmdb_key.strip()),
|
||||
},
|
||||
|
||||
+24
-17
@@ -192,23 +192,30 @@ class AssetsRestoreManager:
|
||||
|
||||
# Ingest into modern ComfyUI asset database if --enable-assets is active
|
||||
try:
|
||||
from app.assets.services.ingest import register_file_in_place
|
||||
res = register_file_in_place(abs_path=filepath, name=filename, tags=["output"])
|
||||
if res and hasattr(res, "ref") and res.ref and hasattr(res.ref, "id"):
|
||||
try:
|
||||
from app.database.db import create_session, init_db
|
||||
from app.assets.database.queries import set_reference_metadata
|
||||
init_db()
|
||||
with create_session() as session:
|
||||
set_reference_metadata(session, reference_id=res.ref.id, metadata={
|
||||
"jobId": prompt_id,
|
||||
"nodeId": output_node_id,
|
||||
"filename": filename,
|
||||
"subfolder": subfolder
|
||||
})
|
||||
session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
import importlib
|
||||
ingest_mod = importlib.import_module("app.assets.services.ingest")
|
||||
register_file_in_place = getattr(ingest_mod, "register_file_in_place", None)
|
||||
if register_file_in_place:
|
||||
res = register_file_in_place(abs_path=filepath, name=filename, tags=["output"])
|
||||
if res and hasattr(res, "ref") and res.ref and hasattr(res.ref, "id"):
|
||||
try:
|
||||
db_mod = importlib.import_module("app.database.db")
|
||||
queries_mod = importlib.import_module("app.assets.database.queries")
|
||||
init_db = getattr(db_mod, "init_db", None)
|
||||
create_session = getattr(db_mod, "create_session", None)
|
||||
set_reference_metadata = getattr(queries_mod, "set_reference_metadata", None)
|
||||
if init_db and create_session and set_reference_metadata:
|
||||
init_db()
|
||||
with create_session() as session:
|
||||
set_reference_metadata(session, reference_id=res.ref.id, metadata={
|
||||
"jobId": prompt_id,
|
||||
"nodeId": output_node_id,
|
||||
"filename": filename,
|
||||
"subfolder": subfolder
|
||||
})
|
||||
session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import subprocess
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import comfy.model_management
|
||||
from .utils import is_local_request
|
||||
from .utils import is_authenticated_local_request
|
||||
|
||||
class DecisionManager:
|
||||
_waiting_events = {}
|
||||
@@ -171,8 +171,8 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
# Register API Route
|
||||
@PromptServer.instance.routes.post("/leafflow/decision")
|
||||
async def handle_decision(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = data.get("node_id")
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import comfy.model_management
|
||||
from .utils import get_env_setting, is_local_request, is_authenticated_local_request
|
||||
|
||||
AUTOMATION_CATEGORY = "🍃 LeafFlow/Automation"
|
||||
|
||||
def _safe_print(msg):
|
||||
try:
|
||||
print(msg)
|
||||
except UnicodeEncodeError:
|
||||
print(msg.encode("ascii", "replace").decode("ascii"))
|
||||
|
||||
def is_local_file_execution_enabled():
|
||||
"""Checks whether local file execution is explicitly enabled in LeafFlow settings (default False)."""
|
||||
val = get_env_setting("ENABLE_LOCAL_FILE_EXECUTION", "false").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
def get_allowed_scripts_directory():
|
||||
"""
|
||||
Returns the strict, non-bypassable scripts directory: ComfyUI/scripts/
|
||||
"""
|
||||
base = folder_paths.base_path if hasattr(folder_paths, "base_path") and folder_paths.base_path else os.getcwd()
|
||||
scripts_dir = os.path.abspath(os.path.realpath(os.path.join(base, "scripts")))
|
||||
try:
|
||||
os.makedirs(scripts_dir, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return scripts_dir
|
||||
|
||||
def is_script_path_permitted(target_path):
|
||||
"""
|
||||
Confines target scripts strictly to ComfyUI/scripts/ using canonical realpath and commonpath.
|
||||
No setting or configuration can bypass this confinement.
|
||||
"""
|
||||
if not target_path:
|
||||
return False, "Target path is empty."
|
||||
|
||||
scripts_dir = get_allowed_scripts_directory()
|
||||
try:
|
||||
abs_target = os.path.abspath(os.path.realpath(target_path))
|
||||
if os.path.commonpath([scripts_dir, abs_target]) == scripts_dir:
|
||||
if not os.path.exists(abs_target):
|
||||
return False, f"Script does not exist: '{os.path.basename(target_path)}'"
|
||||
if not os.path.isfile(abs_target):
|
||||
return False, f"Target is not a regular file: '{os.path.basename(target_path)}'"
|
||||
return True, "Path verified within ComfyUI/scripts/."
|
||||
return False, f"Security Restriction: Script '{os.path.basename(target_path)}' must reside within ComfyUI/scripts/."
|
||||
except Exception as e:
|
||||
return False, f"Path validation error: {e}"
|
||||
|
||||
def resolve_target_path(file_path_input):
|
||||
"""
|
||||
Resolves and verifies a script path strictly within ComfyUI/scripts/.
|
||||
Rejects absolute paths, path traversal ('..'), and any outside references.
|
||||
"""
|
||||
clean = str(file_path_input or "").strip()
|
||||
if not clean:
|
||||
return None, "Script file path is empty."
|
||||
|
||||
# 1. Strictly reject absolute paths (Windows drive letters C:\, UNC \\server, or POSIX /root)
|
||||
if os.path.isabs(clean) or clean.startswith(("\\\\", "//")):
|
||||
return None, f"Security Restriction: Absolute paths are forbidden ('{clean}'). Place your script in ComfyUI/scripts/ and specify a relative name."
|
||||
|
||||
# 2. Strictly reject directory traversal
|
||||
norm = os.path.normpath(clean)
|
||||
parts = norm.replace("\\", "/").split("/")
|
||||
if ".." in parts or norm.startswith(".."):
|
||||
return None, f"Security Restriction: Directory traversal ('..') is forbidden ('{clean}')."
|
||||
|
||||
# 3. If operator wrote "scripts/run.bat" or "scripts\run.bat", normalize to relative subpath
|
||||
if parts and parts[0].lower() == "scripts":
|
||||
clean_sub = os.path.join(*parts[1:]) if len(parts) > 1 else ""
|
||||
else:
|
||||
clean_sub = norm
|
||||
|
||||
if not clean_sub:
|
||||
return None, "Invalid script path."
|
||||
|
||||
scripts_base = get_allowed_scripts_directory()
|
||||
full_target = os.path.abspath(os.path.realpath(os.path.join(scripts_base, clean_sub)))
|
||||
|
||||
# 4. Canonical commonpath confinement
|
||||
try:
|
||||
if os.path.commonpath([scripts_base, full_target]) != scripts_base:
|
||||
return None, f"Security Restriction: Script path escapes ComfyUI/scripts/ ('{clean}')."
|
||||
except Exception as e:
|
||||
return None, f"Security Restriction: Path validation error: {e}"
|
||||
|
||||
if not os.path.exists(full_target):
|
||||
return None, f"Script not found in ComfyUI/scripts/: '{clean_sub}'"
|
||||
|
||||
if not os.path.isfile(full_target):
|
||||
return None, f"Target path is not a file: '{clean_sub}'"
|
||||
|
||||
return full_target, "OK"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# In-Memory Operator Authorization Registry
|
||||
# =========================================================================
|
||||
# Does NOT travel in workflow JSON or prompt requests.
|
||||
# Stored solely in memory and consumed upon execution.
|
||||
_AUTHORIZED_RUNS = {}
|
||||
_AUTH_LOCK = threading.Lock()
|
||||
|
||||
def authorize_node_run(node_id, file_path="", ttl_seconds=300):
|
||||
with _AUTH_LOCK:
|
||||
now = time.time()
|
||||
expired = [k for k, v in _AUTHORIZED_RUNS.items() if v.get("expires_at", 0) < now]
|
||||
for k in expired:
|
||||
_AUTHORIZED_RUNS.pop(k, None)
|
||||
|
||||
filename = os.path.basename(str(file_path).strip()) if file_path else ""
|
||||
_AUTHORIZED_RUNS[str(node_id)] = {
|
||||
"expires_at": now + ttl_seconds,
|
||||
"filename": filename
|
||||
}
|
||||
|
||||
def consume_node_authorization(node_id, resolved_path):
|
||||
if node_id is None:
|
||||
return False, "Missing node identifier. Workflow execution cannot be authorized."
|
||||
with _AUTH_LOCK:
|
||||
now = time.time()
|
||||
auth = _AUTHORIZED_RUNS.pop(str(node_id), None)
|
||||
if not auth:
|
||||
return False, "Node has not been authorized. Operator must click 'Authorize Run' on this node in the canvas before queueing."
|
||||
if auth.get("expires_at", 0) < now:
|
||||
return False, "Authorization has expired (5-minute timeout). Please click 'Authorize Run' again."
|
||||
target_base = os.path.basename(resolved_path) if resolved_path else ""
|
||||
expected_base = auth.get("filename")
|
||||
if expected_base and target_base and target_base.lower() != expected_base.lower():
|
||||
return False, f"Authorized script '{expected_base}' does not match configured script '{target_base}'. Please re-authorize."
|
||||
return True, "Authorized"
|
||||
|
||||
def is_node_authorized(node_id):
|
||||
if not node_id:
|
||||
return False, 0
|
||||
with _AUTH_LOCK:
|
||||
now = time.time()
|
||||
auth = _AUTHORIZED_RUNS.get(str(node_id))
|
||||
if auth and auth.get("expires_at", 0) >= now:
|
||||
return True, int(auth.get("expires_at", 0) - now)
|
||||
return False, 0
|
||||
|
||||
def clear_all_authorizations():
|
||||
with _AUTH_LOCK:
|
||||
_AUTHORIZED_RUNS.clear()
|
||||
|
||||
def setup_local_runner_routes(server):
|
||||
routes = server.routes
|
||||
|
||||
@routes.post("/leafflow/local_runner/authorize")
|
||||
async def authorize_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_local_file_execution_enabled():
|
||||
return web.json_response({
|
||||
"error": "Local file execution is disabled. Enable 'Allow Local File Execution' in LeafFlow settings first.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
node_id = data.get("node_id")
|
||||
file_path = data.get("file_path", "")
|
||||
if node_id is None:
|
||||
return web.json_response({"error": "Missing node_id"}, status=400)
|
||||
authorize_node_run(node_id, file_path, ttl_seconds=300)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"node_id": str(node_id),
|
||||
"expires_in": 300
|
||||
})
|
||||
|
||||
@routes.get("/leafflow/local_runner/status")
|
||||
async def status_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
node_id = request.query.get("node_id")
|
||||
is_auth, remaining = is_node_authorized(node_id)
|
||||
return web.json_response({
|
||||
"enabled": is_local_file_execution_enabled(),
|
||||
"authorized": is_auth,
|
||||
"remaining_seconds": remaining
|
||||
})
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
"""Wildcard type for input and output passthrough wires."""
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
class RunLocalFileNode:
|
||||
"""
|
||||
Safely executes a local file (.exe, .bat, .cmd, .ps1 on Windows; .sh or binary on Linux/macOS)
|
||||
with custom parameters and configurable working directory.
|
||||
Includes strict path confinement and in-memory operator authorization.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"file_path": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "Relative script path inside ComfyUI/scripts/ (e.g. run.bat or convert.sh)",
|
||||
"tooltip": "Script or binary to execute. MUST be located inside 'ComfyUI/scripts/'. Subfolders (e.g. tools/process.bat) are permitted. Absolute paths and directory traversal ('..') are strictly blocked."
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"parameters": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Command-line arguments (e.g. --input file.png --quality 95)",
|
||||
"tooltip": "Optional command-line arguments passed to the script. Parsed safely without shell expansion."
|
||||
}),
|
||||
"working_directory": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "Working directory inside ComfyUI/scripts/. Leave empty to use script directory.",
|
||||
"tooltip": "Execution working directory. MUST be located inside 'ComfyUI/scripts/'. Leave empty to default to the script's own folder."
|
||||
}),
|
||||
"run_mode": (
|
||||
["Synchronous (Wait for Output)", "Asynchronous (Background)"],
|
||||
{"default": "Synchronous (Wait for Output)"}
|
||||
),
|
||||
"timeout": ("INT", {
|
||||
"default": 60,
|
||||
"min": 0,
|
||||
"max": 3600,
|
||||
"step": 5,
|
||||
"tooltip": "Max execution time in seconds (0 = unlimited). Ignored in asynchronous mode."
|
||||
}),
|
||||
"trigger": (any_type, {"tooltip": "Optional passthrough wire to sequence execution in your workflow."}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "BOOLEAN", any_type)
|
||||
RETURN_NAMES = ("stdout", "stderr", "exit_code", "success", "passthrough")
|
||||
FUNCTION = "run_local_file"
|
||||
CATEGORY = AUTOMATION_CATEGORY
|
||||
DESCRIPTION = (
|
||||
"Executes a local script or executable (.bat, .ps1, .exe on Windows; .sh or binary on Linux/macOS) "
|
||||
"with parameter arguments and working directory control.\n\n"
|
||||
"Security Safeguards:\n"
|
||||
"- Strictly confined to ComfyUI/scripts/ (absolute paths and directory traversal are forbidden).\n"
|
||||
"- Requires explicit in-memory operator authorization via the '⚡ Authorize Run' button.\n"
|
||||
"- Consent cannot travel in workflow JSON or image metadata.\n"
|
||||
"- Execution is single-use: each authorization expires upon run or after 5 minutes.\n"
|
||||
"- Never uses shell=True to prevent command chaining injection.\n"
|
||||
"- Disabled globally by default for operator security."
|
||||
)
|
||||
|
||||
def resolve_target_path(self, file_path_input):
|
||||
return resolve_target_path(file_path_input)[0]
|
||||
|
||||
def build_command_args(self, target_path, params_str):
|
||||
"""Builds a safe, non-shell command list suitable for subprocess.Popen(shell=False)."""
|
||||
parsed_args = []
|
||||
if params_str and params_str.strip():
|
||||
try:
|
||||
parsed_args = shlex.split(params_str.strip(), posix=(sys.platform != "win32"))
|
||||
except Exception:
|
||||
parsed_args = params_str.strip().split()
|
||||
|
||||
norm_path = os.path.normpath(target_path)
|
||||
lower_path = norm_path.lower()
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
if lower_path.endswith((".bat", ".cmd")):
|
||||
comspec = os.environ.get("COMSPEC", "cmd.exe")
|
||||
return [comspec, "/c", norm_path, *parsed_args]
|
||||
elif lower_path.endswith(".ps1"):
|
||||
# Security Note: -ExecutionPolicy Bypass is required on Windows to permit execution
|
||||
# of local user scripts (.ps1) which are blocked by default machine policies.
|
||||
# Security is strictly guaranteed by:
|
||||
# 1. Path confinement: target file MUST reside exclusively inside ComfyUI/scripts/
|
||||
# 2. Live in-memory operator authorization required on canvas prior to run
|
||||
# 3. -NoProfile flag ensuring no untrusted global PowerShell profiles are executed
|
||||
return ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", norm_path, *parsed_args]
|
||||
else:
|
||||
return [norm_path, *parsed_args]
|
||||
else:
|
||||
if lower_path.endswith(".sh"):
|
||||
return ["/bin/bash", norm_path, *parsed_args]
|
||||
else:
|
||||
return [norm_path, *parsed_args]
|
||||
|
||||
def run_local_file(
|
||||
self,
|
||||
file_path,
|
||||
parameters="",
|
||||
working_directory="",
|
||||
run_mode="Synchronous (Wait for Output)",
|
||||
timeout=60,
|
||||
trigger=None,
|
||||
unique_id=None,
|
||||
**kwargs
|
||||
):
|
||||
passthrough_out = trigger if trigger is not None else ""
|
||||
|
||||
# Check 1: Global Security Setting (Defaults to Disabled)
|
||||
if not is_local_file_execution_enabled():
|
||||
err = (
|
||||
"Execution Blocked: Local file execution is disabled by default for security. "
|
||||
"To run local files, enable 'Allow Local File Execution' in ComfyUI Settings -> LeafFlow -> Security."
|
||||
)
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
# Check 2: Path Resolution & Strict Confinement to ComfyUI/scripts/
|
||||
resolved_path, path_msg = resolve_target_path(file_path)
|
||||
if not resolved_path:
|
||||
err = f"Execution Error: {path_msg}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
# Check 3: Mandatory In-Memory Operator Authorization Gate
|
||||
node_id = unique_id if unique_id is not None else kwargs.get("node_id")
|
||||
authorized, auth_msg = consume_node_authorization(node_id, resolved_path)
|
||||
if not authorized:
|
||||
err = f"Execution Blocked: {auth_msg}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
# Determine working directory (confined to ComfyUI/scripts/ or script directory)
|
||||
scripts_base = get_allowed_scripts_directory()
|
||||
cwd = os.path.dirname(resolved_path)
|
||||
if working_directory and working_directory.strip():
|
||||
cand_cwd = os.path.abspath(os.path.realpath(os.path.join(scripts_base, working_directory.strip())))
|
||||
try:
|
||||
if os.path.commonpath([scripts_base, cand_cwd]) == scripts_base and os.path.isdir(cand_cwd):
|
||||
cwd = cand_cwd
|
||||
else:
|
||||
_safe_print(f"[LeafFlow LocalRunner] Warning: working_directory '{working_directory}' outside scripts folder. Defaulting to script folder.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd_list = self.build_command_args(resolved_path, parameters)
|
||||
_safe_print(f"[LeafFlow LocalRunner] Executing '{os.path.basename(resolved_path)}' (mode: {run_mode}, cwd: '{cwd}')...")
|
||||
|
||||
# Mode: Asynchronous (Background Detached)
|
||||
if "Asynchronous" in run_mode:
|
||||
try:
|
||||
creationflags = 0
|
||||
if sys.platform.startswith("win"):
|
||||
creationflags = subprocess.CREATE_NEW_CONSOLE if hasattr(subprocess, "CREATE_NEW_CONSOLE") else 0
|
||||
subprocess.Popen(cmd_list, cwd=cwd, creationflags=creationflags)
|
||||
else:
|
||||
subprocess.Popen(cmd_list, cwd=cwd, start_new_session=True)
|
||||
|
||||
msg = f"Started in background: '{os.path.basename(resolved_path)}' (PID assigned)"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {msg}")
|
||||
return (msg, "", 0, True, passthrough_out)
|
||||
except Exception as e:
|
||||
err_msg = f"Failed to launch background process: {str(e)}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] Error: {err_msg}")
|
||||
return ("", err_msg, -1, False, passthrough_out)
|
||||
|
||||
# Mode: Synchronous (Wait for Process with Output & Interrupt Polling)
|
||||
try:
|
||||
creationflags = 0
|
||||
if sys.platform.startswith("win"):
|
||||
creationflags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd_list,
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
creationflags=creationflags
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
def _kill_tree():
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
|
||||
else:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.communicate(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
effective_timeout = timeout if timeout > 0 else None
|
||||
|
||||
# Poll for completion while honoring ComfyUI cancellation
|
||||
while proc.poll() is None:
|
||||
try:
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
except Exception:
|
||||
_kill_tree()
|
||||
_safe_print(f"[LeafFlow LocalRunner] Execution of '{os.path.basename(resolved_path)}' interrupted by user.")
|
||||
raise
|
||||
|
||||
if effective_timeout and (time.time() - start_time) > effective_timeout:
|
||||
_kill_tree()
|
||||
err = f"Execution timed out after {timeout} seconds."
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
stdout_data, stderr_data = proc.communicate()
|
||||
exit_code = proc.returncode
|
||||
success = (exit_code == 0)
|
||||
|
||||
_safe_print(f"[LeafFlow LocalRunner] Completed with exit code {exit_code} (success: {success}).")
|
||||
return (stdout_data or "", stderr_data or "", exit_code, success, passthrough_out)
|
||||
|
||||
except Exception as e:
|
||||
err_msg = f"Process execution error: {str(e)}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err_msg}")
|
||||
return ("", err_msg, -1, False, passthrough_out)
|
||||
+47
-12
@@ -135,6 +135,32 @@ def get_file_sha256(filepath):
|
||||
h.update(chunk)
|
||||
return h.hexdigest().upper()
|
||||
|
||||
def is_safe_external_image_url(url_str):
|
||||
"""
|
||||
Validates that a scraped image URL uses a safe web scheme (https/http)
|
||||
and does not target internal loopback, private networks, or metadata services (SSRF protection).
|
||||
"""
|
||||
if not url_str or not isinstance(url_str, str):
|
||||
return False
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url_str.strip())
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
return False
|
||||
hostname = (parsed.hostname or "").lower().strip()
|
||||
if not hostname:
|
||||
return False
|
||||
disallowed_prefixes = (
|
||||
"127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
|
||||
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"169.254.", "0.0.0.0", "::1", "localhost"
|
||||
)
|
||||
if any(hostname == prefix or hostname.startswith(prefix) for prefix in disallowed_prefixes):
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
@@ -198,14 +224,18 @@ def scrape_missing_images_sync():
|
||||
images = data.get("images", [])
|
||||
if images and len(images) > 0:
|
||||
img_url = images[0].get("url")
|
||||
if img_url:
|
||||
if img_url and is_safe_external_image_url(img_url):
|
||||
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
|
||||
img_req = urllib.request.Request(img_url, headers=headers)
|
||||
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
|
||||
img_data = img_resp.read()
|
||||
try:
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(img_resp.read())
|
||||
print(f"[LeafFlow] Scraped Civitai preview for {pretty_name}")
|
||||
success = True
|
||||
f.write(img_data)
|
||||
print(f"[LeafFlow] Scraped Civitai preview for {pretty_name}")
|
||||
success = True
|
||||
except (PermissionError, OSError) as write_err:
|
||||
print(f"[LeafFlow] Warning: Could not save preview image to '{dest_path}': {write_err}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -239,14 +269,19 @@ def scrape_missing_images_sync():
|
||||
profile_path = res.get("profile_path")
|
||||
if profile_path:
|
||||
img_url = f"https://image.tmdb.org/t/p/w500{profile_path}"
|
||||
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
|
||||
img_req = urllib.request.Request(img_url, headers=tmdb_headers)
|
||||
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(img_resp.read())
|
||||
print(f"[LeafFlow] Scraped TMDB preview for {pretty_name} (via '{term}')")
|
||||
success = True
|
||||
break
|
||||
if is_safe_external_image_url(img_url):
|
||||
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
|
||||
img_req = urllib.request.Request(img_url, headers=tmdb_headers)
|
||||
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
|
||||
img_data = img_resp.read()
|
||||
try:
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(img_data)
|
||||
print(f"[LeafFlow] Scraped TMDB preview for {pretty_name} (via '{term}')")
|
||||
success = True
|
||||
break
|
||||
except (PermissionError, OSError) as write_err:
|
||||
print(f"[LeafFlow] Warning: Could not save preview image to '{dest_path}': {write_err}")
|
||||
except Exception as tmdb_err:
|
||||
pass
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from .prompt_iterator import parse_prompt_blocks
|
||||
|
||||
class PromptCounter:
|
||||
"""
|
||||
LeafFlow Prompt Counter:
|
||||
Multiline text prompt input node with real-time prompt counting according to
|
||||
selectable separator ("Newline", ">1 Empty Line", ">2 Empty Lines", "Custom Regex").
|
||||
Outputs STRING (unmodified text) and count (integer count of prompts).
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "", "multiline": True, "placeholder": "Enter multiline prompts here..."}),
|
||||
},
|
||||
"optional": {
|
||||
"separator": ([
|
||||
">1 Empty Line",
|
||||
"Newline",
|
||||
">2 Empty Lines",
|
||||
"Custom Regex"
|
||||
], {"default": ">1 Empty Line", "advanced": True}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True, "placeholder": "Custom regex delimiter (e.g. \\n---\\n)"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("STRING", "count")
|
||||
FUNCTION = "count_prompts"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
DESCRIPTION = "Multiline prompt text input with live prompt count display according to selectable separator or custom regex."
|
||||
|
||||
def count_prompts(self, text="", separator=">1 Empty Line", custom_regex="", **kwargs):
|
||||
text_str = str(text) if text is not None else ""
|
||||
blocks = parse_prompt_blocks(text_str, separator, custom_regex)
|
||||
return (text_str, len(blocks))
|
||||
+58
-12
@@ -9,7 +9,7 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
from .utils import get_leafflow_user_dir
|
||||
from .utils import get_leafflow_user_dir, is_authenticated_local_request
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
@@ -85,6 +85,8 @@ try:
|
||||
|
||||
@routes.post("/leafflow/prompt_iterator/reset_node")
|
||||
async def reset_prompt_iterator_node(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("node_id", ""))
|
||||
@@ -115,23 +117,37 @@ try:
|
||||
@routes.post("/leafflow/prompt_iterator/clear")
|
||||
@routes.post("/flow_control/prompt_iterator/clear")
|
||||
async def clear_prompt_iterator_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
success = clear_state()
|
||||
return web.json_response({"status": "ok" if success else "error"})
|
||||
|
||||
@routes.post("/leafflow/prompt_iterator/open_file")
|
||||
async def open_prompt_iterator_file(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
ensure_user_dir()
|
||||
if not os.path.exists(STATE_FILE):
|
||||
save_state({})
|
||||
|
||||
# Security: verify canonical state file path is strictly within USER_DIR
|
||||
canonical_state = os.path.realpath(STATE_FILE)
|
||||
canonical_user = os.path.realpath(USER_DIR)
|
||||
try:
|
||||
if os.path.commonpath([canonical_user, canonical_state]) != canonical_user:
|
||||
return web.json_response({"status": "error", "message": "Security: State file escapes user directory"}, status=403)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Security path validation error"}, status=403)
|
||||
|
||||
try:
|
||||
sys_name = platform.system()
|
||||
if sys_name == "Windows":
|
||||
os.startfile(STATE_FILE)
|
||||
os.startfile(canonical_state)
|
||||
elif sys_name == "Darwin":
|
||||
subprocess.Popen(["open", STATE_FILE])
|
||||
subprocess.Popen(["open", canonical_state])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", STATE_FILE])
|
||||
return web.json_response({"status": "ok", "path": STATE_FILE})
|
||||
subprocess.Popen(["xdg-open", canonical_state])
|
||||
return web.json_response({"status": "ok", "path": canonical_state})
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Error opening state file: {e}")
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
@@ -139,18 +155,34 @@ try:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def parse_prompt_blocks(text_str, separator):
|
||||
def parse_prompt_blocks(text_str, separator, custom_regex=""):
|
||||
if not text_str or not text_str.strip():
|
||||
return []
|
||||
|
||||
clean_text = text_str.replace('\r\n', '\n').replace('\r', '\n')
|
||||
sep_lower = str(separator).lower() if separator else ">1 empty line"
|
||||
|
||||
if separator == "Newline":
|
||||
if "regex" in sep_lower:
|
||||
pattern = custom_regex.strip() if (custom_regex and custom_regex.strip()) else r'\n\s*\n+'
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
raw_blocks = regex.split(clean_text)
|
||||
results = []
|
||||
for b in raw_blocks:
|
||||
b_str = b.strip()
|
||||
if b_str and not regex.fullmatch(b):
|
||||
results.append(b_str)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow Prompt] Invalid custom regex pattern '{pattern}': {e}. Falling back to empty line separator.")
|
||||
raw_blocks = re.split(r'\n\s*\n+', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
elif "newline" in sep_lower:
|
||||
return [line.strip() for line in clean_text.split('\n') if line.strip()]
|
||||
elif separator == ">2 Empty Lines":
|
||||
elif ">2" in sep_lower or "2 empty" in sep_lower:
|
||||
raw_blocks = re.split(r'(?:\n\s*){3,}', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
else: # ">1 Empty Line"
|
||||
else: # ">1 Empty Line", ">1 Emptyline"
|
||||
raw_blocks = re.split(r'\n\s*\n+', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
|
||||
@@ -179,11 +211,13 @@ class PromptQueueIterator:
|
||||
"separator": ([
|
||||
">1 Empty Line",
|
||||
"Newline",
|
||||
">2 Empty Lines"
|
||||
">2 Empty Lines",
|
||||
"Custom Regex"
|
||||
], {"default": ">1 Empty Line"}),
|
||||
"text": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"custom_regex": ("STRING", {"default": "", "placeholder": "Custom regex delimiter (e.g. \\n---\\n)"}),
|
||||
"prompt": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"hidden": {
|
||||
@@ -207,6 +241,7 @@ class PromptQueueIterator:
|
||||
pop_mode="Sequential (Loop on End)",
|
||||
separator=">1 Empty Line",
|
||||
text="",
|
||||
custom_regex="",
|
||||
prompt=None,
|
||||
prompt_text=None,
|
||||
unique_id="default",
|
||||
@@ -217,7 +252,7 @@ class PromptQueueIterator:
|
||||
if not text_str.strip():
|
||||
return ("", "", 0)
|
||||
|
||||
original_blocks = parse_prompt_blocks(text_str, separator)
|
||||
original_blocks = parse_prompt_blocks(text_str, separator, custom_regex)
|
||||
total_count = len(original_blocks)
|
||||
if total_count == 0:
|
||||
return ("", "", 0)
|
||||
@@ -285,7 +320,18 @@ class PromptQueueIterator:
|
||||
|
||||
# Calculate remaining items
|
||||
remaining_count = max(0, total_count - display_run)
|
||||
join_delim = "\n" if separator == "Newline" else ("\n\n\n" if separator == ">2 Empty Lines" else "\n\n")
|
||||
sep_lower = str(separator).lower() if separator else ""
|
||||
if "newline" in sep_lower:
|
||||
join_delim = "\n"
|
||||
elif ">2" in sep_lower or "2 empty" in sep_lower:
|
||||
join_delim = "\n\n\n"
|
||||
elif "regex" in sep_lower:
|
||||
if custom_regex and not any(c in custom_regex for c in r"*+?{}[\]^$|()\\"):
|
||||
join_delim = custom_regex
|
||||
else:
|
||||
join_delim = "\n\n"
|
||||
else:
|
||||
join_delim = "\n\n"
|
||||
remaining_text = join_delim.join(original_blocks[display_run:]) if display_run < total_count else ""
|
||||
|
||||
# Update state dictionary
|
||||
|
||||
+215
-76
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
@@ -7,7 +9,7 @@ from server import PromptServer
|
||||
import nodes
|
||||
from .tray_icon import TrayIconManager, is_tray_icon_enabled
|
||||
from .assets_restore import assets_restore_manager, is_assets_restore_enabled, get_assets_restore_count
|
||||
from .utils import get_leafflow_user_dir, is_local_request
|
||||
from .utils import get_leafflow_user_dir, is_local_request, is_authenticated_local_request
|
||||
|
||||
QUEUE_CATEGORY = "🍃 LeafFlow/Queue"
|
||||
|
||||
@@ -31,27 +33,9 @@ def is_persistent_queue_enabled():
|
||||
val = get_env_setting("ENABLE_PERSISTENT_QUEUE", "true").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
class PauseQueueNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {}}
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "noop"
|
||||
CATEGORY = QUEUE_CATEGORY
|
||||
DESCRIPTION = "Settings & status anchor for Pause Queue toolbar features."
|
||||
def noop(self):
|
||||
return ()
|
||||
|
||||
class PersistentQueueNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {}}
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "noop"
|
||||
CATEGORY = QUEUE_CATEGORY
|
||||
DESCRIPTION = "Settings & status anchor for Persistent Queue auto-recovery features."
|
||||
def noop(self):
|
||||
return ()
|
||||
def is_process_management_enabled():
|
||||
val = get_env_setting("ALLOW_PROCESS_MANAGEMENT", "false").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
class PauseQueueManager:
|
||||
def __init__(self):
|
||||
@@ -71,57 +55,68 @@ class PauseQueueManager:
|
||||
def patch_all(self):
|
||||
if self._patched:
|
||||
return
|
||||
server = PromptServer.instance
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if not server:
|
||||
print("[LeafFlow] Queue control: Skipped pause patching (PromptServer instance not ready).")
|
||||
return
|
||||
|
||||
if hasattr(server, "prompt_queue"):
|
||||
queue = server.prompt_queue
|
||||
original_get = queue.get
|
||||
if not hasattr(server, "prompt_queue") or not hasattr(server.prompt_queue, "get"):
|
||||
print("[LeafFlow] Queue control: Skipped patching prompt_queue.get (incompatible ComfyUI version).")
|
||||
return
|
||||
|
||||
def patched_get(*args, **kwargs):
|
||||
while True:
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
queue = server.prompt_queue
|
||||
original_get = queue.get
|
||||
|
||||
item = original_get(*args, **kwargs)
|
||||
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
|
||||
return item
|
||||
|
||||
queue.get = patched_get
|
||||
|
||||
original_send_sync = server.send_sync
|
||||
|
||||
def patched_send_sync(event, data, sid=None):
|
||||
if event == "executing" and isinstance(data, dict) and data.get("node") is not None:
|
||||
if self.paused and self.mode == "instantly":
|
||||
def patched_get(*args, **kwargs):
|
||||
while True:
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print(f"[PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused and self.mode == "instantly":
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
return original_send_sync(event, data, sid)
|
||||
|
||||
server.send_sync = patched_send_sync
|
||||
item = original_get(*args, **kwargs)
|
||||
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
|
||||
return item
|
||||
|
||||
queue.get = patched_get
|
||||
|
||||
if hasattr(server, "send_sync") and callable(server.send_sync):
|
||||
original_send_sync = server.send_sync
|
||||
|
||||
def patched_send_sync(event, data, sid=None):
|
||||
if event == "executing" and isinstance(data, dict) and data.get("node") is not None:
|
||||
if self.paused and self.mode == "instantly":
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print(f"[PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
|
||||
self.notify_clients()
|
||||
while self.paused and self.mode == "instantly":
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
return original_send_sync(event, data, sid)
|
||||
|
||||
server.send_sync = patched_send_sync
|
||||
else:
|
||||
print("[LeafFlow] Queue control: Skipped patching send_sync (not found on server).")
|
||||
|
||||
self._patched = True
|
||||
print("[LeafFlow] Queue control: Pause manager initialized successfully.")
|
||||
|
||||
def is_currently_executing(self):
|
||||
try:
|
||||
@@ -285,11 +280,18 @@ class PersistentQueueManager:
|
||||
def patch_server(self):
|
||||
if self._patched:
|
||||
return
|
||||
server = PromptServer.instance
|
||||
if not hasattr(server, "prompt_queue"):
|
||||
if not is_persistent_queue_enabled():
|
||||
print("[LeafFlow] Persistent queue disabled in settings; core prompt_queue patching skipped.")
|
||||
return
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if not server or not hasattr(server, "prompt_queue"):
|
||||
print("[LeafFlow] Queue control: Skipped persistent queue patching (prompt_queue not found on server).")
|
||||
return
|
||||
|
||||
queue = server.prompt_queue
|
||||
if not hasattr(queue, "put") or not hasattr(queue, "task_done"):
|
||||
print("[LeafFlow] Queue control: Skipped persistent queue patching (incompatible queue interface).")
|
||||
return
|
||||
|
||||
# 1. Patch queue.put
|
||||
original_put = queue.put
|
||||
@@ -359,7 +361,7 @@ class PersistentQueueManager:
|
||||
server.send_sync = patched_send_sync
|
||||
|
||||
self._patched = True
|
||||
print("[PersistentQueue] Server queue hooks patched successfully.")
|
||||
print("[LeafFlow] Queue control: Persistent queue manager initialized successfully.")
|
||||
|
||||
def restore_queue(self, active_client_id=None):
|
||||
if self.has_claimed_once:
|
||||
@@ -462,6 +464,9 @@ class PowerControlManager:
|
||||
def arm(self, action):
|
||||
with self.lock:
|
||||
if action in ["restart", "shutdown"]:
|
||||
if not is_process_management_enabled():
|
||||
print(f"[LeafFlow Power] Arm rejected: Process Management is disabled in LeafFlow settings.")
|
||||
return False
|
||||
self.pending_action = action
|
||||
import time
|
||||
self.armed_at = time.time()
|
||||
@@ -471,13 +476,15 @@ class PowerControlManager:
|
||||
self.armed_at = None
|
||||
print("[LeafFlow Power] Cancelled armed queue power action.")
|
||||
self.notify_clients()
|
||||
return True
|
||||
|
||||
def get_status(self):
|
||||
with self.lock:
|
||||
return {
|
||||
"pending_action": self.pending_action,
|
||||
"armed_at": self.armed_at,
|
||||
"is_paused": getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False)
|
||||
"is_paused": getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False),
|
||||
"enabled": is_process_management_enabled()
|
||||
}
|
||||
|
||||
def notify_clients(self):
|
||||
@@ -491,6 +498,11 @@ class PowerControlManager:
|
||||
if not self.pending_action or self._executing:
|
||||
return
|
||||
|
||||
# CRITICAL: If process management is disabled, cancel and abort
|
||||
if not is_process_management_enabled():
|
||||
self.pending_action = None
|
||||
return
|
||||
|
||||
# CRITICAL: If pause mode is active or waiting, DO NOT execute!
|
||||
is_paused = getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False)
|
||||
if is_paused:
|
||||
@@ -516,6 +528,10 @@ class PowerControlManager:
|
||||
self.execute_shutdown()
|
||||
|
||||
def execute_restart(self):
|
||||
if not is_process_management_enabled():
|
||||
print("[LeafFlow Power] Rejected restart: Process Management is disabled in LeafFlow settings.")
|
||||
return False
|
||||
|
||||
def _run():
|
||||
import subprocess, sys, os, time
|
||||
print("[LeafFlow Power] Restarting ComfyUI server process...")
|
||||
@@ -534,14 +550,49 @@ class PowerControlManager:
|
||||
time.sleep(0.4)
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return True
|
||||
|
||||
def execute_shutdown(self):
|
||||
if not is_process_management_enabled():
|
||||
print("[LeafFlow Power] Rejected shutdown: Process Management is disabled in LeafFlow settings.")
|
||||
return False
|
||||
|
||||
def _run():
|
||||
import os, time
|
||||
print("[LeafFlow Power] Shutting down ComfyUI server...")
|
||||
time.sleep(0.6)
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return True
|
||||
|
||||
_POWER_TICKETS = {}
|
||||
_POWER_TICKET_LOCK = threading.Lock()
|
||||
|
||||
def _issue_power_ticket(action):
|
||||
with _POWER_TICKET_LOCK:
|
||||
now = time.time()
|
||||
expired = [t for t, (exp, _) in _POWER_TICKETS.items() if exp < now]
|
||||
for t in expired:
|
||||
_POWER_TICKETS.pop(t, None)
|
||||
ticket = secrets.token_urlsafe(24)
|
||||
_POWER_TICKETS[ticket] = (now + 30.0, action)
|
||||
return ticket
|
||||
|
||||
def _consume_power_ticket(ticket, expected_action=None):
|
||||
if not ticket or not isinstance(ticket, str):
|
||||
return False
|
||||
with _POWER_TICKET_LOCK:
|
||||
now = time.time()
|
||||
if ticket not in _POWER_TICKETS:
|
||||
return False
|
||||
exp, act = _POWER_TICKETS[ticket]
|
||||
if exp < now:
|
||||
_POWER_TICKETS.pop(ticket, None)
|
||||
return False
|
||||
if expected_action and act != expected_action:
|
||||
return False
|
||||
_POWER_TICKETS.pop(ticket, None)
|
||||
return True
|
||||
|
||||
pause_manager = PauseQueueManager()
|
||||
persistent_manager = PersistentQueueManager()
|
||||
@@ -569,8 +620,13 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/leafflow/power/arm")
|
||||
async def arm_power_action(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings to use restart or shutdown.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -579,24 +635,97 @@ def setup_queue_control_routes(server):
|
||||
power_manager.arm(action)
|
||||
return web.json_response(power_manager.get_status())
|
||||
|
||||
@routes.post("/leafflow/power/request_token")
|
||||
async def request_power_token(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
action = data.get("action")
|
||||
if action not in ["restart", "shutdown"]:
|
||||
return web.json_response({"error": "Invalid action. Must be 'restart' or 'shutdown'."}, status=400)
|
||||
ticket = _issue_power_ticket(action)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"ticket": ticket,
|
||||
"action": action,
|
||||
"expires_in": 30
|
||||
})
|
||||
|
||||
@routes.post("/leafflow/power/confirm_action")
|
||||
async def confirm_power_action(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
ticket = data.get("ticket")
|
||||
action = data.get("action")
|
||||
if not _consume_power_ticket(ticket, action):
|
||||
return web.json_response({"error": "Invalid or expired confirmation ticket. Operator confirmation required."}, status=403)
|
||||
if action == "restart":
|
||||
power_manager.execute_restart()
|
||||
return web.json_response({"status": "restarting", "success": True})
|
||||
elif action == "shutdown":
|
||||
power_manager.execute_shutdown()
|
||||
return web.json_response({"status": "shutting_down", "success": True})
|
||||
return web.json_response({"error": "Unknown action"}, status=400)
|
||||
|
||||
@routes.post("/leafflow/power/restart")
|
||||
async def trigger_restart(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings to use restart.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
ticket = data.get("ticket")
|
||||
if not _consume_power_ticket(ticket, "restart"):
|
||||
return web.json_response({"error": "Confirmation ticket required. Use /leafflow/power/request_token first."}, status=403)
|
||||
power_manager.execute_restart()
|
||||
return web.json_response({"status": "restarting"})
|
||||
|
||||
@routes.post("/leafflow/power/shutdown")
|
||||
async def trigger_shutdown(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings to use shutdown.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
ticket = data.get("ticket")
|
||||
if not _consume_power_ticket(ticket, "shutdown"):
|
||||
return web.json_response({"error": "Confirmation ticket required. Use /leafflow/power/request_token first."}, status=403)
|
||||
power_manager.execute_shutdown()
|
||||
return web.json_response({"status": "shutting_down"})
|
||||
|
||||
@routes.post("/leafflow/assets/restore")
|
||||
async def restore_assets_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -620,8 +749,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/leafflow/batch_queue/sync")
|
||||
async def sync_batch_queue_data(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
pid = data.get("prompt_id")
|
||||
@@ -635,6 +764,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.get("/pause_queue/status")
|
||||
async def get_status(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
pause_manager.patch_all()
|
||||
return web.json_response({
|
||||
"paused": pause_manager.paused,
|
||||
@@ -644,6 +775,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/pause_queue/toggle")
|
||||
async def toggle_pause(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -659,6 +792,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/pause_queue/mode")
|
||||
async def set_mode_route(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -673,6 +808,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/pause_queue/continue")
|
||||
async def continue_queue(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
pause_manager.set_pause(False)
|
||||
return web.json_response({
|
||||
"paused": pause_manager.paused,
|
||||
@@ -682,6 +819,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/persistent_queue/claim")
|
||||
async def claim_queue(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
client_id = data.get("client_id")
|
||||
|
||||
+9
-10
@@ -21,7 +21,13 @@ def get_env_setting(key, default_val):
|
||||
|
||||
def is_tray_icon_enabled():
|
||||
val = get_env_setting("ENABLE_TRAY_ICON", "false").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
if val not in ["true", "1", "yes"]:
|
||||
return False
|
||||
try:
|
||||
import pystray
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
class TrayIconManager:
|
||||
def __init__(self, pause_manager=None):
|
||||
@@ -172,15 +178,8 @@ class TrayIconManager:
|
||||
try:
|
||||
import pystray
|
||||
except ImportError:
|
||||
try:
|
||||
import subprocess
|
||||
print("[LeafFlow] pystray is not installed. Auto-installing pystray...")
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "pystray"])
|
||||
import pystray
|
||||
print("[LeafFlow] Successfully auto-installed pystray.")
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Failed to auto-install pystray: {e}. System tray icon cannot start.")
|
||||
return
|
||||
print("[LeafFlow] System tray icon disabled: 'pystray' is not installed. Install pystray ('pip install pystray') to enable system tray support.")
|
||||
return
|
||||
|
||||
_, color, symbol, title = self.get_status_info()
|
||||
icon_img = self.create_icon_image(color, symbol)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import urlparse
|
||||
import folder_paths
|
||||
|
||||
def get_leafflow_user_dir():
|
||||
@@ -30,6 +32,67 @@ def get_leafflow_user_dir():
|
||||
os.makedirs(leafflow_dir, exist_ok=True)
|
||||
return leafflow_dir
|
||||
|
||||
_SESSION_CSRF_TOKEN = secrets.token_hex(32)
|
||||
|
||||
def get_csrf_token():
|
||||
"""Returns the ephemeral cryptographically random session CSRF token generated on server boot."""
|
||||
return _SESSION_CSRF_TOKEN
|
||||
|
||||
def is_same_origin_or_direct(request):
|
||||
"""
|
||||
Validates that a request originates from the local ComfyUI interface and not from a visited web page (CSRF defense).
|
||||
"""
|
||||
if request is None:
|
||||
return True
|
||||
|
||||
# 1. Reject cross-site requests via Fetch Metadata (modern browsers always send this; cannot be spoofed by JS)
|
||||
sec_fetch_site = getattr(request, "headers", {}).get("Sec-Fetch-Site")
|
||||
if sec_fetch_site and sec_fetch_site.lower() == "cross-site":
|
||||
return False
|
||||
|
||||
# 2. Check Origin header if present
|
||||
origin = getattr(request, "headers", {}).get("Origin")
|
||||
if origin:
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
host = parsed.hostname
|
||||
if host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient"]:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 3. Check Referer header if present
|
||||
referer = getattr(request, "headers", {}).get("Referer")
|
||||
if referer:
|
||||
try:
|
||||
parsed_ref = urlparse(referer)
|
||||
ref_host = parsed_ref.hostname
|
||||
if ref_host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient"]:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_authenticated_local_request(request):
|
||||
"""
|
||||
Guarantees user-initiated actions and protects against CSRF / drive-by requests from open browser tabs:
|
||||
1. Originates strictly from loopback IP (is_local_request)
|
||||
2. Not a cross-site drive-by request (is_same_origin_or_direct)
|
||||
3. Contains a valid X-LeafFlow-CSRF-Token header matching the session secret.
|
||||
"""
|
||||
if not is_local_request(request):
|
||||
return False
|
||||
if not is_same_origin_or_direct(request):
|
||||
return False
|
||||
|
||||
headers = getattr(request, "headers", {})
|
||||
token = headers.get("X-LeafFlow-CSRF-Token")
|
||||
if not token or not secrets.compare_digest(token, _SESSION_CSRF_TOKEN):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_local_request(request):
|
||||
"""
|
||||
Returns True if the HTTP request originates strictly from loopback (localhost / 127.0.0.1 / ::1).
|
||||
|
||||
+5
-2
@@ -1,10 +1,13 @@
|
||||
[project]
|
||||
name = "comfyui-leafflow"
|
||||
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
|
||||
version = "2.2.0"
|
||||
version = "2.3.1"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = ["pillow", "numpy", "piexif", "pystray"]
|
||||
dependencies = ["pillow", "numpy", "piexif"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
tray = ["pystray"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/KOFiblto/ComfyUI-LeafFlow"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
Pillow
|
||||
piexif
|
||||
numpy
|
||||
pystray
|
||||
# pystray # Optional: install manually if desktop system tray icon integration is enabled
|
||||
|
||||
@@ -4,6 +4,8 @@ import unittest
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if test_dir not in sys.path:
|
||||
sys.path.insert(0, test_dir)
|
||||
suite = unittest.defaultTestLoader.discover(test_dir, pattern="test_*.py")
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
from nodes.local_runner import (
|
||||
RunLocalFileNode,
|
||||
get_allowed_scripts_directory,
|
||||
is_script_path_permitted,
|
||||
resolve_target_path,
|
||||
authorize_node_run,
|
||||
consume_node_authorization,
|
||||
clear_all_authorizations
|
||||
)
|
||||
|
||||
class TestLocalRunner(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = RunLocalFileNode()
|
||||
clear_all_authorizations()
|
||||
|
||||
def tearDown(self):
|
||||
clear_all_authorizations()
|
||||
|
||||
def test_operator_authorization_required(self):
|
||||
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
script_path = os.path.join(temp_dir, "test.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("@echo off\n")
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir):
|
||||
stdout, stderr, exit_code, success, passthrough = self.node.run_local_file(
|
||||
file_path="test.bat",
|
||||
unique_id="node_99",
|
||||
trigger="trigger_data"
|
||||
)
|
||||
self.assertEqual(exit_code, -1)
|
||||
self.assertFalse(success)
|
||||
self.assertIn("Authorize Run", stderr)
|
||||
self.assertEqual(passthrough, "trigger_data")
|
||||
|
||||
def test_global_security_setting_disabled_by_default(self):
|
||||
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=False):
|
||||
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
|
||||
file_path="some_script.bat",
|
||||
unique_id="node_99"
|
||||
)
|
||||
self.assertEqual(exit_code, -1)
|
||||
self.assertFalse(success)
|
||||
self.assertIn("disabled by default for security", stderr)
|
||||
|
||||
def test_path_confinement_rejects_absolute_paths(self):
|
||||
resolved, msg = resolve_target_path("C:\\Windows\\System32\\cmd.exe")
|
||||
self.assertIsNone(resolved)
|
||||
self.assertIn("Absolute paths are forbidden", msg)
|
||||
|
||||
resolved_posix, msg_posix = resolve_target_path("/bin/sh")
|
||||
self.assertIsNone(resolved_posix)
|
||||
self.assertIn("Absolute paths are forbidden", msg_posix)
|
||||
|
||||
def test_path_confinement_rejects_traversal(self):
|
||||
resolved, msg = resolve_target_path("../../../etc/passwd")
|
||||
self.assertIsNone(resolved)
|
||||
self.assertIn("Directory traversal", msg)
|
||||
|
||||
resolved_win, msg_win = resolve_target_path("scripts/../../sensitive.txt")
|
||||
self.assertIsNone(resolved_win)
|
||||
self.assertIn("Directory traversal", msg_win)
|
||||
|
||||
def test_single_use_authorization_consumed_on_run(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if sys.platform.startswith("win"):
|
||||
script_path = os.path.join(temp_dir, "single_use.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("@echo off\necho Consumed\n")
|
||||
else:
|
||||
script_path = os.path.join(temp_dir, "single_use.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write('#!/bin/bash\necho "Consumed"\n')
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
|
||||
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
|
||||
# 1. Authorize for node_42
|
||||
filename = os.path.basename(script_path)
|
||||
authorize_node_run("node_42", filename)
|
||||
|
||||
# First run must succeed
|
||||
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
unique_id="node_42"
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue(success)
|
||||
|
||||
# Second run with same node ID must be rejected (authorization consumed)
|
||||
stdout2, stderr2, exit_code2, success2, _ = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
unique_id="node_42"
|
||||
)
|
||||
self.assertEqual(exit_code2, -1)
|
||||
self.assertFalse(success2)
|
||||
self.assertIn("Node has not been authorized", stderr2)
|
||||
|
||||
def test_script_mismatch_authorization_rejected(self):
|
||||
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
authorize_node_run("node_1", "approved_tool.bat")
|
||||
authorized, msg = consume_node_authorization("node_1", "C:\\scripts\\malicious_tool.bat")
|
||||
self.assertFalse(authorized)
|
||||
self.assertIn("does not match", msg)
|
||||
|
||||
def test_build_command_args(self):
|
||||
target = "test_script.bat" if sys.platform.startswith("win") else "test_script.sh"
|
||||
cmd = self.node.build_command_args(target, '--arg1 "value with spaces" --num 42')
|
||||
if sys.platform.startswith("win"):
|
||||
self.assertIn("/c", cmd)
|
||||
self.assertIn("--arg1", cmd)
|
||||
self.assertTrue(any("value with spaces" in x for x in cmd))
|
||||
self.assertIn("42", cmd)
|
||||
else:
|
||||
self.assertIn("/bin/bash", cmd)
|
||||
self.assertIn("--arg1", cmd)
|
||||
self.assertIn("value with spaces", cmd)
|
||||
|
||||
def test_execution_synchronous(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if sys.platform.startswith("win"):
|
||||
script_path = os.path.join(temp_dir, "echo_test.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("@echo off\necho Output: %1 %2\n")
|
||||
else:
|
||||
script_path = os.path.join(temp_dir, "echo_test.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write('#!/bin/bash\necho "Output: $1 $2"\n')
|
||||
|
||||
filename = os.path.basename(script_path)
|
||||
authorize_node_run("node_sync", filename)
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
|
||||
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
stdout, stderr, exit_code, success, passthrough = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
parameters="Hello World",
|
||||
run_mode="Synchronous (Wait for Output)",
|
||||
trigger="flow_pass",
|
||||
unique_id="node_sync"
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue(success)
|
||||
self.assertIn("Hello World", stdout)
|
||||
self.assertEqual(passthrough, "flow_pass")
|
||||
|
||||
def test_timeout_cancellation(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if sys.platform.startswith("win"):
|
||||
script_path = os.path.join(temp_dir, "sleep_test.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write(f"@echo off\n\"{sys.executable}\" -c \"import time; time.sleep(5)\"\n")
|
||||
else:
|
||||
script_path = os.path.join(temp_dir, "sleep_test.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("#!/bin/bash\nsleep 3\n")
|
||||
|
||||
filename = os.path.basename(script_path)
|
||||
authorize_node_run("node_timeout", filename)
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
|
||||
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
timeout=1,
|
||||
run_mode="Synchronous (Wait for Output)",
|
||||
unique_id="node_timeout"
|
||||
)
|
||||
self.assertEqual(exit_code, -1)
|
||||
self.assertFalse(success)
|
||||
self.assertIn("timed out", stderr)
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+98
-38
@@ -1,6 +1,20 @@
|
||||
import unittest
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from nodes.queue_control import PowerControlManager, PauseQueueManager
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.queue_control import (
|
||||
PowerControlManager,
|
||||
PauseQueueManager,
|
||||
_issue_power_ticket,
|
||||
_consume_power_ticket,
|
||||
_POWER_TICKETS,
|
||||
_POWER_TICKET_LOCK
|
||||
)
|
||||
|
||||
class TestPowerControlManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -8,56 +22,102 @@ class TestPowerControlManager(unittest.TestCase):
|
||||
self.pause_manager.paused = False
|
||||
self.pause_manager.is_waiting = False
|
||||
self.power_manager = PowerControlManager(self.pause_manager)
|
||||
with _POWER_TICKET_LOCK:
|
||||
_POWER_TICKETS.clear()
|
||||
|
||||
def tearDown(self):
|
||||
with _POWER_TICKET_LOCK:
|
||||
_POWER_TICKETS.clear()
|
||||
|
||||
def test_arm_disabled_by_default(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=False):
|
||||
result = self.power_manager.arm("restart")
|
||||
self.assertFalse(result)
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
self.assertIsNone(self.power_manager.armed_at)
|
||||
status = self.power_manager.get_status()
|
||||
self.assertFalse(status["enabled"])
|
||||
self.assertFalse(self.power_manager.execute_restart())
|
||||
self.assertFalse(self.power_manager.execute_shutdown())
|
||||
|
||||
def test_arm_and_disarm(self):
|
||||
self.power_manager.arm("restart")
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
self.assertIsNotNone(self.power_manager.armed_at)
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
result = self.power_manager.arm("restart")
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
self.assertIsNotNone(self.power_manager.armed_at)
|
||||
|
||||
status = self.power_manager.get_status()
|
||||
self.assertEqual(status["pending_action"], "restart")
|
||||
status = self.power_manager.get_status()
|
||||
self.assertEqual(status["pending_action"], "restart")
|
||||
self.assertTrue(status["enabled"])
|
||||
|
||||
self.power_manager.arm("shutdown")
|
||||
self.assertEqual(self.power_manager.pending_action, "shutdown")
|
||||
self.power_manager.arm("shutdown")
|
||||
self.assertEqual(self.power_manager.pending_action, "shutdown")
|
||||
|
||||
self.power_manager.arm(None)
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
self.assertIsNone(self.power_manager.armed_at)
|
||||
self.power_manager.arm(None)
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
self.assertIsNone(self.power_manager.armed_at)
|
||||
|
||||
def test_power_ticket_single_use_handshake(self):
|
||||
# 1. Issue ticket for restart
|
||||
ticket = _issue_power_ticket("restart")
|
||||
self.assertIsInstance(ticket, str)
|
||||
self.assertGreater(len(ticket), 16)
|
||||
|
||||
# 2. Consume ticket with wrong action -> Rejected
|
||||
self.assertFalse(_consume_power_ticket(ticket, "shutdown"))
|
||||
|
||||
# 3. Consume ticket with correct action -> Accepted
|
||||
self.assertTrue(_consume_power_ticket(ticket, "restart"))
|
||||
|
||||
# 4. Consume ticket again -> Rejected (single-use)
|
||||
self.assertFalse(_consume_power_ticket(ticket, "restart"))
|
||||
|
||||
def test_power_ticket_expiration(self):
|
||||
ticket = _issue_power_ticket("shutdown")
|
||||
# Artificially expire the ticket
|
||||
with _POWER_TICKET_LOCK:
|
||||
_POWER_TICKETS[ticket] = (time.time() - 1.0, "shutdown")
|
||||
|
||||
self.assertFalse(_consume_power_ticket(ticket, "shutdown"))
|
||||
|
||||
def test_does_not_execute_when_paused(self):
|
||||
self.power_manager.arm("restart")
|
||||
self.pause_manager.paused = True
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
self.power_manager.arm("restart")
|
||||
self.pause_manager.paused = True
|
||||
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
|
||||
def test_does_not_execute_when_tasks_remaining(self):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 2
|
||||
mock_queue.currently_running = {"1": True}
|
||||
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
|
||||
def test_executes_when_queue_empty_and_idle(self):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 0
|
||||
mock_queue.currently_running = {}
|
||||
def test_does_not_execute_when_tasks_remaining(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 2
|
||||
mock_queue.currently_running = {"1": True}
|
||||
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_called_once()
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
|
||||
def test_executes_when_queue_empty_and_idle(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 0
|
||||
mock_queue.currently_running = {}
|
||||
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_called_once()
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.prompt_counter import PromptCounter
|
||||
|
||||
class TestPromptCounter(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = PromptCounter()
|
||||
|
||||
def test_empty_and_whitespace(self):
|
||||
txt, count = self.node.count_prompts("")
|
||||
self.assertEqual(txt, "")
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
txt, count = self.node.count_prompts(" \n\n \t ")
|
||||
self.assertEqual(txt, " \n\n \t ")
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_single_prompt(self):
|
||||
sample = "A magnificent tree standing on a hill, photorealistic 8k"
|
||||
txt, count = self.node.count_prompts(sample)
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_newline_mode(self):
|
||||
sample = "Prompt 1\nPrompt 2\nPrompt 3\n\nPrompt 4"
|
||||
txt, count = self.node.count_prompts(sample, separator="Newline")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 4)
|
||||
|
||||
def test_empty_line_mode(self):
|
||||
sample = "Line 1 of prompt 1\nLine 2 of prompt 1\n\nPrompt 2\n\nPrompt 3"
|
||||
txt, count = self.node.count_prompts(sample, separator=">1 Empty Line")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_gt_two_empty_lines_mode(self):
|
||||
sample = "Prompt 1\n\nStill prompt 1\n\n\nPrompt 2"
|
||||
txt, count = self.node.count_prompts(sample, separator=">2 Empty Lines")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
def test_custom_regex(self):
|
||||
sample = "Prompt A\n---\nPrompt B\n---\nPrompt C"
|
||||
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"\n---\n")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_custom_regex_capturing_group(self):
|
||||
sample = "Prompt A\n===\nPrompt B\n===\nPrompt C"
|
||||
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"(\n===\n)")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_custom_regex_invalid_fallback(self):
|
||||
sample = "Prompt 1\n\nPrompt 2"
|
||||
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"[invalid-regex")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -32,6 +32,22 @@ class TestPromptQueueIterator(unittest.TestCase):
|
||||
self.assertEqual(blocks[0], "Block 1 line 1\nBlock 1 line 2")
|
||||
self.assertEqual(blocks[1], "Block 2")
|
||||
|
||||
def test_parse_prompt_blocks_custom_regex(self):
|
||||
text = "Prompt A\n---\nPrompt B\n---\nPrompt C"
|
||||
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"\n---\n")
|
||||
self.assertEqual(blocks, ["Prompt A", "Prompt B", "Prompt C"])
|
||||
|
||||
def test_parse_prompt_blocks_custom_regex_capturing_group(self):
|
||||
text = "Prompt A\n---\nPrompt B\n---\nPrompt C"
|
||||
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"(\n---\n)")
|
||||
self.assertEqual(blocks, ["Prompt A", "Prompt B", "Prompt C"])
|
||||
|
||||
def test_parse_prompt_blocks_custom_regex_invalid_fallback(self):
|
||||
text = "Prompt 1\n\nPrompt 2"
|
||||
# Invalid regex [unterminated class
|
||||
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"[unterminated")
|
||||
self.assertEqual(blocks, ["Prompt 1", "Prompt 2"])
|
||||
|
||||
def test_sequential_10_batch_execution(self):
|
||||
"""
|
||||
Verify that 10 prompts are executed in exact sequential order (0 to 9).
|
||||
|
||||
+81
-1
@@ -12,7 +12,10 @@ from nodes.utils import (
|
||||
format_lora_output_name,
|
||||
is_local_request,
|
||||
is_safe_path,
|
||||
sanitize_image_loader_folder
|
||||
sanitize_image_loader_folder,
|
||||
get_csrf_token,
|
||||
is_same_origin_or_direct,
|
||||
is_authenticated_local_request
|
||||
)
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
@@ -27,6 +30,70 @@ class TestUtils(unittest.TestCase):
|
||||
self.assertFalse(is_local_request(DummyReq("10.0.0.1")))
|
||||
self.assertFalse(is_local_request(DummyReq("8.8.8.8")))
|
||||
|
||||
def test_csrf_token_generation(self):
|
||||
token = get_csrf_token()
|
||||
self.assertIsInstance(token, str)
|
||||
self.assertEqual(len(token), 64)
|
||||
# Session token is consistent across calls in same session
|
||||
self.assertEqual(get_csrf_token(), token)
|
||||
|
||||
def test_is_same_origin_or_direct(self):
|
||||
class DummyReq:
|
||||
def __init__(self, headers=None):
|
||||
self.headers = headers or {}
|
||||
|
||||
# 1. Direct or same-origin
|
||||
self.assertTrue(is_same_origin_or_direct(DummyReq()))
|
||||
self.assertTrue(is_same_origin_or_direct(DummyReq({"Origin": "http://127.0.0.1:8188"})))
|
||||
self.assertTrue(is_same_origin_or_direct(DummyReq({"Origin": "http://localhost:8188"})))
|
||||
|
||||
# 2. Block cross-site via Sec-Fetch-Site
|
||||
self.assertFalse(is_same_origin_or_direct(DummyReq({"Sec-Fetch-Site": "cross-site"})))
|
||||
|
||||
# 3. Block malicious cross-origin
|
||||
self.assertFalse(is_same_origin_or_direct(DummyReq({"Origin": "http://attacker-site.com"})))
|
||||
self.assertFalse(is_same_origin_or_direct(DummyReq({"Referer": "http://malicious.org/exploit.html"})))
|
||||
|
||||
def test_is_authenticated_local_request(self):
|
||||
class DummyReq:
|
||||
def __init__(self, ip="127.0.0.1", headers=None):
|
||||
self.remote = ip
|
||||
self.headers = headers or {}
|
||||
|
||||
valid_token = get_csrf_token()
|
||||
|
||||
# Valid local request with matching token
|
||||
valid_req = DummyReq("127.0.0.1", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertTrue(is_authenticated_local_request(valid_req))
|
||||
|
||||
# Missing token
|
||||
no_token_req = DummyReq("127.0.0.1", {"Origin": "http://127.0.0.1:8188"})
|
||||
self.assertFalse(is_authenticated_local_request(no_token_req))
|
||||
|
||||
# Wrong token
|
||||
wrong_token_req = DummyReq("127.0.0.1", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": "invalid_forged_token"
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(wrong_token_req))
|
||||
|
||||
# Remote IP attempting to forge or replay token
|
||||
remote_req = DummyReq("192.168.1.100", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(remote_req))
|
||||
|
||||
# Cross-site drive-by request (Sec-Fetch-Site: cross-site)
|
||||
drive_by_req = DummyReq("127.0.0.1", {
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(drive_by_req))
|
||||
|
||||
def test_is_safe_path(self):
|
||||
import tempfile
|
||||
base1 = tempfile.mkdtemp()
|
||||
@@ -69,6 +136,19 @@ class TestUtils(unittest.TestCase):
|
||||
clean = sanitize_folder_path("input/watch/*")
|
||||
self.assertNotIn("*", clean)
|
||||
|
||||
def test_is_safe_external_image_url(self):
|
||||
from nodes.lora_loader import is_safe_external_image_url
|
||||
self.assertTrue(is_safe_external_image_url("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/test.jpg"))
|
||||
self.assertTrue(is_safe_external_image_url("https://image.tmdb.org/t/p/w500/sample.jpg"))
|
||||
self.assertFalse(is_safe_external_image_url("file:///etc/passwd"))
|
||||
self.assertFalse(is_safe_external_image_url("file:///C:/Windows/system32/cmd.exe"))
|
||||
self.assertFalse(is_safe_external_image_url("http://127.0.0.1:8188/secret"))
|
||||
self.assertFalse(is_safe_external_image_url("http://localhost:8080/admin"))
|
||||
self.assertFalse(is_safe_external_image_url("http://169.254.169.254/latest/meta-data/"))
|
||||
self.assertFalse(is_safe_external_image_url("ftp://server/image.png"))
|
||||
self.assertFalse(is_safe_external_image_url(""))
|
||||
self.assertFalse(is_safe_external_image_url(None))
|
||||
|
||||
def test_edge_cases_empty_or_none(self):
|
||||
self.assertEqual(parse_pretty_name(""), "")
|
||||
self.assertEqual(parse_pretty_name(None), "")
|
||||
|
||||
+109
-8
@@ -1,5 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
// 36 Curated Accessible, High-Contrast Cycling Colors for Batch Visual Grouping
|
||||
const BATCH_COLORS = [
|
||||
@@ -44,6 +45,7 @@ const BATCH_COLORS = [
|
||||
const STORAGE_KEY = "leafflow_batch_queue_meta";
|
||||
const BATCH_COUNTER_KEY = "leafflow_batch_counter";
|
||||
const SETTING_ID = "LeafFlow.BatchQueue.Enabled";
|
||||
const SETTING_SNAPSHOT_GUARD = "LeafFlow.BatchQueue.SnapshotGuard";
|
||||
|
||||
// In-memory registry mapping prompt_id -> batch metadata
|
||||
let batchRegistry = new Map();
|
||||
@@ -206,11 +208,79 @@ function isBatchQueueEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSnapshotGuardEnabled() {
|
||||
try {
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
return app.extensionManager.setting.get(SETTING_SNAPSHOT_GUARD) !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSeedInput(key, val, nodeInputs) {
|
||||
const k = String(key).toLowerCase();
|
||||
// 1. Direct seed properties (seed, noise_seed, sampler_seed, seed_num, random_seed, etc.)
|
||||
if (k.includes("seed")) {
|
||||
return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(val.trim()));
|
||||
}
|
||||
// 2. Dynamic random integer/number properties (random_value, random_int, etc.)
|
||||
if (k.includes("random") && typeof val === "number") {
|
||||
return true;
|
||||
}
|
||||
// 3. ComfyUI PrimitiveNode controlling a seed widget (contains control_after_generate or control_mode)
|
||||
if (key === "value" && nodeInputs && (nodeInputs.control_after_generate !== undefined || nodeInputs.control_mode !== undefined)) {
|
||||
return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(String(val).trim()));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergeDynamicSeeds(snapPrompt, livePrompt) {
|
||||
if (!snapPrompt) return livePrompt;
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(snapPrompt));
|
||||
if (livePrompt && livePrompt.output && cloned.output) {
|
||||
for (const [nodeId, liveNode] of Object.entries(livePrompt.output)) {
|
||||
const snapNode = cloned.output[nodeId];
|
||||
if (snapNode && snapNode.inputs && liveNode.inputs) {
|
||||
for (const [key, val] of Object.entries(liveNode.inputs)) {
|
||||
if (isSeedInput(key, val, liveNode.inputs)) {
|
||||
snapNode.inputs[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also sync seed values in workflow metadata so saved PNG metadata matches generated seeds
|
||||
if (livePrompt && livePrompt.workflow && Array.isArray(livePrompt.workflow.nodes) && cloned.workflow && Array.isArray(cloned.workflow.nodes)) {
|
||||
const liveWorkflowMap = new Map();
|
||||
for (const n of livePrompt.workflow.nodes) {
|
||||
if (n && n.id !== undefined) liveWorkflowMap.set(String(n.id), n);
|
||||
}
|
||||
for (const snapNode of cloned.workflow.nodes) {
|
||||
if (!snapNode || snapNode.id === undefined) continue;
|
||||
const liveWfNode = liveWorkflowMap.get(String(snapNode.id));
|
||||
if (liveWfNode && Array.isArray(liveWfNode.widgets_values) && Array.isArray(snapNode.widgets_values)) {
|
||||
for (let i = 0; i < liveWfNode.widgets_values.length; i++) {
|
||||
const liveVal = liveWfNode.widgets_values[i];
|
||||
const snapVal = snapNode.widgets_values[i];
|
||||
if (typeof liveVal === "number" && typeof snapVal === "number" && liveVal !== snapVal) {
|
||||
snapNode.widgets_values[i] = liveVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Error merging dynamic seeds into snapshot:", e);
|
||||
return snapPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with server if PersistentQueue is available
|
||||
async function syncBatchToServer(promptId, batchInfo) {
|
||||
try {
|
||||
if (!api || !api.fetchApi) return;
|
||||
await api.fetchApi("/leafflow/batch_queue/sync", {
|
||||
await authenticatedFetch("/leafflow/batch_queue/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -319,7 +389,9 @@ async function updateQueueBatchVisuals() {
|
||||
}
|
||||
}
|
||||
|
||||
// Hook queuePrompt to track batch execution
|
||||
let batchSubmissionQueue = Promise.resolve();
|
||||
|
||||
// Hook queuePrompt to track batch execution and guard multi-item batches
|
||||
function setupQueueHooks() {
|
||||
loadStorage();
|
||||
|
||||
@@ -328,17 +400,46 @@ function setupQueueHooks() {
|
||||
const origQueuePrompt = app.queuePrompt.bind(app);
|
||||
app.queuePrompt = async function(number, batchCount = 1, queueNodeIds) {
|
||||
const count = Math.max(1, parseInt(batchCount, 10) || 1);
|
||||
activeBatchContext = getNextBatch(count);
|
||||
saveStorage();
|
||||
return origQueuePrompt(number, batchCount, queueNodeIds);
|
||||
|
||||
// Chain onto batchSubmissionQueue to ensure sequential batch submissions
|
||||
const turn = batchSubmissionQueue.then(async () => {
|
||||
const batch = getNextBatch(count);
|
||||
activeBatchContext = batch;
|
||||
saveStorage();
|
||||
try {
|
||||
return await origQueuePrompt(number, batchCount, queueNodeIds);
|
||||
} finally {
|
||||
activeBatchContext = null;
|
||||
}
|
||||
});
|
||||
batchSubmissionQueue = turn.catch(() => {});
|
||||
return turn;
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Hook api.queuePrompt to record every generated prompt_id
|
||||
// 2. Hook api.queuePrompt to record every generated prompt_id & enforce snapshot guard
|
||||
if (api && typeof api.queuePrompt === "function") {
|
||||
const origApiQueuePrompt = api.queuePrompt.bind(api);
|
||||
api.queuePrompt = async function(number, prompt, targets) {
|
||||
const res = await origApiQueuePrompt(number, prompt, targets);
|
||||
let promptToSend = prompt;
|
||||
|
||||
// Batch Graph Snapshot Guard:
|
||||
// When queueing a batch with batchCount > 1, snapshot on iteration 0 in the background
|
||||
// so canvas edits (text, LoRA selection, widgets) don't bleed into subsequent items.
|
||||
if (isSnapshotGuardEnabled() && activeBatchContext && activeBatchContext.batchCount > 1) {
|
||||
const itemIndex = activeBatchContext.prompts.length;
|
||||
if (itemIndex === 0) {
|
||||
try {
|
||||
activeBatchContext.snapshotPrompt = JSON.parse(JSON.stringify(prompt));
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Failed to snapshot prompt:", e);
|
||||
}
|
||||
} else if (activeBatchContext.snapshotPrompt) {
|
||||
promptToSend = mergeDynamicSeeds(activeBatchContext.snapshotPrompt, prompt);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await origApiQueuePrompt(number, promptToSend, targets);
|
||||
if (res && res.prompt_id) {
|
||||
const pid = String(res.prompt_id);
|
||||
if (!activeBatchContext) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
let _cachedCsrfToken = null;
|
||||
let _tokenPromise = null;
|
||||
|
||||
export async function getCsrfToken() {
|
||||
if (_cachedCsrfToken) {
|
||||
return _cachedCsrfToken;
|
||||
}
|
||||
if (_tokenPromise) {
|
||||
return _tokenPromise;
|
||||
}
|
||||
_tokenPromise = (async () => {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/auth/token");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_cachedCsrfToken = data.csrf_token || "";
|
||||
return _cachedCsrfToken;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow Auth] Could not fetch CSRF token:", e);
|
||||
} finally {
|
||||
_tokenPromise = null;
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
return _tokenPromise;
|
||||
}
|
||||
|
||||
export async function authenticatedFetch(endpoint, options = {}) {
|
||||
const token = await getCsrfToken();
|
||||
const opts = { ...options };
|
||||
opts.headers = { ...(opts.headers || {}) };
|
||||
if (token) {
|
||||
opts.headers["X-LeafFlow-CSRF-Token"] = token;
|
||||
}
|
||||
return api.fetchApi(endpoint, opts);
|
||||
}
|
||||
|
||||
// Expose on window for all LeafFlow extensions
|
||||
if (typeof window !== "undefined") {
|
||||
window._LeafFlowAuth = {
|
||||
getToken: getCsrfToken,
|
||||
fetch: authenticatedFetch
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
// Create global styles for the decision buttons
|
||||
const styles = document.createElement("style");
|
||||
@@ -87,8 +88,9 @@ app.registerExtension({
|
||||
const sendDecision = async (action) => {
|
||||
node.disableDecisionButtons();
|
||||
try {
|
||||
await api.fetchApi("/leafflow/decision", {
|
||||
await authenticatedFetch("/leafflow/decision", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: node.id.toString(),
|
||||
action: action
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
// LeafFlow Local File Runner In-Memory Consent Gate Extension
|
||||
app.registerExtension({
|
||||
name: "LeafFlow.LocalRunner.Security",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "RunLocalFileNode") return;
|
||||
|
||||
node._isAuthorized = false;
|
||||
node._authExpiresAt = 0;
|
||||
|
||||
const filePathWidget = node.widgets?.find(w => w.name === "file_path");
|
||||
|
||||
const updateVisualStatus = () => {
|
||||
const now = Date.now();
|
||||
const isArmed = Boolean(node._isAuthorized && node._authExpiresAt > now);
|
||||
|
||||
if (isArmed) {
|
||||
const remainingSec = Math.max(0, Math.round((node._authExpiresAt - now) / 1000));
|
||||
const remainingMin = Math.ceil(remainingSec / 60);
|
||||
node.title = `🍃 ⚡ Run Local File [ARMED (${remainingMin}m)]`;
|
||||
node.color = "#047857";
|
||||
node.bgcolor = "#064e3b";
|
||||
if (authButton) {
|
||||
authButton.name = `✅ Authorized (${remainingMin}m left)`;
|
||||
}
|
||||
} else {
|
||||
node._isAuthorized = false;
|
||||
node.title = "🍃 ⚡ Run Local File [DISARMED]";
|
||||
node.color = "#7f1d1d";
|
||||
node.bgcolor = "#18181b";
|
||||
if (authButton) {
|
||||
authButton.name = "⚡ Authorize Run (5 min)";
|
||||
}
|
||||
}
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// Add explicit interactive action button: "⚡ Authorize Run (5 min)"
|
||||
const authButton = node.addWidget(
|
||||
"button",
|
||||
"⚡ Authorize Run (5 min)",
|
||||
null,
|
||||
async () => {
|
||||
const scriptName = filePathWidget?.value?.trim() || "configured script";
|
||||
const confirmed = confirm(
|
||||
"🍃 LeafFlow Security Verification\n\n" +
|
||||
`Authorize a SINGLE run of local script:\n'${scriptName}'\n\n` +
|
||||
"⚠️ Notice: Scripts can execute shell commands on your system.\n" +
|
||||
"• The script must reside inside 'ComfyUI/scripts/'.\n" +
|
||||
"• Authorization is temporary (expires in 5 minutes) and single-use.\n" +
|
||||
"• External workflows can NEVER run scripts without your live authorization.\n\n" +
|
||||
"Authorize this run now?"
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await authenticatedFetch("/leafflow/local_runner/authorize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: node.id,
|
||||
file_path: filePathWidget?.value || ""
|
||||
})
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const errMsg = err.error || "Failed to authorize node run.";
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Authorization Failed",
|
||||
detail: errMsg,
|
||||
life: 6000
|
||||
});
|
||||
} else {
|
||||
alert("❌ " + errMsg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
node._isAuthorized = true;
|
||||
node._authExpiresAt = Date.now() + (300 * 1000);
|
||||
updateVisualStatus();
|
||||
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "success",
|
||||
summary: "⚡ Script Run Authorized",
|
||||
detail: `Node ${node.id} authorized for 5 minutes. Single-use: will be consumed upon execution.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow Security] Error authorizing script run:", e);
|
||||
alert("❌ Error communicating with authorization service: " + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Intercept workflow load / configuration: ALWAYS disarm on load!
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function(info) {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
// GUARANTEE: Never let imported workflow or image auto-run with consent pre-checked
|
||||
node._isAuthorized = false;
|
||||
node._authExpiresAt = 0;
|
||||
updateVisualStatus();
|
||||
};
|
||||
|
||||
// If file path changes, invalidate existing authorization
|
||||
if (filePathWidget) {
|
||||
const origCallback = filePathWidget.callback;
|
||||
filePathWidget.callback = function(val) {
|
||||
node._isAuthorized = false;
|
||||
node._authExpiresAt = 0;
|
||||
updateVisualStatus();
|
||||
if (origCallback) origCallback.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Draw security alert ribbon on canvas if disarmed
|
||||
const origOnDrawForeground = node.onDrawForeground;
|
||||
node.onDrawForeground = function(ctx) {
|
||||
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
|
||||
if (node.flags?.collapsed) return;
|
||||
|
||||
const now = Date.now();
|
||||
const isArmed = Boolean(node._isAuthorized && node._authExpiresAt > now);
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = "italic 9px sans-serif";
|
||||
ctx.fillStyle = "#9ca3af";
|
||||
ctx.fillText("📂 Script Location: ComfyUI/scripts/", node.size[0] / 2, node.size[1] - 19);
|
||||
|
||||
ctx.font = "bold 10px sans-serif";
|
||||
if (!isArmed) {
|
||||
ctx.fillStyle = "#ef4444";
|
||||
ctx.fillText("🛡️ DISARMED: Click 'Authorize Run' to execute", node.size[0] / 2, node.size[1] - 7);
|
||||
} else {
|
||||
const rem = Math.max(0, Math.ceil((node._authExpiresAt - now) / 60000));
|
||||
ctx.fillStyle = "#10b981";
|
||||
ctx.fillText(`⚡ ARMED (${rem}m left) - Consumed on run`, node.size[0] / 2, node.size[1] - 7);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
updateVisualStatus();
|
||||
}
|
||||
});
|
||||
+147
-25
@@ -1,5 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
const EXTENSION_NAME = "LeafFlow.PowerControlSidebar";
|
||||
const STYLE_ID = "leafflow-power-sidebar-styles";
|
||||
@@ -234,7 +235,7 @@ function openConfirmModal(title, message, onConfirm, isDanger = false) {
|
||||
btnRow.style.marginTop = "8px";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.textContent = onConfirm ? "Cancel" : "Close";
|
||||
cancelBtn.style.padding = "8px 16px";
|
||||
cancelBtn.style.borderRadius = "6px";
|
||||
cancelBtn.style.border = "1px solid #52525b";
|
||||
@@ -243,32 +244,57 @@ function openConfirmModal(title, message, onConfirm, isDanger = false) {
|
||||
cancelBtn.style.cursor = "pointer";
|
||||
cancelBtn.onclick = () => overlay.remove();
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.textContent = "Confirm";
|
||||
confirmBtn.style.padding = "8px 16px";
|
||||
confirmBtn.style.borderRadius = "6px";
|
||||
confirmBtn.style.border = "none";
|
||||
confirmBtn.style.fontWeight = "600";
|
||||
confirmBtn.style.cursor = "pointer";
|
||||
if (isDanger) {
|
||||
confirmBtn.style.background = "#dc2626";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
if (onConfirm) {
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.textContent = "Confirm";
|
||||
confirmBtn.style.padding = "8px 16px";
|
||||
confirmBtn.style.borderRadius = "6px";
|
||||
confirmBtn.style.border = "none";
|
||||
confirmBtn.style.fontWeight = "600";
|
||||
confirmBtn.style.cursor = "pointer";
|
||||
if (isDanger) {
|
||||
confirmBtn.style.background = "#dc2626";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
} else {
|
||||
confirmBtn.style.background = "#2563eb";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
}
|
||||
|
||||
confirmBtn.onclick = async () => {
|
||||
overlay.remove();
|
||||
await onConfirm();
|
||||
};
|
||||
|
||||
btnRow.append(cancelBtn, confirmBtn);
|
||||
} else {
|
||||
confirmBtn.style.background = "#2563eb";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
btnRow.append(cancelBtn);
|
||||
}
|
||||
|
||||
confirmBtn.onclick = async () => {
|
||||
overlay.remove();
|
||||
if (onConfirm) await onConfirm();
|
||||
};
|
||||
|
||||
btnRow.append(cancelBtn, confirmBtn);
|
||||
modal.append(header, desc, btnRow);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function checkProcessManagementEnabled() {
|
||||
if (currentPowerState.enabled === false) {
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "🍃 Process Management Disabled",
|
||||
detail: "Enable 'Allow Process Management' in LeafFlow settings to restart or shut down ComfyUI.",
|
||||
life: 7000
|
||||
});
|
||||
}
|
||||
openConfirmModal(
|
||||
"Process Management Disabled",
|
||||
"Server restart and shutdown controls are currently disabled for security.\n\nTo enable them, go to ComfyUI Settings ⚙️ -> LeafFlow -> Pause Controls -> Turn ON 'Allow Process Management (Restart / Shutdown)'.",
|
||||
null,
|
||||
false
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function closePowerPopup() {
|
||||
if (activePopup) {
|
||||
activePopup.remove();
|
||||
@@ -315,7 +341,7 @@ function showPowerSidePopup(anchorElement) {
|
||||
cancelBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
await api.fetchApi("/leafflow/power/arm", {
|
||||
await authenticatedFetch("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: null })
|
||||
@@ -345,10 +371,46 @@ function showPowerSidePopup(anchorElement) {
|
||||
restartItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Restart ComfyUI Immediately?",
|
||||
"Are you sure you want to restart ComfyUI immediately? Any active prompt generation will be interrupted.",
|
||||
async () => {
|
||||
const tokenResp = await authenticatedFetch("/leafflow/power/request_token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restart" })
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const err = await tokenResp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Restart Denied",
|
||||
detail: err.error || "Failed to obtain power action ticket.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
const resp = await authenticatedFetch("/leafflow/power/confirm_action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ticket: tokenData.ticket, action: "restart" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Restart Failed",
|
||||
detail: err.error || "Failed to trigger restart.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
@@ -357,7 +419,6 @@ function showPowerSidePopup(anchorElement) {
|
||||
life: 8000
|
||||
});
|
||||
}
|
||||
await api.fetchApi("/leafflow/power/restart", { method: "POST" });
|
||||
setTimeout(() => window.location.reload(), 2500);
|
||||
},
|
||||
true
|
||||
@@ -377,10 +438,46 @@ function showPowerSidePopup(anchorElement) {
|
||||
shutdownItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Shutdown ComfyUI Server?",
|
||||
"Are you sure you want to SHUT DOWN the ComfyUI server immediately? The process will exit.",
|
||||
async () => {
|
||||
const tokenResp = await authenticatedFetch("/leafflow/power/request_token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "shutdown" })
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const err = await tokenResp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Shutdown Denied",
|
||||
detail: err.error || "Failed to obtain power action ticket.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
const resp = await authenticatedFetch("/leafflow/power/confirm_action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ticket: tokenData.ticket, action: "shutdown" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Shutdown Failed",
|
||||
detail: err.error || "Failed to trigger shutdown.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
@@ -389,7 +486,6 @@ function showPowerSidePopup(anchorElement) {
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
await api.fetchApi("/leafflow/power/shutdown", { method: "POST" });
|
||||
},
|
||||
true
|
||||
);
|
||||
@@ -413,15 +509,28 @@ function showPowerSidePopup(anchorElement) {
|
||||
restartQueueItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Schedule Restart After Queue Finish?",
|
||||
"ComfyUI will wait until all remaining prompts in the queue finish and the system returns to idle before restarting. (Note: If queue is paused, it will not restart).",
|
||||
async () => {
|
||||
await api.fetchApi("/leafflow/power/arm", {
|
||||
const resp = await authenticatedFetch("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restart" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Scheduling Failed",
|
||||
detail: err.error || "Failed to schedule restart.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
@@ -448,15 +557,28 @@ function showPowerSidePopup(anchorElement) {
|
||||
shutdownQueueItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Schedule Shutdown After Queue Finish?",
|
||||
"ComfyUI will wait until all queued prompts complete and the system returns to idle before shutting down completely.",
|
||||
async () => {
|
||||
await api.fetchApi("/leafflow/power/arm", {
|
||||
const resp = await authenticatedFetch("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "shutdown" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Scheduling Failed",
|
||||
detail: err.error || "Failed to schedule shutdown.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
function countPromptsInText(text, separator, customRegex) {
|
||||
if (!text || !text.trim()) return 0;
|
||||
const cleanText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const sep = (separator || ">1 Empty Line").toLowerCase();
|
||||
|
||||
if (sep.includes("regex")) {
|
||||
const patStr = (customRegex || "").trim() || "\\n\\s*\\n+";
|
||||
try {
|
||||
const re = new RegExp(patStr, "g");
|
||||
const rawBlocks = cleanText.split(re);
|
||||
let count = 0;
|
||||
for (const b of rawBlocks) {
|
||||
const s = b.trim();
|
||||
if (s) {
|
||||
try {
|
||||
if (!new RegExp(`^(?:${patStr})$`).test(b)) {
|
||||
count++;
|
||||
}
|
||||
} catch (_) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
} catch (e) {
|
||||
// Fallback on invalid regex
|
||||
const rawBlocks = cleanText.split(/\n\s*\n+/);
|
||||
return rawBlocks.filter(b => b.trim()).length;
|
||||
}
|
||||
} else if (sep.includes("newline")) {
|
||||
return cleanText.split('\n').filter(line => line.trim()).length;
|
||||
} else if (sep.includes(">2") || sep.includes("2 empty")) {
|
||||
return cleanText.split(/(?:\n\s*){3,}/).filter(b => b.trim()).length;
|
||||
} else { // >1 Empty Line / >1 Emptyline
|
||||
return cleanText.split(/\n\s*\n+/).filter(b => b.trim()).length;
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PromptCounter",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "PromptCounter") {
|
||||
const textWidget = node.widgets?.find(w => w.name === "text");
|
||||
const sepWidget = node.widgets?.find(w => w.name === "separator");
|
||||
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
|
||||
|
||||
// Add the live count display widget
|
||||
const countWidget = node.addWidget("text", "🍃 Prompts", "0 Prompts", () => {}, { serialize: false });
|
||||
countWidget.disabled = true;
|
||||
|
||||
// Custom drawing for counter widget
|
||||
countWidget.computeSize = function(width) {
|
||||
return [width, 28];
|
||||
};
|
||||
|
||||
countWidget.draw = function(ctx, n, widget_width, y, widget_height) {
|
||||
ctx.save();
|
||||
const count = n._promptCount ?? 0;
|
||||
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
|
||||
|
||||
const margin = 8;
|
||||
const badgeX = margin;
|
||||
const badgeY = y + 2;
|
||||
const badgeW = widget_width - margin * 2;
|
||||
const badgeH = Math.max(24, widget_height - 4);
|
||||
const radius = 6;
|
||||
|
||||
// Subtle dark background pill
|
||||
ctx.fillStyle = count > 0 ? "rgba(34, 197, 94, 0.15)" : "rgba(255, 255, 255, 0.05)";
|
||||
ctx.strokeStyle = count > 0 ? "rgba(34, 197, 94, 0.5)" : "rgba(255, 255, 255, 0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(badgeX, badgeY, badgeW, badgeH, radius);
|
||||
} else {
|
||||
ctx.rect(badgeX, badgeY, badgeW, badgeH);
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Text
|
||||
ctx.font = "bold 13px Inter, system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = count > 0 ? "#4ade80" : "#94a3b8";
|
||||
ctx.fillText(`🍃 ${label}`, badgeX + badgeW / 2, badgeY + badgeH / 2);
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
function updateCount() {
|
||||
const text = (textWidget?.inputEl ? textWidget.inputEl.value : textWidget?.value) || "";
|
||||
const sep = sepWidget?.value || ">1 Empty Line";
|
||||
const regex = regexWidget?.value || "";
|
||||
const count = countPromptsInText(text, sep, regex);
|
||||
node._promptCount = count;
|
||||
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
|
||||
countWidget.value = label;
|
||||
|
||||
// Manage custom_regex enable/disable
|
||||
if (regexWidget) {
|
||||
const isCustom = String(sep || "").toLowerCase().includes("regex");
|
||||
regexWidget.disabled = !isCustom;
|
||||
if (regexWidget.inputEl) {
|
||||
regexWidget.inputEl.disabled = !isCustom;
|
||||
regexWidget.inputEl.style.opacity = isCustom ? "1" : "0.4";
|
||||
regexWidget.inputEl.style.pointerEvents = isCustom ? "auto" : "none";
|
||||
}
|
||||
if (regexWidget.options) {
|
||||
regexWidget.options.disabled = !isCustom;
|
||||
}
|
||||
}
|
||||
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
// Hook DOM input listener on text textarea
|
||||
function hookInputEl() {
|
||||
if (textWidget?.inputEl && !textWidget.inputEl._promptCounterHooked) {
|
||||
textWidget.inputEl._promptCounterHooked = true;
|
||||
textWidget.inputEl.addEventListener("input", () => {
|
||||
textWidget.value = textWidget.inputEl.value;
|
||||
updateCount();
|
||||
});
|
||||
textWidget.inputEl.addEventListener("paste", () => {
|
||||
setTimeout(() => {
|
||||
textWidget.value = textWidget.inputEl.value;
|
||||
updateCount();
|
||||
}, 20);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (textWidget) {
|
||||
const origTextCb = textWidget.callback;
|
||||
textWidget.callback = function(val) {
|
||||
if (origTextCb) origTextCb.apply(this, arguments);
|
||||
updateCount();
|
||||
};
|
||||
}
|
||||
|
||||
if (sepWidget) {
|
||||
const origSepCb = sepWidget.callback;
|
||||
sepWidget.callback = function(val) {
|
||||
if (origSepCb) origSepCb.apply(this, arguments);
|
||||
updateCount();
|
||||
};
|
||||
}
|
||||
|
||||
if (regexWidget) {
|
||||
const origRegexCb = regexWidget.callback;
|
||||
regexWidget.callback = function(val) {
|
||||
if (origRegexCb) origRegexCb.apply(this, arguments);
|
||||
updateCount();
|
||||
};
|
||||
|
||||
const origMouse = regexWidget.mouse;
|
||||
regexWidget.mouse = function(event, pos, n) {
|
||||
if (this.disabled) return false;
|
||||
if (origMouse) return origMouse.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origDraw = regexWidget.draw;
|
||||
regexWidget.draw = function(ctx, n, widget_width, y, widget_height) {
|
||||
if (this.disabled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
ctx.restore();
|
||||
} else {
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Also draw title pill badge when node is collapsed
|
||||
const origOnDrawForeground = node.onDrawForeground;
|
||||
node.onDrawForeground = function(ctx) {
|
||||
hookInputEl();
|
||||
|
||||
// If textWidget value changed without callback
|
||||
const currentVal = (textWidget?.inputEl ? textWidget.inputEl.value : textWidget?.value) || "";
|
||||
if (currentVal !== node._lastPromptVal) {
|
||||
node._lastPromptVal = currentVal;
|
||||
updateCount();
|
||||
}
|
||||
|
||||
if (node.flags?.collapsed) {
|
||||
const count = node._promptCount ?? 0;
|
||||
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
|
||||
ctx.save();
|
||||
ctx.font = "bold 11px Inter, system-ui, sans-serif";
|
||||
const badgeText = `🍃 ${label}`;
|
||||
const tw = ctx.measureText(badgeText).width;
|
||||
const bx = node.size[0] - tw - 16;
|
||||
const by = -LiteGraph.NODE_TITLE_HEIGHT + 3;
|
||||
|
||||
ctx.fillStyle = count > 0 ? "rgba(34, 197, 94, 0.2)" : "rgba(255, 255, 255, 0.08)";
|
||||
ctx.strokeStyle = count > 0 ? "rgba(34, 197, 94, 0.6)" : "rgba(255, 255, 255, 0.2)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(bx, by, tw + 10, 18, 4);
|
||||
} else {
|
||||
ctx.rect(bx, by, tw + 10, 18);
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = count > 0 ? "#4ade80" : "#cbd5e1";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(badgeText, bx + 5, by + 9);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function() {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
setTimeout(() => {
|
||||
hookInputEl();
|
||||
updateCount();
|
||||
}, 40);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
hookInputEl();
|
||||
updateCount();
|
||||
}, 20);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PromptQueueIterator",
|
||||
@@ -30,7 +31,7 @@ app.registerExtension({
|
||||
try {
|
||||
resetBtn.name = "⏳ Resetting...";
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
const resp = await api.fetchApi("/leafflow/prompt_iterator/reset_node", {
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/reset_node", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: String(node.id) })
|
||||
@@ -58,7 +59,7 @@ app.registerExtension({
|
||||
try {
|
||||
openFileBtn.name = "⏳ Opening...";
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
const resp = await api.fetchApi("/leafflow/prompt_iterator/open_file", { method: "POST" });
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/open_file", { method: "POST" });
|
||||
const res = await resp.json();
|
||||
if (res && res.status === "ok") {
|
||||
openFileBtn.name = "✅ Opened in Editor!";
|
||||
@@ -74,7 +75,58 @@ app.registerExtension({
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}, 1500);
|
||||
});
|
||||
openFileBtn.serialize = false;
|
||||
// 4. Manage custom_regex enable/disable based on separator
|
||||
const sepWidget = node.widgets?.find(w => w.name === "separator");
|
||||
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
|
||||
|
||||
function updateRegexState() {
|
||||
if (!sepWidget || !regexWidget) return;
|
||||
const isCustom = String(sepWidget.value || "").toLowerCase().includes("regex");
|
||||
regexWidget.disabled = !isCustom;
|
||||
if (regexWidget.inputEl) {
|
||||
regexWidget.inputEl.disabled = !isCustom;
|
||||
regexWidget.inputEl.style.opacity = isCustom ? "1" : "0.4";
|
||||
regexWidget.inputEl.style.pointerEvents = isCustom ? "auto" : "none";
|
||||
}
|
||||
if (regexWidget.options) {
|
||||
regexWidget.options.disabled = !isCustom;
|
||||
}
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
if (sepWidget && regexWidget) {
|
||||
const origSepCb = sepWidget.callback;
|
||||
sepWidget.callback = function(val) {
|
||||
if (origSepCb) origSepCb.apply(this, arguments);
|
||||
updateRegexState();
|
||||
};
|
||||
|
||||
const origMouse = regexWidget.mouse;
|
||||
regexWidget.mouse = function(event, pos, node) {
|
||||
if (this.disabled) return false;
|
||||
if (origMouse) return origMouse.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origDraw = regexWidget.draw;
|
||||
regexWidget.draw = function(ctx, node, widget_width, y, widget_height) {
|
||||
if (this.disabled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
ctx.restore();
|
||||
} else {
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(updateRegexState, 20);
|
||||
}
|
||||
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function() {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
setTimeout(updateRegexState, 40);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ const COLOR_MAP = {
|
||||
"PreviewImageSizeAspectRatio": LEAF_LIGHT,
|
||||
"TextLoraFinder": LEAF_LIGHT,
|
||||
"PromptQueueIterator": LEAF_LIGHT,
|
||||
"PromptCounter": LEAF_LIGHT,
|
||||
"MultiTextReplacer": LEAF_LIGHT,
|
||||
"LeafFlowTextSplit": LEAF_LIGHT,
|
||||
"LeafFlowDecision": LEAF_LIGHT,
|
||||
|
||||
+67
-69
@@ -1,5 +1,18 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
async function postLeafFlowSettings(bodyObj) {
|
||||
try {
|
||||
return await authenticatedFetch("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bodyObj)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow Settings] Failed to save setting:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject CSS to ensure settings buttons look distinct, styled with an emerald theme, and never get text cut off
|
||||
if (typeof document !== "undefined") {
|
||||
@@ -235,11 +248,7 @@ app.registerExtension({
|
||||
tooltip: "Optional. Civitai SHA256 search works publicly without a key for normal models. Only needed for NSFW/private models or higher rate limits. Whitespace is automatically stripped.",
|
||||
onChange(value) {
|
||||
const cleanKey = (value || "").trim();
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ civitai_api_key: cleanKey })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ civitai_api_key: cleanKey });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -251,11 +260,7 @@ app.registerExtension({
|
||||
defaultValue: true,
|
||||
tooltip: "Toggles automated downloading of preview thumbnails for new LoRAs from Civitai via SHA256 file hashes. Note: SHA256 hash searching will always work regardless of this setting when matching local models.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_civitai_scraping: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ enable_civitai_scraping: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -268,11 +273,7 @@ app.registerExtension({
|
||||
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for celebrity poster and preview image lookup. Whitespace is automatically stripped.",
|
||||
onChange(value) {
|
||||
const cleanKey = (value || "").trim();
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tmdb_api_key: cleanKey })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ tmdb_api_key: cleanKey });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -284,11 +285,7 @@ app.registerExtension({
|
||||
defaultValue: false,
|
||||
tooltip: "Toggles automated downloading of celebrity preview thumbnails from TMDB. Default is disabled.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_tmdb_scraping: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ enable_tmdb_scraping: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -300,11 +297,7 @@ app.registerExtension({
|
||||
defaultValue: true,
|
||||
tooltip: "Toggles tracking and displaying LoRA usage counts & visual rank badges (🔥, Gold, Silver, Bronze) in the LoRA picker. Existing usage history is preserved when disabled.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_lora_usage: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ enable_lora_usage: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -320,7 +313,7 @@ app.registerExtension({
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const resp = await authenticatedFetch("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data && data.status === "ok") {
|
||||
alert("LeafFlow: Failed scrapes cache successfully reset!");
|
||||
@@ -333,7 +326,7 @@ app.registerExtension({
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("🗑️ Clear Scrapes Cache", "⏳ Clearing...", "✅ Cache Reset!", async () => {
|
||||
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const resp = await authenticatedFetch("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data.status !== "ok") {
|
||||
throw new Error(data.message || "Failed to reset scrapes cache");
|
||||
@@ -354,11 +347,7 @@ app.registerExtension({
|
||||
defaultValue: false,
|
||||
tooltip: "Privacy setting. When enabled, prompt_iterator_state.json will be emptied automatically every time ComfyUI starts up.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clear_prompt_iterator_on_launch: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ clear_prompt_iterator_on_launch: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -374,7 +363,7 @@ app.registerExtension({
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data && data.status === "ok") {
|
||||
alert("LeafFlow: Prompt Iterator queues successfully reset!");
|
||||
@@ -387,7 +376,7 @@ app.registerExtension({
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("🔄 Reset All Queues", "⏳ Resetting...", "✅ State Reset!", async () => {
|
||||
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data.status !== "ok") {
|
||||
throw new Error(data.message || "Failed to clear prompt iterator state");
|
||||
@@ -450,11 +439,7 @@ app.registerExtension({
|
||||
defaultValue: "Paused",
|
||||
tooltip: "Choose whether execution starts in Paused state or Running state on ComfyUI startup.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_pause_state: value })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ default_pause_state: value });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -468,11 +453,7 @@ app.registerExtension({
|
||||
tooltip: "Choose default pause behavior when the pause button or hotkey is triggered.",
|
||||
onChange(value) {
|
||||
const modeKey = (value === "Instant Resume Node" || value === "Pause (Instant)") ? "instantly" : "after_finish";
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_pause_mode: modeKey })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ default_pause_mode: modeKey });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -523,11 +504,19 @@ app.registerExtension({
|
||||
defaultValue: false,
|
||||
tooltip: "Displays an OS system tray icon with real-time queue status colors and outside-browser queue controls.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_tray_icon: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ enable_tray_icon: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 4.7 Allow Process Management (Restart / Shutdown)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.07_AllowProcessManagement",
|
||||
name: "Allow Process Management (Restart / Shutdown)",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Enables server restart and shutdown actions from the LeafFlow power controls. Disabled by default for security.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_process_management: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -544,11 +533,7 @@ app.registerExtension({
|
||||
defaultValue: true,
|
||||
tooltip: "Automatically persists unfinished batch queue items to disk and restores them after server or browser crashes.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_persistent_queue: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ enable_persistent_queue: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -561,11 +546,7 @@ app.registerExtension({
|
||||
defaultValue: "Match Default",
|
||||
tooltip: "Override launch state when unfinished queue items are recovered on startup.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ persistent_queue_restored_state: value })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ persistent_queue_restored_state: value });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -582,11 +563,7 @@ app.registerExtension({
|
||||
defaultValue: true,
|
||||
tooltip: "Automatically populates the Assets / History pane upon ComfyUI launch with your latest generated images.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_assets_restore: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ enable_assets_restore: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -599,11 +576,7 @@ app.registerExtension({
|
||||
tooltip: "Number of newest images from the output folder to restore into the Assets / History pane on launch (default: 64).",
|
||||
onChange(value) {
|
||||
const count = parseInt(value, 10) || 64;
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ restore_assets_count: count })
|
||||
}).catch(() => {});
|
||||
postLeafFlowSettings({ restore_assets_count: count });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -671,6 +644,31 @@ app.registerExtension({
|
||||
defaultValue: true,
|
||||
tooltip: "Renders 1D git-graph style colored lines indicating batch groupings and contiguous segments on queued items."
|
||||
});
|
||||
|
||||
// 8.2 Batch Graph Snapshot Guard
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.BatchQueue.SnapshotGuard",
|
||||
name: "Batch Graph Snapshot Guard",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "When queueing multi-item batches, snapshots prompt and node inputs (like text and LoRAs) in the background so mid-queue canvas edits do not corrupt queued items. Random seeds continue to randomize."
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 9: 9 - 🛡️ Security & Script Execution
|
||||
// =========================================================================
|
||||
|
||||
// 9.1 Allow Local File Execution (Default Disabled)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.9 - 🛡️ Security.01_AllowLocalFileExecution",
|
||||
name: "Allow Local File Execution",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Controls whether the '🍃 ⚡ Run Local File' node is permitted to run executable files (.bat, .ps1, .exe, .sh). Disabled by default for operator security. When enabled, scripts are strictly confined to the 'ComfyUI/scripts/' directory. Execution is blocked if this setting is disabled or if the node is not interactively authorized.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_local_file_execution: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
const stylePath = new URL('./pause_queue.css', import.meta.url).href;
|
||||
if (!document.querySelector(`link[href="${stylePath}"]`)) {
|
||||
@@ -57,7 +58,7 @@ class PauseQueueUI {
|
||||
|
||||
async togglePause(paused) {
|
||||
try {
|
||||
const res = await api.fetchApi("/pause_queue/toggle", {
|
||||
const res = await authenticatedFetch("/pause_queue/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ paused, mode: this.state.mode })
|
||||
@@ -73,7 +74,7 @@ class PauseQueueUI {
|
||||
|
||||
async setMode(mode) {
|
||||
try {
|
||||
const res = await api.fetchApi("/pause_queue/mode", {
|
||||
const res = await authenticatedFetch("/pause_queue/mode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode })
|
||||
@@ -89,7 +90,7 @@ class PauseQueueUI {
|
||||
|
||||
async continueQueue() {
|
||||
try {
|
||||
const res = await api.fetchApi("/pause_queue/continue", { method: "POST" });
|
||||
const res = await authenticatedFetch("/pause_queue/continue", { method: "POST" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.updateState(data);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PersistentQueue",
|
||||
@@ -46,7 +47,7 @@ app.registerExtension({
|
||||
const claimQueueOwnership = async () => {
|
||||
if (api && api.clientId) {
|
||||
try {
|
||||
await api.fetchApi("/persistent_queue/claim", {
|
||||
await authenticatedFetch("/persistent_queue/claim", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: api.clientId })
|
||||
|
||||
Reference in New Issue
Block a user