Harden local updater and install changed dependencies

This commit is contained in:
Codex
2026-09-24 10:07:37 +00:00
parent 5fccb3e8eb
commit fa9d571c18
5 changed files with 112 additions and 36 deletions
+11 -26
View File
@@ -14,36 +14,27 @@
## 📦 安装 ## 📦 安装
### 方法一:通过 ComfyUI Manager(推荐 ### 通过公开 Gitea 仓库安装(无需登录
1. 在 ComfyUI 中打开 Manager
2. 搜索 `Comfyui_o1key`
3. 点击安装
4. 重启 ComfyUI
### 方法二:手动安装
```bash ```bash
cd ComfyUI/custom_nodes cd ComfyUI/custom_nodes
git clone https://github.com/lizhongyi1209/comfyui_o1key.git git clone https://git.o1key.com/publisher/comfyui_o1key.git
cd comfyui_o1key cd comfyui_o1key
pip install -r requirements.txt python -m pip install -r requirements.txt
``` ```
然后重启 ComfyUI 请在 ComfyUI 使用的 Python 环境中运行安装命令,然后重启 ComfyUI。之后可直接使用左侧功能栏的「更新」按钮
### 国内用户安装(GitHub 拉取慢或失败时) ### 已通过 GitHub 安装的用户
使用 Gitee 镜像安装与更新,避免网络问题 在节点包目录中把更新地址切换到 Gitea,即可继续保留现有安装和本地配置
```bash ```bash
cd ComfyUI/custom_nodes cd ComfyUI/custom_nodes/comfyui_o1key
git clone https://gitee.com/resonLzy/comfyui_o1key.git git remote set-url origin https://git.o1key.com/publisher/comfyui_o1key.git
cd comfyui_o1key
pip install -r requirements.txt
``` ```
自动更新脚本(见下方「更新插件」)已改为从 Gitee 拉取,国内用户可直接使用 仓库公开读取,无需为用户配置 Gitea 账号或令牌
--- ---
@@ -134,16 +125,10 @@ O1KEY_API_KEY=你的API密钥
### 界面更新 ### 界面更新
在 ComfyUI 左侧功能栏点击「更新」(位于「重启」下方)。按钮会从当前 Git 仓库的 `origin/main` 拉取最新版本。完成后点击「重启」使新版本生效。 本机 ComfyUI 左侧功能栏点击「更新」(位于「重启」下方)。按钮会从当前 Git 仓库的 `origin/main` 拉取最新版本;依赖列表有变化时,会在 ComfyUI 当前 Python 环境中自动安装。完成后点击「重启」使新版本生效。
界面更新需要通过 Git 安装、处于 `main` 分支,且节点包文件没有本地修改。更新仅允许快进,不会覆盖本地修改或删除配置。ZIP 安装、分支分叉或网络连接失败时,界面会显示原因,需要手动处理。 界面更新需要通过 Git 安装、处于 `main` 分支,且节点包文件没有本地修改。更新仅允许快进,不会覆盖本地修改或删除配置。ZIP 安装、分支分叉或网络连接失败时,界面会显示原因,需要手动处理。
为了防止其他网站或局域网设备触发本机的软件管理操作,「更新」和「重启」按钮只能在本机打开的 ComfyUI 页面使用。
如果提示依赖列表已变化,请在 ComfyUI 使用的 Python 环境中执行:
```bash
cd ComfyUI/custom_nodes/comfyui_o1key
python -m pip install -r requirements.txt
```
### 手动更新 ### 手动更新
+4 -2
View File
@@ -208,7 +208,7 @@ try:
from server import PromptServer from server import PromptServer
import folder_paths import folder_paths
from .utils.config import CONFIG_FILE, load_config, NETWORK_ROUTES from .utils.config import CONFIG_FILE, load_config, NETWORK_ROUTES
from .utils.updater import UpdateError, update_package from .utils.updater import UpdateError, is_local_management_request, update_package
import threading as _update_threading import threading as _update_threading
_update_lock = _update_threading.Lock() _update_lock = _update_threading.Lock()
@@ -618,7 +618,7 @@ try:
@PromptServer.instance.routes.post("/o1key/update") @PromptServer.instance.routes.post("/o1key/update")
async def update_node_package(request): async def update_node_package(request):
if request.headers.get("X-O1Key-Update") != "1": if not is_local_management_request(request) or request.headers.get("X-O1Key-Update") != "1":
return web.json_response({"error": "无效的更新请求。"}, status=403) return web.json_response({"error": "无效的更新请求。"}, status=403)
if not _update_lock.acquire(blocking=False): if not _update_lock.acquire(blocking=False):
return web.json_response({"error": "更新正在进行,请稍候。"}, status=409) return web.json_response({"error": "更新正在进行,请稍候。"}, status=409)
@@ -636,6 +636,8 @@ try:
# === AI 聊天代理(流式 SSE 透传) === # === AI 聊天代理(流式 SSE 透传) ===
@PromptServer.instance.routes.post("/o1key/restart") @PromptServer.instance.routes.post("/o1key/restart")
async def restart_server(request): async def restart_server(request):
if not is_local_management_request(request):
return web.json_response({"error": "仅允许在本机界面重启 ComfyUI。"}, status=403)
import sys, os as _ros, subprocess, threading import sys, os as _ros, subprocess, threading
def _do_restart(): def _do_restart():
import time import time
+32
View File
@@ -5,6 +5,7 @@ import subprocess
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch
UPDATER_PATH = Path(__file__).resolve().parents[1] / "utils" / "updater.py" UPDATER_PATH = Path(__file__).resolve().parents[1] / "utils" / "updater.py"
@@ -45,11 +46,24 @@ class UpdaterTests(unittest.TestCase):
(self.author / "version.txt").write_text("2\n", encoding="utf-8") (self.author / "version.txt").write_text("2\n", encoding="utf-8")
(self.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8") (self.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8")
self.commit_and_push() self.commit_and_push()
with patch.object(updater, "_install_requirements") as install:
result = updater.update_package() result = updater.update_package()
install.assert_called_once_with("requests>=3\n")
self.assertTrue(result["updated"]) self.assertTrue(result["updated"])
self.assertTrue(result["requirements_changed"]) self.assertTrue(result["requirements_changed"])
self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "2\n") self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "2\n")
def test_dependency_failure_keeps_previous_code(self):
old_commit = self.git(self.install, "rev-parse", "HEAD")
(self.author / "requirements.txt").write_text("requests>=3\n", encoding="utf-8")
(self.author / "version.txt").write_text("2\n", encoding="utf-8")
self.commit_and_push()
with patch.object(updater, "_install_requirements", side_effect=updater.UpdateError("依赖安装失败")):
with self.assertRaisesRegex(updater.UpdateError, "依赖安装失败"):
updater.update_package()
self.assertEqual(self.git(self.install, "rev-parse", "HEAD"), old_commit)
self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "1\n")
def test_local_changes_are_preserved(self): def test_local_changes_are_preserved(self):
(self.install / "version.txt").write_text("local\n", encoding="utf-8") (self.install / "version.txt").write_text("local\n", encoding="utf-8")
with self.assertRaisesRegex(updater.UpdateError, "本地修改"): with self.assertRaisesRegex(updater.UpdateError, "本地修改"):
@@ -68,6 +82,24 @@ class UpdaterTests(unittest.TestCase):
updater.update_package() updater.update_package()
self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "local commit\n") self.assertEqual((self.install / "version.txt").read_text(encoding="utf-8"), "local commit\n")
def test_management_request_requires_local_peer_and_same_origin(self):
cases = [
("local browser", "127.0.0.1", "127.0.0.1:8188", "http://127.0.0.1:8188", True),
("localhost", "::1", "localhost:8188", "http://localhost:8188", True),
("remote peer", "192.168.1.2", "127.0.0.1:8188", "http://127.0.0.1:8188", False),
("cross origin", "127.0.0.1", "127.0.0.1:8188", "http://evil.example", False),
("missing origin", "127.0.0.1", "127.0.0.1:8188", "", False),
("malformed origin", "127.0.0.1", "127.0.0.1:8188", "http://[", False),
("rebinding host", "127.0.0.1", "evil.example:8188", "http://evil.example:8188", False),
]
for name, peer, host, origin, expected in cases:
with self.subTest(name=name):
request = Mock()
request.transport.get_extra_info.return_value = (peer, 12345)
request.headers = {"Host": host, "Origin": origin}
request.scheme = "http"
self.assertEqual(updater.is_local_management_request(request), expected)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+60 -1
View File
@@ -1,8 +1,12 @@
"""Safely fast-forward a Git installation of this node package.""" """Safely fast-forward a Git installation of this node package."""
import ipaddress
import os import os
import subprocess import subprocess
import sys
import tempfile
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
PLUGIN_DIR = Path(__file__).resolve().parent.parent PLUGIN_DIR = Path(__file__).resolve().parent.parent
@@ -12,6 +16,35 @@ class UpdateError(Exception):
pass pass
def is_local_management_request(request):
"""Allow software management only from the local ComfyUI browser."""
peer = request.transport.get_extra_info("peername") if request.transport else None
if not peer:
return False
try:
if not ipaddress.ip_address(peer[0]).is_loopback:
return False
host = request.headers.get("Host", "")
host_url = urlsplit(f"http://{host}")
hostname = host_url.hostname
if host_url.username or host_url.password or host_url.path or host_url.query or host_url.fragment:
return False
if hostname != "localhost" and not ipaddress.ip_address(hostname).is_loopback:
return False
origin = request.headers.get("Origin", "")
parsed_origin = urlsplit(origin)
except (ValueError, TypeError):
return False
return (
parsed_origin.scheme == request.scheme
and parsed_origin.netloc.lower() == host.lower()
and not parsed_origin.path
and not parsed_origin.query
and not parsed_origin.fragment
)
def _git(*args, timeout=60, check=True): def _git(*args, timeout=60, check=True):
env = os.environ.copy() env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0" env["GIT_TERMINAL_PROMPT"] = "0"
@@ -37,6 +70,29 @@ def _git(*args, timeout=60, check=True):
return result return result
def _install_requirements(requirements):
fd, filename = tempfile.mkstemp(prefix=".o1key-requirements-", suffix=".txt", dir=PLUGIN_DIR)
try:
with os.fdopen(fd, "w", encoding="utf-8") as req_file:
req_file.write(requirements)
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", filename],
cwd=PLUGIN_DIR,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=600,
)
except subprocess.TimeoutExpired as exc:
raise UpdateError("依赖安装超时,节点代码尚未更新,请检查网络后重试。") from exc
if result.returncode:
raise UpdateError("依赖安装失败,节点代码尚未更新,请检查 ComfyUI 的 Python 环境后重试。")
finally:
Path(filename).unlink(missing_ok=True)
def update_package(): def update_package():
"""Update origin/main without discarding local changes or switching branches.""" """Update origin/main without discarding local changes or switching branches."""
if not (PLUGIN_DIR / ".git").exists(): if not (PLUGIN_DIR / ".git").exists():
@@ -59,8 +115,11 @@ def update_package():
if _git("merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD", check=False).returncode: if _git("merge-base", "--is-ancestor", "HEAD", "FETCH_HEAD", check=False).returncode:
raise UpdateError("本地与 origin/main 已分叉,无法安全快进。请手动处理。") raise UpdateError("本地与 origin/main 已分叉,无法安全快进。请手动处理。")
new_requirements = _git("show", "FETCH_HEAD:requirements.txt", check=False).stdout
requirements_changed = old_requirements != new_requirements
if requirements_changed and new_requirements.strip():
_install_requirements(new_requirements)
_git("merge", "--ff-only", "FETCH_HEAD") _git("merge", "--ff-only", "FETCH_HEAD")
requirements_changed = old_requirements != (PLUGIN_DIR / "requirements.txt").read_text(encoding="utf-8")
return { return {
"updated": True, "updated": True,
"version": new_commit[:7], "version": new_commit[:7],
+4 -6
View File
@@ -57,11 +57,11 @@ app.registerExtension({
let updating = false; let updating = false;
updateBtn.addEventListener("click", async () => { updateBtn.addEventListener("click", async () => {
if (updating) return; if (updating) return;
if (!confirm("从 origin/main 拉取 comfyui_o1key 最新版本?")) return; if (!confirm("更新 comfyui_o1key 到发布仓库的最新版本?若依赖有变化,也会自动安装,可能需要几分钟。")) return;
updating = true; updating = true;
updateBtn.disabled = true; updateBtn.disabled = true;
updateBtn.style.opacity = "0.5"; updateBtn.style.opacity = "0.5";
updateBtn.title = "正在更新..."; updateBtn.title = "正在拉取代码并安装所需依赖...";
try { try {
const response = await fetch("/o1key/update", { const response = await fetch("/o1key/update", {
method: "POST", method: "POST",
@@ -72,10 +72,8 @@ app.registerExtension({
if (!result.updated) { if (!result.updated) {
alert(`已是最新版本(${result.version})。`); alert(`已是最新版本(${result.version})。`);
} else { } else {
const dependencies = result.requirements_changed const dependencies = result.requirements_changed ? "依赖已同步。\n" : "";
? "\n依赖列表已变化,请先在 ComfyUI 的 Python 环境中执行 pip install -r requirements.txt。" alert(`更新完成(${result.version})。\n${dependencies}请点击“重启”使新版本生效。`);
: "";
alert(`更新完成(${result.version})。${dependencies}\n请点击“重启”使新版本生效。`);
} }
} catch (error) { } catch (error) {
alert(`更新失败:${error.message}`); alert(`更新失败:${error.message}`);