comfyui-mcp 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/config.js +156 -10
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/grok-backend.js +5 -4
- package/dist/orchestrator/grok-backend.js.map +1 -1
- package/dist/orchestrator/index.js +552 -42
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/services/download-progress.js +177 -7
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/process-control.js +154 -4
- package/dist/services/process-control.js.map +1 -1
- package/dist/services/runpod-watch.js +139 -1
- package/dist/services/runpod-watch.js.map +1 -1
- package/dist/services/secure-bridge.js +22 -6
- package/dist/services/secure-bridge.js.map +1 -1
- package/dist/tools/apps.js +222 -0
- package/dist/tools/apps.js.map +1 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/process-control.js +1 -1
- package/dist/tools/process-control.js.map +1 -1
- package/dist/tools/runpod.js +198 -17
- package/dist/tools/runpod.js.map +1 -1
- package/package.json +1 -1
- package/scripts/gen-tool-docs.ts +7 -0
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// one Claude Agent SDK streaming session per panel tab (src/orchestrator/
|
|
9
9
|
// panel-agent.ts). Each agent runs on the user's Claude SUBSCRIPTION with no API
|
|
10
10
|
// key. See docs/design/panel-orchestrator.md.
|
|
11
|
-
import { existsSync, writeFileSync, unlinkSync, readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
12
12
|
import { execFileSync } from "node:child_process";
|
|
13
|
-
import { tmpdir, networkInterfaces } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
13
|
+
import { tmpdir, homedir, networkInterfaces } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { randomBytes } from "node:crypto";
|
|
17
17
|
import readline from "node:readline";
|
|
@@ -32,7 +32,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
32
32
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
33
33
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
34
34
|
import { registerAllTools } from "../tools/index.js";
|
|
35
|
-
import { isForceRemoteFlagSet, isLoopbackHost, detectLocalComfyUIPath, setComfyuiTarget, onComfyuiTargetChanged, isTargetingLocal, getComfyUIBaseUrl } from "../config.js";
|
|
35
|
+
import { isForceRemoteFlagSet, isLoopbackHost, detectLocalComfyUIPath, setComfyuiTarget, onComfyuiTargetChanged, isTargetingLocal, isTargetingLocalOrLan, isTargetingPod, getComfyUIBaseUrl, getLocalComfyuiUrl, rescopeLocalTargetFile, getComfyUIAuthHeaders } from "../config.js";
|
|
36
36
|
import { buildComfyuiMcpEnv, comfyuiSecretKeys, onComfyuiSecretsChanged, hydrateAgentSecretsIntoEnv, onAgentSecretsChanged, setAgentSecret, setComfyuiSecret, isAllowedComfyuiSecretKey, } from "../services/panel-secrets.js";
|
|
37
37
|
import { CodexBackend } from "./codex-backend.js";
|
|
38
38
|
import { GeminiBackend, GEMINI_DEFAULT_MODEL } from "./gemini-backend.js";
|
|
@@ -55,6 +55,8 @@ import { startPanelConsoleHttpServer } from "./panel-console-http.js";
|
|
|
55
55
|
import { readComfyuiCrashLog, formatCrashNote } from "../services/crash-log.js";
|
|
56
56
|
import { QueueMonitor } from "../services/queue-monitor.js";
|
|
57
57
|
import { initRunpodWatcher, getRunpodWatcher } from "../services/runpod-watch.js";
|
|
58
|
+
import { getPod } from "../services/runpod-client.js";
|
|
59
|
+
import { listTargetChangeRequests, consumeTargetChange, ackTargetChange, setProgressDir, CONTROL_PREFIX } from "../services/download-progress.js";
|
|
58
60
|
import { hasActiveTrainingJob, reconcileStaleTrainingJobs } from "../services/training-jobs.js";
|
|
59
61
|
import { buildQueueStatusFrame, createQueueStatusBroadcaster, } from "../services/queue-status-broadcast.js";
|
|
60
62
|
import { unloadAllOllama, warmOllama, resolveOllamaHost } from "../services/ollama-vram.js";
|
|
@@ -624,6 +626,18 @@ const CALL_TOOL_WHITELIST = new Set([
|
|
|
624
626
|
"runpod_watch",
|
|
625
627
|
"runpod_unwatch",
|
|
626
628
|
"runpod_deploy_link",
|
|
629
|
+
// Micro-Apps (panel "Apps" feature): the canvas-less client's list/run/poll
|
|
630
|
+
// surface. Same risk posture as enqueue_workflow (already whitelisted): run
|
|
631
|
+
// queues a job the user explicitly tapped; list/get/status are read-only.
|
|
632
|
+
"apps_list",
|
|
633
|
+
"apps_get",
|
|
634
|
+
"apps_run",
|
|
635
|
+
"apps_run_status",
|
|
636
|
+
// Registry install (mobile Explore): the rig fetches the bundle from the
|
|
637
|
+
// public registry and imports it locally. Same risk as save_workflow +
|
|
638
|
+
// download_model (already whitelisted): writes a local app bundle, no
|
|
639
|
+
// model/system mutation. Deps install stays a separate consented action.
|
|
640
|
+
"apps_import",
|
|
627
641
|
]);
|
|
628
642
|
/** Lazily build ONE in-process MCP client wired to the full comfyui tool surface,
|
|
629
643
|
* reused across call_tool requests. Reuses the exact tool implementations (same
|
|
@@ -737,11 +751,19 @@ export async function runPanelOrchestrator() {
|
|
|
737
751
|
const bridgeHost = (process.env.COMFYUI_MCP_BRIDGE_HOST ?? "127.0.0.1").trim() || "127.0.0.1";
|
|
738
752
|
const lanBridge = !isLoopbackBindHost(bridgeHost);
|
|
739
753
|
const envBridgeToken = process.env.COMFYUI_MCP_BRIDGE_TOKEN?.trim() || null;
|
|
740
|
-
|
|
754
|
+
// Provisioned eagerly for secure/LAN boots, LAZILY on the first remote
|
|
755
|
+
// retarget (a loopback boot must not permanently rule out the secure bridge
|
|
756
|
+
// the pod's HTTPS panel needs — codex finding).
|
|
757
|
+
let bridgeToken = envBridgeToken ?? (wantSecureBridge || lanBridge ? randomBytes(24).toString("hex") : null);
|
|
741
758
|
// Dedicated PANEL bridge port (default 9180). Token-gated in secure/LAN mode.
|
|
742
759
|
const lockPort = Number(process.env.COMFYUI_MCP_BRIDGE_PORT) || 9180;
|
|
743
760
|
const lockPath = orchLockPath(lockPort);
|
|
744
761
|
const bridge = startUiBridge(lockPort, bridgeToken, bridgeHost);
|
|
762
|
+
// The LISTENER'S auth was fixed at construction: a null boot token means a
|
|
763
|
+
// tokenless listener FOREVER — lazily provisioning a token later would
|
|
764
|
+
// advertise a tunnel whose token is not enforced (codex finding). The lazy
|
|
765
|
+
// secure-bridge path must refuse in that case, not expose it publicly.
|
|
766
|
+
const bridgeListenerTokenless = bridgeToken === null;
|
|
745
767
|
// On-demand phone pairing (the panel "Remote control" button). Off by default:
|
|
746
768
|
// the FIRST pair request lazily binds a SECOND, token-gated listener on all
|
|
747
769
|
// interfaces (so a phone can reach it), while the primary loopback bridge stays
|
|
@@ -947,10 +969,32 @@ export async function runPanelOrchestrator() {
|
|
|
947
969
|
`SSH tunnel (ssh -L 3000:localhost:3000 …) at http://localhost:3000.`);
|
|
948
970
|
}
|
|
949
971
|
}
|
|
950
|
-
// Cross-process download-progress channel: each tab's comfyui MCP
|
|
951
|
-
// writes per-download JSON
|
|
952
|
-
//
|
|
953
|
-
|
|
972
|
+
// Cross-process download-progress + control channel: each tab's comfyui MCP
|
|
973
|
+
// subprocess writes per-download JSON (and runpod_* target requests) here;
|
|
974
|
+
// the watcher below broadcasts downloads to the panel tray and applies
|
|
975
|
+
// control requests. Port-scoped so parallel orchestrators don't cross
|
|
976
|
+
// streams, NONCED + mode-0700 so another local user can't pre-create the
|
|
977
|
+
// predictable path and inject a hostile retarget (codex finding — the
|
|
978
|
+
// applied URL receives configured auth headers on later requests).
|
|
979
|
+
const progressNonce = randomBytes(12).toString("hex");
|
|
980
|
+
const progressDir = join(tmpdir(), `comfyui-mcp-progress-${bridgePort}-${progressNonce}`);
|
|
981
|
+
// Reap dirs from dead processes on this port (auto-restarts would otherwise
|
|
982
|
+
// accumulate them forever — codex finding). Same port ⇒ same orchestrator,
|
|
983
|
+
// so any other dir with the prefix belongs to a previous life.
|
|
984
|
+
try {
|
|
985
|
+
for (const d of readdirSync(tmpdir())) {
|
|
986
|
+
if (d.startsWith(`comfyui-mcp-progress-${bridgePort}-`) && join(tmpdir(), d) !== progressDir) {
|
|
987
|
+
rmSync(join(tmpdir(), d), { recursive: true, force: true });
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
/* best-effort */
|
|
993
|
+
}
|
|
994
|
+
mkdirSync(progressDir, { recursive: true, mode: 0o700 });
|
|
995
|
+
// Late-bind the channel dir so the control channel works IN-PROCESS too
|
|
996
|
+
// (direct/mobile tool calls — codex finding: it was dead without the env var).
|
|
997
|
+
setProgressDir(progressDir);
|
|
954
998
|
// The bundled plugin (skills) ships alongside dist/ in the package root. Load
|
|
955
999
|
// it so the background agents are ComfyUI experts out of the box.
|
|
956
1000
|
const pluginPath = fileURLToPath(new URL("../../plugin", import.meta.url));
|
|
@@ -1717,6 +1761,22 @@ export async function runPanelOrchestrator() {
|
|
|
1717
1761
|
// ComfyUI the user actually has open — local OR a RunPod proxy — with no
|
|
1718
1762
|
// `connect <url>`. Loopback → LOCAL mode (keep COMFYUI_PATH); non-loopback →
|
|
1719
1763
|
// REMOTE mode (drop the path). No-op if unchanged. Returns true if it retargeted.
|
|
1764
|
+
// Same-URL canonicalization, DEFAULT-PORT-INSENSITIVE but SCHEME-AWARE:
|
|
1765
|
+
// strip only the scheme's actual default (:443 for https, :80 for http) —
|
|
1766
|
+
// http://h:443 and http://h are NOT the same endpoint (codex finding).
|
|
1767
|
+
// Shared by applyComfyuiUrl's dedupe and the control-channel ack check
|
|
1768
|
+
// (runpodProxyUrl omits :443 while getComfyUIBaseUrl includes it — codex).
|
|
1769
|
+
const canonTargetUrl = (u) => {
|
|
1770
|
+
try {
|
|
1771
|
+
const p = new URL(u);
|
|
1772
|
+
if ((p.protocol === "https:" && p.port === "443") || (p.protocol === "http:" && p.port === "80"))
|
|
1773
|
+
p.port = "";
|
|
1774
|
+
return p.toString().replace(/\/+$/, "");
|
|
1775
|
+
}
|
|
1776
|
+
catch {
|
|
1777
|
+
return u.replace(/\/+$/, "");
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1720
1780
|
const applyComfyuiUrl = (rawUrl) => {
|
|
1721
1781
|
if (typeof rawUrl !== "string")
|
|
1722
1782
|
return false;
|
|
@@ -1733,7 +1793,7 @@ export async function runPanelOrchestrator() {
|
|
|
1733
1793
|
catch {
|
|
1734
1794
|
return false; // not a valid URL — ignore (keep current target)
|
|
1735
1795
|
}
|
|
1736
|
-
if (!host || next === comfyuiUrl)
|
|
1796
|
+
if (!host || canonTargetUrl(next) === canonTargetUrl(comfyuiUrl))
|
|
1737
1797
|
return false;
|
|
1738
1798
|
const prev = comfyuiUrl;
|
|
1739
1799
|
comfyuiUrl = next;
|
|
@@ -1746,22 +1806,11 @@ export async function runPanelOrchestrator() {
|
|
|
1746
1806
|
// forces getClient() to rebuild against the new host on its next use.
|
|
1747
1807
|
setComfyuiTarget(next);
|
|
1748
1808
|
resetClient();
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
//
|
|
1753
|
-
|
|
1754
|
-
manager.setComfyuiUrl(comfyuiUrl);
|
|
1755
|
-
manager.restartAllForMcpEnv();
|
|
1756
|
-
// Re-point the render watchdog and re-probe the env (remote vs local differs).
|
|
1757
|
-
try {
|
|
1758
|
-
QueueMonitor.stop();
|
|
1759
|
-
}
|
|
1760
|
-
catch {
|
|
1761
|
-
/* best-effort */
|
|
1762
|
-
}
|
|
1763
|
-
QueueMonitor.start(comfyuiUrl);
|
|
1764
|
-
void refreshEnvCapabilities();
|
|
1809
|
+
// The heavy retarget work (QueueMonitor restart, agent MCP-env respawn,
|
|
1810
|
+
// env-capability re-probe) runs in the onComfyuiTargetChanged listener
|
|
1811
|
+
// fired by setComfyuiTarget above — ONE fan-out for every retarget path
|
|
1812
|
+
// (hello, runpod tools, watcher auto-stop/vanish), so they can't drift
|
|
1813
|
+
// split-brained again (#269).
|
|
1765
1814
|
logger.info(`[panel-orchestrator] retargeted ComfyUI ${prev} → ${comfyuiUrl} (${isLoopbackUrl(next) ? "local" : "remote"} mode) from panel hello`);
|
|
1766
1815
|
// Advertising itself happens unconditionally on every hello (see below) —
|
|
1767
1816
|
// a retarget doesn't need its own advertise call here.
|
|
@@ -2151,8 +2200,12 @@ export async function runPanelOrchestrator() {
|
|
|
2151
2200
|
const rpFrame = getRunpodWatcher()?.current();
|
|
2152
2201
|
if (rpFrame)
|
|
2153
2202
|
bridge.push(rpFrame, panelTab);
|
|
2203
|
+
// Seed any failed auto-connect warnings too — they live SEPARATELY from
|
|
2204
|
+
// the watched frame so this tab sees every still-billing failure (codex).
|
|
2205
|
+
for (const f of getRunpodWatcher()?.failedFrames() ?? [])
|
|
2206
|
+
bridge.push(f, panelTab);
|
|
2154
2207
|
// Seed the honest host indicator: tell this tab where renders run now.
|
|
2155
|
-
bridge.push({ type: "comfyui_target", url: getComfyUIBaseUrl(), is_local:
|
|
2208
|
+
bridge.push({ type: "comfyui_target", url: getComfyUIBaseUrl(), is_local: isTargetingLocalOrLan() }, panelTab);
|
|
2156
2209
|
// Re-push the last usage so the context meter isn't blank after a reload.
|
|
2157
2210
|
const lastStatus = manager.lastStatusFor(key);
|
|
2158
2211
|
if (lastStatus)
|
|
@@ -3047,6 +3100,84 @@ export async function runPanelOrchestrator() {
|
|
|
3047
3100
|
// a downloading row that stops updating for 60s is treated as a dead writer.
|
|
3048
3101
|
const DOWNLOAD_LINGER_MS = 8000;
|
|
3049
3102
|
const downloadRemoveAt = new Map();
|
|
3103
|
+
/** Boolean URL probe with a timeout — readiness checks for pending pod connects. */
|
|
3104
|
+
const probeOk = async (url, timeoutMs = 8_000) => {
|
|
3105
|
+
const ctl = new AbortController();
|
|
3106
|
+
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
|
3107
|
+
try {
|
|
3108
|
+
// Carry configured auth (COMFYUI_AUTH_TOKEN / custom header) — a
|
|
3109
|
+
// protected pod's ComfyUI 401s otherwise and connect:true always times
|
|
3110
|
+
// out (codex finding).
|
|
3111
|
+
const res = await fetch(url, { signal: ctl.signal, headers: getComfyUIAuthHeaders() });
|
|
3112
|
+
return res.ok;
|
|
3113
|
+
}
|
|
3114
|
+
catch {
|
|
3115
|
+
return false;
|
|
3116
|
+
}
|
|
3117
|
+
finally {
|
|
3118
|
+
clearTimeout(t);
|
|
3119
|
+
}
|
|
3120
|
+
};
|
|
3121
|
+
// Genuinely-in-flight download rows (set by pollDownloads) — read by the pod
|
|
3122
|
+
// idle-stop veto, SCOPED per pod via each row's stamped target (#269).
|
|
3123
|
+
let downloadingRows = [];
|
|
3124
|
+
// create-with-connect pending boot: per-pod pending connects (a second
|
|
3125
|
+
// create(connect:true) must not silently displace the first's promise —
|
|
3126
|
+
// codex finding: the displaced pod kept billing with no deadline, no
|
|
3127
|
+
// notification, and no auto-stop). Any deliberate target event clears ALL
|
|
3128
|
+
// of them (the user chose something else) — EXCEPT a readiness-driven
|
|
3129
|
+
// completion, which would otherwise wipe its siblings' promises (codex):
|
|
3130
|
+
// machineRetargetInFlight distinguishes machine- from user-driven retargets.
|
|
3131
|
+
// PERSISTED (user-private config dir): an orchestrator self-restart mid-boot
|
|
3132
|
+
// must not strand a promised auto-connect (codex finding — the replacement
|
|
3133
|
+
// process gets a fresh progress dir, so in-memory state alone is lost).
|
|
3134
|
+
const pendingPodConnects = new Map();
|
|
3135
|
+
let machineRetargetInFlight = false;
|
|
3136
|
+
const pendingConnectsFile = join(homedir(), ".comfyui-mcp", `runpod-pending-connects-${bridgePort}.json`);
|
|
3137
|
+
// Port-scope the saved-target file too, and re-restore for a pod boot (the
|
|
3138
|
+
// module-init read ran unscoped at import — codex finding).
|
|
3139
|
+
rescopeLocalTargetFile(join(homedir(), ".comfyui-mcp", `local-target-${bridgePort}.json`));
|
|
3140
|
+
const persistPendingConnects = () => {
|
|
3141
|
+
try {
|
|
3142
|
+
if (pendingPodConnects.size === 0) {
|
|
3143
|
+
unlinkSync(pendingConnectsFile);
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
mkdirSync(dirname(pendingConnectsFile), { recursive: true });
|
|
3147
|
+
writeFileSync(pendingConnectsFile, JSON.stringify(Object.fromEntries([...pendingPodConnects].map(([k, v]) => [k, { url: v.url, deadline: v.deadline }]))));
|
|
3148
|
+
}
|
|
3149
|
+
catch {
|
|
3150
|
+
/* best-effort */
|
|
3151
|
+
}
|
|
3152
|
+
};
|
|
3153
|
+
// Restore promises made before a restart; an already-past deadline fires the
|
|
3154
|
+
// honest timeout on the first poll tick. RESTORE on a pod boot OR any
|
|
3155
|
+
// self-restart generation (the common create(connect:true) case restarts
|
|
3156
|
+
// with the target still LOCAL mid-wait — codex finding: requiring a pod
|
|
3157
|
+
// target deleted the promise on the normal self-restart). A deliberate
|
|
3158
|
+
// fresh non-pod boot (gen 0) invalidates old pendings instead.
|
|
3159
|
+
try {
|
|
3160
|
+
const restartGen = Number(process.env.COMFYUI_MCP_RESTART_GEN ?? "0");
|
|
3161
|
+
if (!isTargetingPod() && !(restartGen > 0)) {
|
|
3162
|
+
if (existsSync(pendingConnectsFile)) {
|
|
3163
|
+
logger.info("[panel-orchestrator] discarding saved pending pod auto-connect(s) — this boot selected a non-pod target explicitly");
|
|
3164
|
+
unlinkSync(pendingConnectsFile);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
else {
|
|
3168
|
+
const saved = JSON.parse(readFileSync(pendingConnectsFile, "utf-8"));
|
|
3169
|
+
for (const [podId, v] of Object.entries(saved)) {
|
|
3170
|
+
if (typeof v?.url === "string" && typeof v?.deadline === "number") {
|
|
3171
|
+
pendingPodConnects.set(podId, { url: v.url, deadline: v.deadline, lastProbe: 0 });
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
if (pendingPodConnects.size > 0)
|
|
3175
|
+
logger.info(`[panel-orchestrator] restored ${pendingPodConnects.size} pending pod auto-connect(s) from before the restart`);
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
catch {
|
|
3179
|
+
// no saved state
|
|
3180
|
+
}
|
|
3050
3181
|
let lastDownloadSnapshot = "[]";
|
|
3051
3182
|
const pollDownloads = () => {
|
|
3052
3183
|
let files = [];
|
|
@@ -3059,6 +3190,8 @@ export async function runPanelOrchestrator() {
|
|
|
3059
3190
|
const now = Date.now();
|
|
3060
3191
|
const downloads = [];
|
|
3061
3192
|
for (const f of files) {
|
|
3193
|
+
if (f.startsWith(CONTROL_PREFIX))
|
|
3194
|
+
continue; // control channel, not a download row
|
|
3062
3195
|
const full = join(progressDir, f);
|
|
3063
3196
|
let row;
|
|
3064
3197
|
try {
|
|
@@ -3097,12 +3230,222 @@ export async function runPanelOrchestrator() {
|
|
|
3097
3230
|
}
|
|
3098
3231
|
downloads.push(row);
|
|
3099
3232
|
}
|
|
3233
|
+
// Live-download rows for the idle-stop veto (#269): rows surviving the loop
|
|
3234
|
+
// above are fresh (dead writers >60s are already unlinked), so a
|
|
3235
|
+
// "downloading" row here is genuinely in flight — and a pod mid-download
|
|
3236
|
+
// must not count as idle. Self-healing: a crashed writer's row ages out.
|
|
3237
|
+
downloadingRows = downloads.filter((d) => d.status === "downloading");
|
|
3100
3238
|
downloads.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
|
|
3101
3239
|
const snapshot = JSON.stringify(downloads);
|
|
3102
3240
|
if (snapshot !== lastDownloadSnapshot) {
|
|
3103
3241
|
lastDownloadSnapshot = snapshot;
|
|
3104
3242
|
bridge.push({ type: "download_progress", downloads }); // broadcast to all tabs
|
|
3105
3243
|
}
|
|
3244
|
+
// MCP-child control channel (#269): runpod_* tools that ran in spawned
|
|
3245
|
+
// agent children ask the orchestrator to retarget / watch / unwatch /
|
|
3246
|
+
// auto-connect here — through the SAME applyComfyuiUrl fan-out as a panel
|
|
3247
|
+
// hello. Each request is its own file: consumption deletes exactly the
|
|
3248
|
+
// file read, so concurrent children can't clobber each other (codex).
|
|
3249
|
+
try {
|
|
3250
|
+
for (const { req, file } of listTargetChangeRequests(progressDir)) {
|
|
3251
|
+
// Apply EVERY request — per-request files make the old timestamp
|
|
3252
|
+
// watermark both unnecessary and wrong (two same-millisecond requests
|
|
3253
|
+
// — e.g. create's watch + auto-connect — would drop the second, codex
|
|
3254
|
+
// finding). A crash between apply and delete replays an idempotent op
|
|
3255
|
+
// once on restart; a dropped auto-connect bills an unwatched pod.
|
|
3256
|
+
// A `local` request resolves the fallback HERE — the orchestrator owns
|
|
3257
|
+
// the learned-LAN memory; the requesting child (spawned post-connect)
|
|
3258
|
+
// may know nothing but loopback (codex finding). `onlyIfTarget` guards
|
|
3259
|
+
// it: a stale child's stop-fallback applies only when the CURRENT
|
|
3260
|
+
// target really is that pod (codex finding — it dragged the target
|
|
3261
|
+
// off a newer pod). The ack below reports the resulting URL either
|
|
3262
|
+
// way, so the stale child ALIGNS to it.
|
|
3263
|
+
const localGuardOk = !req.onlyIfTarget || getComfyUIBaseUrl().includes(req.onlyIfTarget);
|
|
3264
|
+
// Generation guard for URL retargets: drop the retarget when a NEWER
|
|
3265
|
+
// direct choice moved the target after the child wrote this (codex
|
|
3266
|
+
// finding: a queued pod-A request applied after the user picked pod B).
|
|
3267
|
+
const urlGenOk = !req.expectedCurrentUrl || canonTargetUrl(getComfyUIBaseUrl()) === canonTargetUrl(req.expectedCurrentUrl);
|
|
3268
|
+
// Only an APPLIED target/unwatch choice supersedes pending auto-connects
|
|
3269
|
+
// (watch-ONLY isn't a choice; a guarded-OUT or generation-STALE request
|
|
3270
|
+
// applied nothing and must not drop a booting pod's promise — codex
|
|
3271
|
+
// findings on both axes).
|
|
3272
|
+
const appliedChoice = urlGenOk && (!!req.url || ((!!req.local || !!req.unwatch) && localGuardOk));
|
|
3273
|
+
if (!req.connectWhenReady && appliedChoice) {
|
|
3274
|
+
pendingPodConnects.clear();
|
|
3275
|
+
persistPendingConnects();
|
|
3276
|
+
}
|
|
3277
|
+
if (req.local && localGuardOk && urlGenOk)
|
|
3278
|
+
applyComfyuiUrl(getLocalComfyuiUrl());
|
|
3279
|
+
else if (req.url && urlGenOk)
|
|
3280
|
+
applyComfyuiUrl(req.url); // dropped when a newer choice superseded it
|
|
3281
|
+
if (req.unwatch && localGuardOk && urlGenOk) {
|
|
3282
|
+
// Scoped for stop-fallbacks: only the stopped pod's own watch dies
|
|
3283
|
+
// (codex finding: stopping A killed unrelated watched B).
|
|
3284
|
+
if (req.unwatchPodId) {
|
|
3285
|
+
const w = getRunpodWatcher();
|
|
3286
|
+
if (w?.watchedPodId() === req.unwatchPodId)
|
|
3287
|
+
w.unwatch();
|
|
3288
|
+
}
|
|
3289
|
+
else {
|
|
3290
|
+
getRunpodWatcher()?.unwatch();
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
// A confirmed stop clears the pod's recorded auto-connect failure —
|
|
3294
|
+
// including the spawned-child case, where the caller had no watcher
|
|
3295
|
+
// of its own (codex finding: stopped pods kept "billing" forever).
|
|
3296
|
+
// And it CANCELS any pending auto-connect for it — otherwise the
|
|
3297
|
+
// readiness loop could still retarget to a just-stopped pod, or warn
|
|
3298
|
+
// about a timeout for a pod the user deliberately stopped (codex).
|
|
3299
|
+
if (req.stoppedPodId) {
|
|
3300
|
+
getRunpodWatcher()?.clearConnectFailed(req.stoppedPodId);
|
|
3301
|
+
if (pendingPodConnects.delete(req.stoppedPodId))
|
|
3302
|
+
persistPendingConnects();
|
|
3303
|
+
}
|
|
3304
|
+
if (req.watchPodId) {
|
|
3305
|
+
// Boot-status arm for a create: never displace the ACTIVE render
|
|
3306
|
+
// target's watch — its idle auto-stop / dead-target cleanup is the
|
|
3307
|
+
// billing guard (codex finding: creating pod B while rendering on
|
|
3308
|
+
// pod A left A unguarded and billing indefinitely). The new pod gets
|
|
3309
|
+
// watched when its pending connect completes (it becomes the target).
|
|
3310
|
+
const w = getRunpodWatcher();
|
|
3311
|
+
const cur = w?.watchedPodId();
|
|
3312
|
+
const curIsActiveTarget = !!cur && !isTargetingLocal() && getComfyUIBaseUrl().includes(cur);
|
|
3313
|
+
if (!(curIsActiveTarget && cur !== req.watchPodId))
|
|
3314
|
+
w?.watch(req.watchPodId);
|
|
3315
|
+
}
|
|
3316
|
+
// Whether the watch request actually LANDED (the guard above may have
|
|
3317
|
+
// refused it) — computed AFTER the attempt; the ack carries it so the
|
|
3318
|
+
// child's create result can't claim a watch we rejected (codex finding).
|
|
3319
|
+
const watchApplied = !!req.watchPodId && getRunpodWatcher()?.watchedPodId() === req.watchPodId;
|
|
3320
|
+
// Whether a URL retarget landed (generation-guarded): the ack must
|
|
3321
|
+
// confirm the AUTHORITATIVE retarget before the child reports success
|
|
3322
|
+
// (codex finding: rejected writes still read "connected").
|
|
3323
|
+
const connectApplied = !!req.url && urlGenOk && canonTargetUrl(getComfyUIBaseUrl()) === canonTargetUrl(req.url);
|
|
3324
|
+
if (req.connectWhenReady && urlGenOk) {
|
|
3325
|
+
// The ORCHESTRATOR waits for boot (the tool call returned inside the
|
|
3326
|
+
// MCP 60s lifetime): probe every ~10s, retarget+watch on ready, and
|
|
3327
|
+
// report honestly on deadline — never block the MCP child (codex).
|
|
3328
|
+
// Per-pod slot: concurrent creates each keep their own deadline.
|
|
3329
|
+
// Generation-guarded: a stale registration must not re-arm after the
|
|
3330
|
+
// user already chose a newer target (codex finding).
|
|
3331
|
+
pendingPodConnects.set(req.connectWhenReady.podId, {
|
|
3332
|
+
url: req.connectWhenReady.url,
|
|
3333
|
+
deadline: Date.now() + 8 * 60_000,
|
|
3334
|
+
lastProbe: 0,
|
|
3335
|
+
});
|
|
3336
|
+
persistPendingConnects();
|
|
3337
|
+
}
|
|
3338
|
+
consumeTargetChange(file);
|
|
3339
|
+
// Ack with the RESULTING target ONLY when the requester is waiting —
|
|
3340
|
+
// fire-and-forget requests would leak ack files (codex finding). The
|
|
3341
|
+
// `applied` flag distinguishes an applied local switch from a guarded
|
|
3342
|
+
// skip (onlyIfTarget didn't match — codex finding).
|
|
3343
|
+
if (req.wantAck)
|
|
3344
|
+
ackTargetChange(file, getComfyUIBaseUrl(), req.onlyIfTarget ? localGuardOk && urlGenOk : req.url ? connectApplied : req.connectWhenReady ? urlGenOk : req.watchPodId ? watchApplied : true);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
catch {
|
|
3348
|
+
/* best-effort — the next tick retries a partially-written file */
|
|
3349
|
+
}
|
|
3350
|
+
// Pending create-and-connects: probe each until its pod's ComfyUI answers
|
|
3351
|
+
// BOTH readiness endpoints, then retarget through the shared fan-out.
|
|
3352
|
+
const reportConnectFailed = async (podId, supersededBy) => {
|
|
3353
|
+
// HONEST failure frame (codex finding: fabricated watching:true/RUNNING
|
|
3354
|
+
// read as a healthy pod while it bills unguarded). Fetch the real state;
|
|
3355
|
+
// `watching` reflects whether the single watcher actually follows it.
|
|
3356
|
+
let status = "UNKNOWN"; // unconfirmed — never present a terminal state we didn't verify (codex)
|
|
3357
|
+
let name = null;
|
|
3358
|
+
let gpu = null;
|
|
3359
|
+
let costPerHr = null;
|
|
3360
|
+
let uptime = null;
|
|
3361
|
+
try {
|
|
3362
|
+
const pod = await getPod(podId);
|
|
3363
|
+
status = pod?.desiredStatus ?? "TERMINATED";
|
|
3364
|
+
name = pod?.name ?? null;
|
|
3365
|
+
gpu = pod?.machine?.gpuDisplayName ?? null;
|
|
3366
|
+
costPerHr = pod?.costPerHr ?? null;
|
|
3367
|
+
uptime = pod?.runtime?.uptimeInSeconds ?? null;
|
|
3368
|
+
}
|
|
3369
|
+
catch { /* keep fallbacks */ }
|
|
3370
|
+
// RECHECK before installing: a manual connect/stop during the getPod
|
|
3371
|
+
// await already resolved this — installing now would resurrect a stale
|
|
3372
|
+
// alert on a healthy target (codex finding).
|
|
3373
|
+
if (!isTargetingLocal() && getComfyUIBaseUrl().includes(podId))
|
|
3374
|
+
return;
|
|
3375
|
+
// Suppress the alert ONLY for proven terminal/gone states — a booting
|
|
3376
|
+
// (CREATED/RESTARTING) or unverifiable (UNKNOWN) pod may still be billing
|
|
3377
|
+
// and keeps its warning (codex finding).
|
|
3378
|
+
if (status === "EXITED" || status === "TERMINATED" || status === "DEAD" || status === "PAUSED")
|
|
3379
|
+
return;
|
|
3380
|
+
const frame = {
|
|
3381
|
+
type: "runpod_alert",
|
|
3382
|
+
pod_id: podId,
|
|
3383
|
+
reason: supersededBy ? "superseded" : "timeout",
|
|
3384
|
+
status,
|
|
3385
|
+
name,
|
|
3386
|
+
gpu,
|
|
3387
|
+
cost_per_hr: costPerHr,
|
|
3388
|
+
uptime_seconds: uptime,
|
|
3389
|
+
...(supersededBy ? { superseded_by: supersededBy } : {}),
|
|
3390
|
+
};
|
|
3391
|
+
// Route through the watcher so the failure STICKS (seeds to new tabs and
|
|
3392
|
+
// rides later frames until the pod exits — codex finding: a one-shot
|
|
3393
|
+
// push lets the only warning evaporate on the next routine poll). The
|
|
3394
|
+
// alert channel can't clobber the watched pod's status slot (codex).
|
|
3395
|
+
const w = getRunpodWatcher();
|
|
3396
|
+
if (w)
|
|
3397
|
+
w.markConnectFailed(podId, frame);
|
|
3398
|
+
else
|
|
3399
|
+
void bridge.push(frame);
|
|
3400
|
+
};
|
|
3401
|
+
for (const [podId, p] of pendingPodConnects) {
|
|
3402
|
+
if (Date.now() > p.deadline) {
|
|
3403
|
+
pendingPodConnects.delete(podId);
|
|
3404
|
+
persistPendingConnects();
|
|
3405
|
+
logger.warn(`[panel-orchestrator] pod ${podId} was not ready within 8 minutes — NOT auto-connecting (connect manually with runpod_pod_connect)`);
|
|
3406
|
+
// The tool call is long gone and idle auto-stop can't fire on a pod we
|
|
3407
|
+
// never connected to (renderingOnPod is false) — the failed pod keeps
|
|
3408
|
+
// billing with no visible failure unless we say so (codex finding).
|
|
3409
|
+
void reportConnectFailed(podId);
|
|
3410
|
+
continue;
|
|
3411
|
+
}
|
|
3412
|
+
if (Date.now() - p.lastProbe >= 10_000) {
|
|
3413
|
+
p.lastProbe = Date.now();
|
|
3414
|
+
void (async () => {
|
|
3415
|
+
const stats = await probeOk(`${p.url}/system_stats`);
|
|
3416
|
+
const queue = stats ? await probeOk(`${p.url}/queue`) : false;
|
|
3417
|
+
if (!pendingPodConnects.has(podId) || pendingPodConnects.get(podId) !== p)
|
|
3418
|
+
return; // superseded/cleared
|
|
3419
|
+
if (stats && queue) {
|
|
3420
|
+
pendingPodConnects.delete(podId);
|
|
3421
|
+
persistPendingConnects();
|
|
3422
|
+
// A readiness-driven retarget — NOT a user override: siblings'
|
|
3423
|
+
// pending connects stay alive through the listener (codex).
|
|
3424
|
+
machineRetargetInFlight = true;
|
|
3425
|
+
try {
|
|
3426
|
+
applyComfyuiUrl(p.url);
|
|
3427
|
+
}
|
|
3428
|
+
finally {
|
|
3429
|
+
machineRetargetInFlight = false;
|
|
3430
|
+
}
|
|
3431
|
+
// ONE winner: a second readiness would displace this pod's watch
|
|
3432
|
+
// (its idle-stop guard), so competing pending connects are resolved
|
|
3433
|
+
// as SUPERSEDED — with an HONEST frame: the loser is UNWATCHED
|
|
3434
|
+
// (nothing guards its cost) and connect_failed says why (codex).
|
|
3435
|
+
for (const [otherId, other] of pendingPodConnects) {
|
|
3436
|
+
pendingPodConnects.delete(otherId);
|
|
3437
|
+
persistPendingConnects();
|
|
3438
|
+
logger.warn(`[panel-orchestrator] pod ${otherId} auto-connect superseded by pod ${podId} (one active target) — it is UNWATCHED and still billing; stop it to end billing if unused`);
|
|
3439
|
+
void reportConnectFailed(otherId, podId);
|
|
3440
|
+
void other;
|
|
3441
|
+
}
|
|
3442
|
+
getRunpodWatcher()?.watch(podId);
|
|
3443
|
+
logger.info(`[panel-orchestrator] pod ${podId} ready — auto-connected (${p.url})`);
|
|
3444
|
+
void bridge.push({ type: "runpod_connected", pod_id: podId, url: p.url });
|
|
3445
|
+
}
|
|
3446
|
+
})();
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3106
3449
|
};
|
|
3107
3450
|
const downloadTimer = setInterval(pollDownloads, 700);
|
|
3108
3451
|
downloadTimer.unref?.();
|
|
@@ -3135,6 +3478,9 @@ export async function runPanelOrchestrator() {
|
|
|
3135
3478
|
})();
|
|
3136
3479
|
initRunpodWatcher({
|
|
3137
3480
|
push: (frame) => void bridge.push(frame),
|
|
3481
|
+
// Persist unresolved connect-failure alerts per port (restart-proof — a
|
|
3482
|
+
// self-restart must not lose a still-billing warning, codex finding).
|
|
3483
|
+
persistPath: join(homedir(), ".comfyui-mcp", `runpod-connect-failures-${bridgePort}.json`),
|
|
3138
3484
|
comfyuiIdle: (podId) => {
|
|
3139
3485
|
const s = QueueMonitor.snapshot();
|
|
3140
3486
|
// NOT idle while a training job is alive on THIS pod: training isn't a
|
|
@@ -3142,14 +3488,41 @@ export async function runPanelOrchestrator() {
|
|
|
3142
3488
|
// run "idle" and auto-stop the pod mid-flight (P4 guard; review finding
|
|
3143
3489
|
// on the connector). hasActiveTrainingJob is a probe-free file scan,
|
|
3144
3490
|
// scoped to the watched pod so a run on another pod doesn't suppress
|
|
3145
|
-
// this pod's idle-stop (codex #274).
|
|
3146
|
-
|
|
3491
|
+
// this pod's idle-stop (codex #274). Also NOT idle while THIS POD is
|
|
3492
|
+
// downloading — rows are target-stamped by their writer (#269; an
|
|
3493
|
+
// unstamped pre-fix row errs toward "busy", the cost-safe direction).
|
|
3494
|
+
const podDownloading = downloadingRows.some((d) => {
|
|
3495
|
+
const t = typeof d.target === "string" ? d.target : "";
|
|
3496
|
+
return t === "" || t.includes(podId);
|
|
3497
|
+
});
|
|
3498
|
+
return s.connected && !s.running && s.queueDepth === 0 && !podDownloading && !hasActiveTrainingJob("pod", podId);
|
|
3147
3499
|
},
|
|
3148
3500
|
// Idle auto-stop only applies to a pod we're actually rendering on: the active
|
|
3149
3501
|
// ComfyUI target is that pod's proxy (its id appears in the URL). A pod we
|
|
3150
3502
|
// merely watch while it boots stays local-targeted, so this is false and it is
|
|
3151
3503
|
// never auto-stopped on the local rig's idleness.
|
|
3152
3504
|
renderingOnPod: (podId) => !isTargetingLocal() && getComfyUIBaseUrl().includes(podId),
|
|
3505
|
+
// The watched pod vanished or was auto-stopped (#269 dead-target cleanup):
|
|
3506
|
+
// when renders were pointing AT it, fall back to the local target — via
|
|
3507
|
+
// setComfyuiTarget, so the shared retarget fan-out (QueueMonitor, agents,
|
|
3508
|
+
// frame) runs too. Not every watched pod is the render target, so guard.
|
|
3509
|
+
onPodUnavailable: (goneId) => {
|
|
3510
|
+
if (isTargetingLocal() || !getComfyUIBaseUrl().includes(goneId))
|
|
3511
|
+
return;
|
|
3512
|
+
const local = getLocalComfyuiUrl();
|
|
3513
|
+
logger.warn(`[panel-orchestrator] pod ${goneId} unavailable while targeted — retargeting local ComfyUI (${local})`);
|
|
3514
|
+
// A MACHINE-driven fallback (auto-stop/vanish), not a user override:
|
|
3515
|
+
// pending create-and-connects on OTHER pods must survive it — otherwise
|
|
3516
|
+
// a booting pod silently loses its promised auto-connect (codex finding).
|
|
3517
|
+
machineRetargetInFlight = true;
|
|
3518
|
+
try {
|
|
3519
|
+
setComfyuiTarget(local);
|
|
3520
|
+
}
|
|
3521
|
+
finally {
|
|
3522
|
+
machineRetargetInFlight = false;
|
|
3523
|
+
}
|
|
3524
|
+
resetClient();
|
|
3525
|
+
},
|
|
3153
3526
|
idleStopMinutes: runpodIdleStopMinutes,
|
|
3154
3527
|
});
|
|
3155
3528
|
// Money guard (codex #263): the idle predicate above trusts persisted
|
|
@@ -3170,12 +3543,52 @@ export async function runPanelOrchestrator() {
|
|
|
3170
3543
|
});
|
|
3171
3544
|
}, 5 * 60_000);
|
|
3172
3545
|
trainingReconcileTimer.unref?.();
|
|
3173
|
-
// Honest host indicator: whenever the ComfyUI target moves
|
|
3174
|
-
// pod stop → local fallback, "Local" switch
|
|
3175
|
-
//
|
|
3176
|
-
//
|
|
3177
|
-
//
|
|
3546
|
+
// Honest host indicator + RETARGET FAN-OUT: whenever the ComfyUI target moves
|
|
3547
|
+
// (RunPod connect, pod stop → local fallback, "Local" switch, panel hello),
|
|
3548
|
+
// setComfyuiTarget fires this ONE listener — it repoints everything that was
|
|
3549
|
+
// previously left split-brained (#269): QueueMonitor (its WS/polls keep
|
|
3550
|
+
// talking to the OLD host), the agent subprocesses' MCP env (respawned via
|
|
3551
|
+
// restartAllForMcpEnv), and the env-capability probe, then broadcasts the
|
|
3552
|
+
// `comfyui_target` frame so every panel truthfully shows where renders run.
|
|
3553
|
+
// Same-URL retargets (a repeated connect to the current target) skip the
|
|
3554
|
+
// restart storm and only re-broadcast. Seeded per tab on connect (below) so
|
|
3555
|
+
// a fresh tab knows the host without waiting for a switch.
|
|
3556
|
+
let lastRetargetUrl = comfyuiUrl; // seeded: a first same-URL event must not restart everything
|
|
3178
3557
|
onComfyuiTargetChanged((url, isLocal) => {
|
|
3558
|
+
// ANY target event — a change OR a reaffirmation of the current target —
|
|
3559
|
+
// supersedes ALL pending auto-connects (codex findings: direct
|
|
3560
|
+
// setComfyuiTarget callers bypass applyComfyuiUrl, and an explicit
|
|
3561
|
+
// Local/Stop at the SAME target must not let a booting pod steal it back
|
|
3562
|
+
// later) — EXCEPT a readiness-driven completion, which must leave its
|
|
3563
|
+
// siblings' pending promises intact (codex finding).
|
|
3564
|
+
if (!machineRetargetInFlight) {
|
|
3565
|
+
pendingPodConnects.clear();
|
|
3566
|
+
persistPendingConnects();
|
|
3567
|
+
}
|
|
3568
|
+
if (url !== lastRetargetUrl) {
|
|
3569
|
+
lastRetargetUrl = url;
|
|
3570
|
+
// Sync the shared target closures FIRST: retargets that did NOT come
|
|
3571
|
+
// through applyComfyuiUrl (runpod tools, watcher callbacks) leave them
|
|
3572
|
+
// holding the OLD host — and buildMcpServers()/refreshEnvCapabilities()
|
|
3573
|
+
// below would rebuild/respawn agents against it (codex finding).
|
|
3574
|
+
comfyuiUrl = url;
|
|
3575
|
+
comfyuiPath = localPathForTarget(url);
|
|
3576
|
+
try {
|
|
3577
|
+
QueueMonitor.stop();
|
|
3578
|
+
}
|
|
3579
|
+
catch {
|
|
3580
|
+
/* best-effort */
|
|
3581
|
+
}
|
|
3582
|
+
QueueMonitor.start(url);
|
|
3583
|
+
manager.setMcpServers(buildMcpServers());
|
|
3584
|
+
manager.setComfyuiUrl(url);
|
|
3585
|
+
manager.restartAllForMcpEnv();
|
|
3586
|
+
void refreshEnvCapabilities();
|
|
3587
|
+
}
|
|
3588
|
+
// The readvertise timer follows the target too (created only at startup
|
|
3589
|
+
// before — a local→pod retarget left the pod unable to learn the bridge
|
|
3590
|
+
// URL, codex finding). Cheap no-op for same-url events.
|
|
3591
|
+
syncReadvertise(url);
|
|
3179
3592
|
void bridge.push({ type: "comfyui_target", url, is_local: isLocal });
|
|
3180
3593
|
});
|
|
3181
3594
|
// Keep the pod's stored bridge URL fresh so a ComfyUI RESTART self-heals fast.
|
|
@@ -3190,15 +3603,104 @@ export async function runPanelOrchestrator() {
|
|
|
3190
3603
|
// the advertise on a cheap idempotent timer repopulates the pod's store within
|
|
3191
3604
|
// one interval of any reboot (from any cause: the agent's restart, a Manager
|
|
3192
3605
|
// UI restart, a crash), so the browser's reclaim poll reconnects promptly.
|
|
3193
|
-
// Only meaningful for a remote https target with a secure bridge.
|
|
3606
|
+
// Only meaningful for a remote https target with a secure bridge. Driven by
|
|
3607
|
+
// syncReadvertise at STARTUP and on every retarget (codex finding: a
|
|
3608
|
+
// local→pod retarget later left the timer nonexistent, so the pod could
|
|
3609
|
+
// never learn the WSS URL/token — the very deadlock it prevents). The
|
|
3610
|
+
// bridge itself is created LAZILY on the first remote target too (a local
|
|
3611
|
+
// boot has wantSecureBridge=false — codex finding).
|
|
3194
3612
|
let readvertiseTimer = null;
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3613
|
+
// One in-flight setup MAX — concurrent ticks must not each start a tunnel
|
|
3614
|
+
// (codex finding: a slow first attempt spawned multiple cloudflared clients).
|
|
3615
|
+
let secureBridgeSetup = null;
|
|
3616
|
+
// A tokenless primary listener does NOT rule out pod panels: the tunnel gets
|
|
3617
|
+
// its OWN token-gated listener (same addListener mechanism the phone-pair
|
|
3618
|
+
// flow uses) on a dedicated port — never retrofit auth onto the public path
|
|
3619
|
+
// and never open a tunnel in front of an unauthenticated one (codex P1).
|
|
3620
|
+
// Port map: +0 bridge, +1 panel_* HTTP-MCP, +2 phone-pair, +3 console (line
|
|
3621
|
+
// ~1427), +4 tunnel listener (codex finding: +3 was already taken).
|
|
3622
|
+
const tunnelPort = lockPort + 4;
|
|
3623
|
+
let tunnelToken = null;
|
|
3624
|
+
let tunnelListenerStarted = false;
|
|
3625
|
+
const ensureSecureBridge = async (url) => {
|
|
3626
|
+
if (secureBridge)
|
|
3627
|
+
return;
|
|
3628
|
+
if (!secureBridgeSetup) {
|
|
3629
|
+
secureBridgeSetup = (async () => {
|
|
3630
|
+
try {
|
|
3631
|
+
let port = lockPort;
|
|
3632
|
+
let token;
|
|
3633
|
+
if (bridgeListenerTokenless) {
|
|
3634
|
+
// Primary listener stays tokenless for local use; the tunnel binds
|
|
3635
|
+
// its own token-gated listener (pair-flow precedent) and advertises
|
|
3636
|
+
// THAT token — enforced, because this listener was built with it.
|
|
3637
|
+
// Bound ONCE: a retried setup must reuse it, not EADDRINUSE (codex).
|
|
3638
|
+
tunnelToken ??= randomBytes(24).toString("hex");
|
|
3639
|
+
if (!tunnelListenerStarted) {
|
|
3640
|
+
await bridge.addListener("0.0.0.0", tunnelPort, tunnelToken);
|
|
3641
|
+
tunnelListenerStarted = true;
|
|
3642
|
+
}
|
|
3643
|
+
port = tunnelPort;
|
|
3644
|
+
token = tunnelToken;
|
|
3645
|
+
}
|
|
3646
|
+
else if (bridgeToken) {
|
|
3647
|
+
token = bridgeToken;
|
|
3648
|
+
}
|
|
3649
|
+
else {
|
|
3650
|
+
return; // unreachable — the tokenless branch above covers this
|
|
3651
|
+
}
|
|
3652
|
+
secureBridge = await setupSecureBridge({
|
|
3653
|
+
bridgePort: port,
|
|
3654
|
+
comfyuiUrl: url,
|
|
3655
|
+
token,
|
|
3656
|
+
bridge,
|
|
3657
|
+
// The setup itself advertises on completion — guard it too: a slow
|
|
3658
|
+
// tunnel must not hand the bridge URL to a pod the user already
|
|
3659
|
+
// left (codex finding: the post-await guard alone couldn't stop it).
|
|
3660
|
+
shouldAdvertise: (t) => comfyuiUrl === t && isRemoteHttpsUrl(t),
|
|
3661
|
+
});
|
|
3662
|
+
}
|
|
3663
|
+
catch (err) {
|
|
3664
|
+
logger.error(`[panel-orchestrator] secure bridge (cloudflared) failed: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
3665
|
+
`Install cloudflared (npm i -g cloudflared), or re-run with --insecure-bridge and open the pod through an ` +
|
|
3666
|
+
`SSH tunnel (ssh -L 3000:localhost:3000 …) at http://localhost:3000.`);
|
|
3667
|
+
}
|
|
3668
|
+
finally {
|
|
3669
|
+
secureBridgeSetup = null;
|
|
3670
|
+
}
|
|
3671
|
+
})();
|
|
3672
|
+
}
|
|
3673
|
+
return secureBridgeSetup;
|
|
3674
|
+
};
|
|
3675
|
+
const syncReadvertise = (url) => {
|
|
3676
|
+
const wanted = !insecureBridge && isRemoteHttpsUrl(url);
|
|
3677
|
+
if (wanted && !readvertiseTimer) {
|
|
3678
|
+
// Immediate first advertise (creating the tunnel on a local→pod
|
|
3679
|
+
// transition), then the interval.
|
|
3680
|
+
void (async () => {
|
|
3681
|
+
await ensureSecureBridge(url);
|
|
3682
|
+
// The user may have moved on while the tunnel came up — advertising an
|
|
3683
|
+
// OLD pod now would let its panel hello steal the newer target (codex).
|
|
3684
|
+
if (secureBridge && comfyuiUrl === url && isRemoteHttpsUrl(url))
|
|
3685
|
+
void secureBridge.advertise(url);
|
|
3686
|
+
})();
|
|
3687
|
+
readvertiseTimer = setInterval(() => {
|
|
3688
|
+
void (async () => {
|
|
3689
|
+
if (isRemoteHttpsUrl(comfyuiUrl)) {
|
|
3690
|
+
await ensureSecureBridge(comfyuiUrl);
|
|
3691
|
+
if (secureBridge)
|
|
3692
|
+
void secureBridge.advertise(comfyuiUrl);
|
|
3693
|
+
}
|
|
3694
|
+
})();
|
|
3695
|
+
}, 5000);
|
|
3696
|
+
readvertiseTimer.unref?.();
|
|
3697
|
+
}
|
|
3698
|
+
else if (!wanted && readvertiseTimer) {
|
|
3699
|
+
clearInterval(readvertiseTimer);
|
|
3700
|
+
readvertiseTimer = null;
|
|
3701
|
+
}
|
|
3702
|
+
};
|
|
3703
|
+
syncReadvertise(comfyuiUrl);
|
|
3202
3704
|
// The no-path suffix must not read as an error when it is BY DESIGN: for a
|
|
3203
3705
|
// remote target a local path is the wrong filesystem and is deliberately
|
|
3204
3706
|
// dropped — installs/downloads run host-side via ComfyUI-Manager (remote
|
|
@@ -3227,6 +3729,14 @@ export async function runPanelOrchestrator() {
|
|
|
3227
3729
|
if (readvertiseTimer)
|
|
3228
3730
|
clearInterval(readvertiseTimer);
|
|
3229
3731
|
QueueMonitor.stop();
|
|
3732
|
+
// Remove OUR nonced progress dir (startup reaps previous lives') so the
|
|
3733
|
+
// auto-restart cycle doesn't accumulate temp dirs (codex finding).
|
|
3734
|
+
try {
|
|
3735
|
+
rmSync(progressDir, { recursive: true, force: true });
|
|
3736
|
+
}
|
|
3737
|
+
catch {
|
|
3738
|
+
/* best-effort */
|
|
3739
|
+
}
|
|
3230
3740
|
unsubscribeSecrets();
|
|
3231
3741
|
unsubscribeAgentSecrets();
|
|
3232
3742
|
await manager.stopAll();
|