comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
1,564 lines • 70.3 kB
JavaScript
import { renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
import { lstat, mkdtemp, open, opendir, rename, rmdir, unlink } from "node:fs/promises";
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* The only production path for a /view retry when the configured headless
* ComfyUI target is unreachable. The child writes a reference-only request;
* the orchestrator resolves an in-memory capability to a live panel and
* performs the authenticated bridge command. The child supplies only the
* canonical target identity it was spawned for; the orchestrator compares it
* with its current target before and after the bridge command. No origin or
* file-supplied tab identity crosses this channel.
*/
export const PANEL_IMAGE_RELAY_VERSION = 1;
export const PANEL_IMAGE_RELAY_MAX_BYTES = 32 * 1024 * 1024;
export const PANEL_IMAGE_RELAY_TIMEOUT_MS = 8_000;
export const PANEL_IMAGE_RELAY_STALE_MS = 15_000;
export const PANEL_IMAGE_RELAY_MAX_REQUEST_FILE_BYTES = 16 * 1024;
export const PANEL_IMAGE_RELAY_MAX_RESPONSE_FILE_BYTES = 48 * 1024 * 1024;
export const PANEL_COMFYUI_READ_MAX_BYTES = 16 * 1024 * 1024;
export const PANEL_COMFYUI_READ_OBJECT_INFO_MAX_BYTES = 32 * 1024 * 1024;
export const PANEL_COMFYUI_READ_OBJECT_INFO_TIMEOUT_MS = 30_000;
export const PANEL_IMAGE_RELAY_MAX_CONCURRENT = 4;
export const PANEL_IMAGE_RELAY_MAX_REQUESTS_PER_TICK = 4;
export const PANEL_IMAGE_RELAY_MAX_DIRECTORY_ENTRIES_PER_TICK = 128;
export const PANEL_IMAGE_RELAY_MAX_PENDING_REQUESTS = 16;
export const PANEL_IMAGE_RELAY_MAX_PENDING_RESPONSES = 4;
export const PANEL_IMAGE_RELAY_HTTP_PATH = "/__comfyui_mcp_panel_image_relay";
export const PANEL_IMAGE_RELAY_MAX_HTTP_REQUEST_BYTES = 16 * 1024;
export const PANEL_IMAGE_RELAY_MAX_HTTP_RESPONSE_BYTES = 48 * 1024 * 1024;
export const PANEL_IMAGE_RELAY_REQUEST_PREFIX = "control-image-request-";
export const PANEL_IMAGE_RELAY_RESPONSE_PREFIX = "control-image-response-";
const RELAY_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
const RELAY_CAPABILITY_RE = /^[a-f0-9]{64}$/;
const RELAY_SECRET_RE = /^[a-f0-9]{64}$/;
const IMAGE_TYPES = new Set(["output", "input", "temp"]);
const FIXED_READ_OPERATIONS = new Set([
"history",
"system_stats",
"logs",
"object_info",
"models",
"workflow_templates",
]);
const MODELS_FOLDER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const MIME_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,62}\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,62}$/;
/** Closed allowlist: diagnostics reads, workflow templates, `/models`, or `/models/<folder>`. */
export function isPanelComfyUIReadOperation(value) {
if (FIXED_READ_OPERATIONS.has(value))
return true;
if (!value.startsWith("models/"))
return false;
return MODELS_FOLDER_RE.test(value.slice("models/".length));
}
export class PanelImageRelayError extends Error {
code;
unavailable;
/** #2703 - the panel-side cause behind a PANEL_FETCH_FAILED, when one was
* carried. DIAGNOSTIC PROSE ONLY: nothing branches on it. */
reason;
constructor(message, code, unavailable = false, reason) {
super(message);
this.name = "PanelImageRelayError";
this.code = code;
this.unavailable = unavailable;
if (reason !== undefined)
this.reason = reason;
}
}
export class PanelComfyUIReadRelayError extends Error {
code;
unavailable;
/** #2703 - see PanelImageRelayError.reason. */
reason;
constructor(message, code, unavailable = false, reason) {
super(message);
this.name = "PanelComfyUIReadRelayError";
this.code = code;
this.unavailable = unavailable;
if (reason !== undefined)
this.reason = reason;
}
}
function hasOwn(value, key) {
return Object.prototype.hasOwnProperty.call(value, key);
}
function hasOnlyKeys(value, keys) {
const allowed = new Set(keys);
return Object.keys(value).every((key) => allowed.has(key));
}
function isSafeText(value, max) {
return (typeof value === "string" &&
value.length > 0 &&
value.length <= max &&
![...value].some((char) => {
const code = char.charCodeAt(0);
return code < 0x20 || code === 0x7f;
}));
}
/**
* How much of a panel-side failure cause may ride back with the code (#2703).
*
* 200 rather than the 160 used for `error`: `error` is a fixed token from a
* closed set, while this is a sentence. The panel's longest shipped message
* ("fetch_comfyui_read response exceeds the 16777216-byte limit") fits well
* inside it, and a bound this size cannot be used to move a payload.
*/
const PANEL_FAILURE_REASON_MAX = 200;
/**
* Turn the rejected bridge send into ONE sentence naming what actually went
* wrong (#2703).
*
* WHY THIS EXISTS. `PANEL_FETCH_FAILED` is the answer for every non-timeout
* throw out of `bridge.send`, and that is a whole family of distinct,
* differently-actionable causes: the panel's own `too_large` (the read exceeded
* its byte ceiling), `timeout`, `network_error`, `http_error` with a status,
* `invalid_origin`, `redirect_error`, `api_unavailable`, the workflow-reload
* guard, and an "Unknown command" from a panel that predates the read relay.
* The code separates NONE of them, so the reporter on #2703 was told
* "the connected panel ComfyUI read fallback failed safely (PANEL_FETCH_FAILED)"
* - true, and unactionable. The error this catch is already holding says which
* one it was; it was simply being dropped on the floor.
*
* TRUST. This is panel-authored prose reaching an agent, so it is treated the
* way the image path already treats a panel-authored `error` string: bounded,
* control characters removed, and gated on `isSafeText`. Nothing parses it and
* nothing branches on it - it is one sentence appended to an error message.
* There is no credential-reflection surface behind it either: the panel's read
* helper builds its messages from a FIXED route, a status number, a byte count
* and the browser's own fetch error, never from a response body (the class of
* leak #385 hit). Anything that fails the guard is dropped whole rather than
* repaired, so the worst case is exactly the pre-#2703 bare code.
*/
function panelFailureReason(error) {
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
// Control characters are folded to spaces rather than rejecting the whole
// string: a message that merely wraps a line still carries its cause, and
// dropping it would put us back at the bare code this exists to replace.
const flattened = raw.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
if (!flattened)
return undefined;
const clipped = flattened.length > PANEL_FAILURE_REASON_MAX
? `${flattened.slice(0, PANEL_FAILURE_REASON_MAX - 1)}\u2026`
: flattened;
return isSafeText(clipped, PANEL_FAILURE_REASON_MAX) ? clipped : undefined;
}
/**
* ABSENT, or an OWN bounded safe string on the ONE code that carries one
* (#2703). Anything else is a malformed reply.
*
* Kept separate from the `hasOnlyKeys` list because the two say different
* things: the key list decides which fields MAY appear, and this decides what
* the field is allowed to be. A `reason` present but wrong is a malformed
* reply, not a reply with the field quietly ignored - the MAC covers it, so a
* value we would not accept is a value the writer and reader disagree about.
*
* Two refusals here that the first draft did not have (codex gate, round 2):
*
* - THE CODE. Widening the key set let a signed `TIMEOUT` carrying a `reason`
* validate, where before the extra key made it MALFORMED_REPLY. Dropping
* that reason at RENDER time (the first draft did) fixed what the user sees
* while still loosening the wire contract for every code except the one that
* legitimately changed. Refusing it here restores the old answer for all of
* them, and makes this the single place the rule is stated - the two readers
* downstream may then simply repeat `response.reason`.
*
* - INHERITANCE. `hasOwn` said "absent" for a `reason` reached through the
* prototype while `record.reason` downstream reads it, so a polluted
* `Object.prototype` slipped past this gate. This one is LOAD-BEARING, and
* a first draft of the comment here wrongly said it was not: the reasoning
* was that `responseMacPayload` reads the same inherited value, so the
* digest would disagree and the reply be refused anyway. It disagrees only
* when the SENDER did not account for it. A peer that signs the six-element
* digest over the inherited value sends a body with no `reason` key at all,
* and the MAC then verifies perfectly - leaving this the only check between
* an unbounded string and the user's error message. Mutation testing is what
* said so: deleting this line killed nothing until the test signed the
* polluted value. An inherited value is refused outright rather than
* accepted-if-safe; nothing on the wire put it there.
*/
function validFailureReason(record) {
// The EFFECTIVE value (own or inherited), because that is what every reader
// of this record - including the MAC payload - will see.
const effective = record.reason;
if (effective === undefined)
return true;
if (!hasOwn(record, "reason"))
return false;
if (record.error !== "PANEL_FETCH_FAILED")
return false;
return isSafeText(effective, PANEL_FAILURE_REASON_MAX);
}
/** The one rendering of a carried reason, shared by both readers. */
function reasonSuffix(reason) {
return reason ? ` The panel reported: ${reason}` : "";
}
/** Strict wire-level validation. Keep this stricter than the legacy tool path. */
export function isSafePanelImageRef(filename, subfolder, type) {
if (!isSafeText(filename, 4096) || !isSafeText(subfolder, 4096) && subfolder !== "")
return false;
if (typeof type !== "string" || !IMAGE_TYPES.has(type))
return false;
if (filename === "." ||
filename === ".." ||
filename.includes("/") ||
filename.includes("\\") ||
filename.includes(":") ||
filename.includes("?") ||
filename.includes("#"))
return false;
if (subfolder === "")
return true;
if (subfolder.startsWith("/") ||
subfolder.startsWith("\\") ||
/^[A-Za-z]:/.test(subfolder) ||
subfolder.includes("\\") ||
subfolder.includes("?") ||
subfolder.includes("#"))
return false;
const segments = subfolder.split("/");
return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
}
function isSafeRelayCapability(value) {
return typeof value === "string" && RELAY_CAPABILITY_RE.test(value);
}
function isSafeRelaySecret(value) {
return typeof value === "string" && RELAY_SECRET_RE.test(value);
}
const RELAY_TARGET_URL_MAX_LENGTH = 4_096;
/** Keep only the identity-bearing, non-secret parts of a ComfyUI URL. */
function canonicalRelayTargetUrl(value) {
if (typeof value !== "string" || value.length === 0 || value.length > RELAY_TARGET_URL_MAX_LENGTH)
return undefined;
try {
const url = new URL(value.trim());
if ((url.protocol !== "http:" && url.protocol !== "https:") ||
url.username ||
url.password ||
url.search ||
url.hash)
return undefined;
return `${url.protocol}//${url.host}${url.pathname.replace(/\/+$/, "")}`;
}
catch {
return undefined;
}
}
function relayTargetFromEnv() {
const url = canonicalRelayTargetUrl(process.env.COMFYUI_URL);
const rawGeneration = process.env.COMFYUI_MCP_TARGET_GENERATION?.trim();
if (!url || !rawGeneration || !/^\d+$/.test(rawGeneration))
return undefined;
const generation = Number(rawGeneration);
if (!Number.isSafeInteger(generation) || generation < 0)
return undefined;
return { url, generation };
}
function relayTargetMatches(request, target) {
return !!target &&
canonicalRelayTargetUrl(request.targetUrl) === canonicalRelayTargetUrl(target.url) &&
request.targetGeneration === target.generation;
}
function currentRelayTarget(options) {
try {
const target = options.resolveCurrentTarget();
if (!target ||
typeof target.url !== "string" ||
!Number.isSafeInteger(target.generation) ||
target.generation < 0 ||
!canonicalRelayTargetUrl(target.url))
return undefined;
return target;
}
catch {
return undefined;
}
}
function currentPanelRelayTarget(options, tabId) {
try {
const target = options.resolvePanelTarget(tabId);
if (!target ||
typeof target.url !== "string" ||
!Number.isSafeInteger(target.generation) ||
target.generation < 0 ||
!canonicalRelayTargetUrl(target.url))
return undefined;
return target;
}
catch {
return undefined;
}
}
function relayTargetsMatch(options, request, panelTab) {
return relayTargetMatches(request, currentRelayTarget(options)) &&
relayTargetMatches(request, currentPanelRelayTarget(options, panelTab));
}
function relayAuthPayload(input) {
return JSON.stringify([
input.requestId,
input.targetUrl,
input.targetGeneration,
input.filename,
input.subfolder,
input.type,
input.createdAt,
input.deadlineAt,
]);
}
export function makePanelImageRelayCapability(secret, input) {
return createHmac("sha256", secret).update(relayAuthPayload(input)).digest("hex");
}
export function verifyPanelImageRelayCapability(secret, request) {
if (!isSafeRelaySecret(secret) || !isSafeRelayCapability(request.capability))
return false;
const expected = makePanelImageRelayCapability(secret, request);
return timingSafeEqual(Buffer.from(request.capability, "hex"), Buffer.from(expected, "hex"));
}
function readRelayAuthPayload(input) {
return JSON.stringify([
"fetch_comfyui_read",
input.requestId,
input.targetUrl,
input.targetGeneration,
input.operation,
input.createdAt,
input.deadlineAt,
]);
}
export function makePanelComfyUIReadRelayCapability(secret, input) {
return createHmac("sha256", secret).update(readRelayAuthPayload(input)).digest("hex");
}
export function verifyPanelComfyUIReadRelayCapability(secret, request) {
if (!isSafeRelaySecret(secret) ||
!isSafeRelayCapability(request.capability) ||
!isPanelComfyUIReadOperation(request.operation))
return false;
const expected = makePanelComfyUIReadRelayCapability(secret, request);
return timingSafeEqual(Buffer.from(request.capability, "hex"), Buffer.from(expected, "hex"));
}
function isSafeRelayId(value) {
return typeof value === "string" && RELAY_ID_RE.test(value);
}
function requestPath(dir, requestId) {
return join(dir, `${PANEL_IMAGE_RELAY_REQUEST_PREFIX}${requestId}.json`);
}
function responsePath(dir, requestId) {
return join(dir, `${PANEL_IMAGE_RELAY_RESPONSE_PREFIX}${requestId}.json`);
}
function sameFileIdentity(left, right) {
return left.dev === right.dev && left.ino === right.ino;
}
/** Read one bounded regular file through one descriptor; never stat then reopen. */
async function readBoundedJson(path, maxBytes) {
const linkStat = await lstat(path);
if (!linkStat.isFile() || linkStat.isSymbolicLink())
throw new Error("relay input is not a regular file");
const handle = await open(path, "r");
try {
const stat = await handle.stat();
if (!stat.isFile() || !sameFileIdentity(linkStat, stat) || stat.size > maxBytes) {
throw new Error("relay input is not the handed-off regular file");
}
const data = Buffer.alloc(stat.size);
let offset = 0;
while (offset < data.byteLength) {
const { bytesRead } = await handle.read(data, offset, data.byteLength - offset, null);
if (bytesRead === 0)
throw new Error("relay input ended before its declared size");
offset += bytesRead;
}
const finalStat = await handle.stat();
if (!finalStat.isFile() || finalStat.size !== stat.size) {
throw new Error("relay input changed while it was being read");
}
return JSON.parse(data.toString("utf8"));
}
finally {
await handle.close();
}
}
async function createPrivateStagingDir() {
try {
return await mkdtemp(join(tmpdir(), "comfyui-mcp-image-relay-"));
}
catch {
return undefined;
}
}
/** Move the untrusted directory entry without opening or following it. */
async function handoffRequest(dir, file, stagingDir) {
const source = join(dir, file);
const destination = join(stagingDir, file);
try {
await rename(source, destination);
return destination;
}
catch {
return undefined;
}
}
async function handoffResponse(dir, requestId, stagingDir) {
const file = `${PANEL_IMAGE_RELAY_RESPONSE_PREFIX}${requestId}.json`;
const source = join(dir, file);
const destination = join(stagingDir, file);
try {
await rename(source, destination);
return destination;
}
catch {
return undefined;
}
}
async function removeStagedEntry(path) {
try {
const stat = await lstat(path);
if (stat.isDirectory() && !stat.isSymbolicLink()) {
await rmdir(path).catch(() => undefined);
}
else {
await unlink(path).catch(() => undefined);
}
}
catch {
// The entry may already have been removed during shutdown or cleanup.
}
}
function writeJsonAtomically(path, value, maxBytes) {
const data = Buffer.from(JSON.stringify(value), "utf8");
if (data.byteLength > maxBytes)
throw new Error("relay response exceeds the safety limit");
const temp = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
try {
writeFileSync(temp, data, { flag: "wx", mode: 0o600 });
renameSync(temp, path);
}
finally {
try {
rmSync(temp, { force: true });
}
catch {
// best effort cleanup
}
}
}
function canonicalBase64(value) {
if (typeof value !== "string" || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
return false;
}
return Buffer.from(value, "base64").toString("base64") === value;
}
function validateImagePayload(value) {
if (!value || typeof value !== "object")
return undefined;
const record = value;
if (!hasOwn(record, "base64") ||
!hasOwn(record, "mimeType") ||
!hasOwn(record, "bytes") ||
!hasOnlyKeys(record, ["base64", "mimeType", "bytes"]))
return undefined;
if (!canonicalBase64(record.base64) || !isSafeText(record.mimeType, 128) || !MIME_RE.test(record.mimeType))
return undefined;
const bytes = record.bytes;
if (typeof bytes !== "number" || !Number.isSafeInteger(bytes) || bytes < 0 || bytes > PANEL_IMAGE_RELAY_MAX_BYTES)
return undefined;
const actualBytes = Buffer.byteLength(record.base64, "base64");
if (actualBytes !== bytes)
return undefined;
return { base64: record.base64, mimeType: record.mimeType, bytes };
}
function validateReadPayload(value) {
if (!value || typeof value !== "object")
return undefined;
const record = value;
if (!hasOnlyKeys(record, ["operation", "body", "contentType", "bytes", "viewing"]) ||
typeof record.operation !== "string" ||
!isPanelComfyUIReadOperation(record.operation) ||
typeof record.body !== "string" ||
(record.contentType !== null &&
(typeof record.contentType !== "string" || !isSafeText(record.contentType, 128))))
return undefined;
const operation = record.operation;
if (hasOwn(record, "viewing")) {
const viewing = record.viewing;
if (!viewing || typeof viewing !== "object" || Array.isArray(viewing))
return undefined;
const witness = viewing;
if (!hasOnlyKeys(witness, ["scope", "owner_node_id", "title", "workflow_uuid", "graph_identity"]) ||
(witness.scope !== "root" && witness.scope !== "subgraph"))
return undefined;
if (hasOwn(witness, "owner_node_id")) {
const owner = witness.owner_node_id;
if (owner !== null &&
!(typeof owner === "number" && Number.isSafeInteger(owner)) &&
!(typeof owner === "string" && isSafeText(owner, 256)))
return undefined;
}
if (hasOwn(witness, "title") && !isSafeText(witness.title, 256))
return undefined;
if (hasOwn(witness, "workflow_uuid") && !isSafeText(witness.workflow_uuid, 256))
return undefined;
if (hasOwn(witness, "graph_identity") && !isSafeText(witness.graph_identity, 256))
return undefined;
}
const bytes = record.bytes;
if (typeof bytes !== "number" ||
!Number.isSafeInteger(bytes) ||
bytes < 0 ||
bytes > readMaxBytes(operation) ||
Buffer.byteLength(record.body, "utf8") !== bytes)
return undefined;
const contentType = record.contentType === null ? null : record.contentType;
if (contentType !== null && typeof contentType !== "string")
return undefined;
return {
operation,
body: record.body,
contentType,
bytes,
};
}
function validateRequest(value, requestId) {
if (!value || typeof value !== "object")
return undefined;
const record = value;
const capability = record.capability;
const createdAt = record.createdAt;
const deadlineAt = record.deadlineAt;
if (!hasOnlyKeys(record, ["version", "requestId", "capability", "targetUrl", "targetGeneration", "filename", "subfolder", "type", "createdAt", "deadlineAt"]) ||
record.version !== PANEL_IMAGE_RELAY_VERSION ||
record.requestId !== requestId ||
!isSafeRelayCapability(capability) ||
!canonicalRelayTargetUrl(record.targetUrl) ||
typeof record.targetGeneration !== "number" ||
!Number.isSafeInteger(record.targetGeneration) ||
record.targetGeneration < 0 ||
!isSafePanelImageRef(record.filename, record.subfolder, record.type) ||
typeof createdAt !== "number" ||
typeof deadlineAt !== "number" ||
!Number.isSafeInteger(createdAt) ||
!Number.isSafeInteger(deadlineAt) ||
deadlineAt < createdAt ||
deadlineAt - createdAt > PANEL_IMAGE_RELAY_TIMEOUT_MS)
return undefined;
return record;
}
function validateReadRequest(value, requestId) {
if (!value || typeof value !== "object")
return undefined;
const record = value;
const capability = record.capability;
const createdAt = record.createdAt;
const deadlineAt = record.deadlineAt;
if (!hasOnlyKeys(record, ["version", "requestId", "capability", "targetUrl", "targetGeneration", "operation", "createdAt", "deadlineAt"]) ||
record.version !== PANEL_IMAGE_RELAY_VERSION ||
record.requestId !== requestId ||
!isSafeRelayCapability(capability) ||
!canonicalRelayTargetUrl(record.targetUrl) ||
typeof record.targetGeneration !== "number" ||
!Number.isSafeInteger(record.targetGeneration) ||
record.targetGeneration < 0 ||
typeof record.operation !== "string" ||
!isPanelComfyUIReadOperation(record.operation) ||
typeof createdAt !== "number" ||
typeof deadlineAt !== "number" ||
!Number.isSafeInteger(createdAt) ||
!Number.isSafeInteger(deadlineAt) ||
deadlineAt < createdAt ||
deadlineAt - createdAt > readTimeoutMs(record.operation))
return undefined;
return record;
}
function isReadRelayRequest(request) {
return "operation" in request;
}
function readTimeoutMs(operation) {
return operation === "object_info" ? PANEL_COMFYUI_READ_OBJECT_INFO_TIMEOUT_MS : PANEL_IMAGE_RELAY_TIMEOUT_MS;
}
function readMaxBytes(operation) {
return operation === "object_info" ? PANEL_COMFYUI_READ_OBJECT_INFO_MAX_BYTES : PANEL_COMFYUI_READ_MAX_BYTES;
}
function requestTimeoutMs(request) {
return isReadRelayRequest(request) ? readTimeoutMs(request.operation) : PANEL_IMAGE_RELAY_TIMEOUT_MS;
}
function validateResponse(value, requestId) {
if (!value || typeof value !== "object")
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
const record = value;
if (record.version !== PANEL_IMAGE_RELAY_VERSION ||
record.requestId !== requestId ||
!hasOwn(record, "ok") ||
!Number.isSafeInteger(record.updated))
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
const updated = record.updated;
if (typeof updated !== "number")
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
if (record.ok === true) {
const payload = validateImagePayload({
base64: record.base64,
mimeType: record.mimeType,
bytes: record.bytes,
});
if (!payload ||
!hasOwn(record, "updated") ||
!hasOnlyKeys(record, ["version", "requestId", "ok", "base64", "mimeType", "bytes", "updated"]))
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
return { ...payload, version: PANEL_IMAGE_RELAY_VERSION, requestId, ok: true, updated };
}
if (record.ok === false &&
typeof record.error === "string" &&
record.error.length <= 160 &&
isSafeText(record.error, 160) &&
validFailureReason(record) &&
hasOnlyKeys(record, ["version", "requestId", "ok", "error", "updated", "reason"])) {
return record;
}
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
}
function failureResponse(requestId, error, updated = Date.now(), reason) {
return {
version: PANEL_IMAGE_RELAY_VERSION,
requestId,
ok: false,
error,
updated,
// Omitted rather than set to undefined: the far side validates the KEY SET
// with hasOnlyKeys, and JSON.stringify drops an explicit undefined anyway -
// which would leave the two sides disagreeing about the shape they signed.
...(reason === undefined ? {} : { reason }),
};
}
function responseFailureMessage(response) {
const known = new Set([
"AMBIGUOUS_REQUESTER",
"BACKLOG_FULL",
"MALFORMED_REPLY",
"NO_LIVE_PANEL",
"PANEL_FETCH_FAILED",
"STALE_TARGET",
"STALE_REQUEST",
"TIMEOUT",
]);
const code = known.has(response.error) ? response.error : "PANEL_FETCH_FAILED";
// #2703 - `response` reached here through validFailureReason, which admits a
// reason ONLY alongside a literal `PANEL_FETCH_FAILED` error. So a reason
// present here cannot belong to a remapped unknown code or to a TIMEOUT, and
// `code` necessarily equals `response.error`. A second filter at this point
// was written first and then removed: mutation testing showed it unreachable,
// and an unreachable guard that looks load-bearing misdirects the next reader
// about where the rule actually lives. It lives in validFailureReason.
const reason = response.reason;
throw new PanelImageRelayError(code === "PANEL_FETCH_FAILED"
? `The connected panel could not fetch that image.${reasonSuffix(reason)}`
: `The connected panel image relay failed (${code}).`, code, false, reason);
}
function responseMacPayload(response) {
if (!response.ok) {
// `reason` is part of the SIGNED payload. A field the MAC does not cover is
// a field the reader may not rely on, and this one is read out loud to the
// user - an unauthenticated sentence attributed to the panel is worse than
// no sentence at all.
//
// APPENDED, not always-present-as-null. Writing it unconditionally changed
// the digest of a REASONLESS failure too, and that is a compatibility break
// this fix has no business making: the relay server and its client are two
// PROCESSES. An in-place package update swaps `dist` under a running
// orchestrator, and the children it spawns afterwards load the new code
// while it still runs the old - so an old signer would meet a new verifier
// and every relay failure would read MALFORMED_REPLY until the orchestrator
// restarted. (Caught by the shipped `classifies an authenticated server
// TIMEOUT before deadline freshness` test, which hand-signs the pre-#2703
// payload; that test was the old signer, and it was right to reject the
// change.) Appending keeps the old shape byte-identical.
//
// The two forms still cannot be confused: a 5-element array and a
// 6-element one never serialise to the same string, so stripping a reason
// or adding one both fail verification.
const base = [response.version, response.requestId, false, response.error, response.updated];
return JSON.stringify(response.reason === undefined ? base : [...base, response.reason]);
}
if ("operation" in response) {
return JSON.stringify([
response.version,
response.requestId,
true,
response.operation,
response.body,
response.contentType,
response.bytes,
response.updated,
]);
}
return JSON.stringify([
response.version,
response.requestId,
true,
response.base64,
response.mimeType,
response.bytes,
response.updated,
]);
}
function addResponseMac(secret, response) {
return {
...response,
responseMac: createHmac("sha256", secret).update(responseMacPayload(response)).digest("hex"),
};
}
function validateReadResponse(value, requestId, operation) {
if (!value || typeof value !== "object") {
throw new PanelComfyUIReadRelayError("The panel returned a malformed ComfyUI read reply.", "MALFORMED_REPLY");
}
const record = value;
if (record.version !== PANEL_IMAGE_RELAY_VERSION ||
record.requestId !== requestId ||
!hasOwn(record, "ok") ||
!Number.isSafeInteger(record.updated))
throw new PanelComfyUIReadRelayError("The panel returned a malformed ComfyUI read reply.", "MALFORMED_REPLY");
if (record.ok === false) {
if (typeof record.error === "string" &&
record.error.length <= 160 &&
isSafeText(record.error, 160) &&
validFailureReason(record) &&
hasOnlyKeys(record, ["version", "requestId", "ok", "error", "updated", "reason"]))
return record;
throw new PanelComfyUIReadRelayError("The panel returned a malformed ComfyUI read reply.", "MALFORMED_REPLY");
}
const payload = validateReadPayload({
operation: record.operation,
body: record.body,
contentType: record.contentType,
bytes: record.bytes,
});
if (record.ok !== true ||
!payload ||
payload.operation !== operation ||
!hasOnlyKeys(record, ["version", "requestId", "ok", "operation", "body", "contentType", "bytes", "updated"]))
throw new PanelComfyUIReadRelayError("The panel returned a malformed ComfyUI read reply.", "MALFORMED_REPLY");
return { ...payload, version: PANEL_IMAGE_RELAY_VERSION, requestId, ok: true, updated: record.updated };
}
function validateReadTransportResponse(value, requestId, secret, operation) {
if (!value || typeof value !== "object") {
throw new PanelComfyUIReadRelayError("The panel returned a malformed ComfyUI read reply.", "MALFORMED_REPLY");
}
const record = value;
const responseMac = record.responseMac;
if (!isSafeRelayCapability(responseMac) || !hasOwn(record, "responseMac")) {
throw new PanelComfyUIReadRelayError("The panel returned an unauthenticated ComfyUI read reply.", "MALFORMED_REPLY");
}
const { responseMac: ignored, ...unsigned } = record;
const response = validateReadResponse(unsigned, requestId, operation);
const expected = createHmac("sha256", secret).update(responseMacPayload(response)).digest("hex");
if (!timingSafeEqual(Buffer.from(responseMac, "hex"), Buffer.from(expected, "hex"))) {
throw new PanelComfyUIReadRelayError("The panel returned an unauthenticated ComfyUI read reply.", "MALFORMED_REPLY");
}
return response;
}
function validateTransportResponse(value, requestId, secret) {
if (!value || typeof value !== "object") {
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
}
const record = value;
const responseMac = record.responseMac;
if (!isSafeRelayCapability(responseMac) || !hasOwn(record, "responseMac")) {
throw new PanelImageRelayError("The panel returned an unauthenticated image relay reply.", "MALFORMED_REPLY");
}
const { responseMac: ignored, ...unsigned } = record;
const response = validateResponse(unsigned, requestId);
const expected = createHmac("sha256", secret).update(responseMacPayload(response)).digest("hex");
if (!timingSafeEqual(Buffer.from(responseMac, "hex"), Buffer.from(expected, "hex"))) {
throw new PanelImageRelayError("The panel returned an unauthenticated image relay reply.", "MALFORMED_REPLY");
}
return response;
}
function relayEndpoint() {
const raw = process.env.COMFYUI_MCP_RELAY_URL?.trim();
if (!raw)
return undefined;
try {
const url = new URL(raw);
if (url.protocol !== "http:" ||
url.hostname !== "127.0.0.1" ||
!url.port ||
Number(url.port) < 1 ||
Number(url.port) > 65535 ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== PANEL_IMAGE_RELAY_HTTP_PATH)
return undefined;
return url;
}
catch {
return undefined;
}
}
async function readHttpResponseBounded(response, maxBytes) {
const declared = response.headers.get("content-length");
if (declared !== null) {
const length = Number(declared);
if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes) {
throw new PanelImageRelayError("The panel relay response exceeded its safety limit.", "RESPONSE_TOO_LARGE");
}
}
if (!response.body) {
const text = await response.text();
if (Buffer.byteLength(text, "utf8") > maxBytes) {
throw new PanelImageRelayError("The panel relay response exceeded its safety limit.", "RESPONSE_TOO_LARGE");
}
return JSON.parse(text);
}
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel();
throw new PanelImageRelayError("The panel relay response exceeded its safety limit.", "RESPONSE_TOO_LARGE");
}
chunks.push(Buffer.from(value));
}
}
finally {
reader.releaseLock();
}
return JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
}
function requestDeadline(request) {
return Math.min(request.deadlineAt, request.createdAt + requestTimeoutMs(request));
}
function errorCodeFromHttpBody(value, status) {
if (value && typeof value === "object" && typeof value.error === "string") {
const error = value.error;
if (/^[A-Z_]{3,40}$/.test(error))
return error;
}
if (status === 400)
return "MALFORMED_REQUEST";
if (status === 401)
return "UNAUTHORIZED";
if (status === 429)
return "BACKLOG_FULL";
return "RELAY_UNAVAILABLE";
}
function readRequestBody(req, maxBytes) {
const declared = req.headers["content-length"];
if (declared !== undefined) {
const length = Number(declared);
if (!Number.isSafeInteger(length) || length < 0 || length > maxBytes) {
return Promise.reject(new PanelImageRelayError("The relay request exceeded its safety limit.", "REQUEST_TOO_LARGE"));
}
}
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let settled = false;
const finish = (error, value) => {
if (settled)
return;
settled = true;
req.removeListener("data", onData);
req.removeListener("end", onEnd);
req.removeListener("error", onError);
req.removeListener("aborted", onAborted);
req.setTimeout(0);
if (error)
reject(error);
else
resolve(value ?? Buffer.alloc(0));
};
const onData = (chunk) => {
const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += data.byteLength;
if (total > maxBytes) {
req.destroy();
finish(new PanelImageRelayError("The relay request exceeded its safety limit.", "REQUEST_TOO_LARGE"));
return;
}
chunks.push(data);
};
const onEnd = () => finish(undefined, Buffer.concat(chunks, total));
const onError = () => finish(new PanelImageRelayError("The relay request was interrupted.", "RELAY_UNAVAILABLE", true));
const onAborted = () => finish(new PanelImageRelayError("The relay request was interrupted.", "RELAY_UNAVAILABLE", true));
req.on("data", onData);
req.once("end", onEnd);
req.once("error", onError);
req.once("aborted", onAborted);
req.setTimeout(PANEL_IMAGE_RELAY_TIMEOUT_MS + 1_000, () => {
req.destroy();
finish(new PanelImageRelayError("The relay request timed out.", "TIMEOUT"));
});
});
}
function writeHttpJson(res, status, value) {
const body = Buffer.from(JSON.stringify(value), "utf8");
if (body.byteLength > PANEL_IMAGE_RELAY_MAX_HTTP_RESPONSE_BYTES) {
res.writeHead(500, { "content-type": "application/json", "content-length": "0" });
res.end();
return;
}
res.writeHead(status, {
"content-type": "application/json",
"content-length": String(body.byteLength),
connection: "close",
});
res.end(body);
}
async function withinDeadline(promise, deadlineAt) {
const remaining = deadlineAt - Date.now();
if (remaining <= 0)
throw new PanelImageRelayError("The panel image relay timed out.", "TIMEOUT");
let timer;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new PanelImageRelayError("The panel image relay timed out.", "TIMEOUT")), remaining);
timer.unref?.();
}),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
/**
* The production child/orchestrator channel. It binds only to IPv4 loopback,
* authenticates the reference before resolving an agent, and never reads the
* shared progress directory. The file poller below is retained solely for
* legacy/unit coverage and is not wired into the orchestrator.
*/
export async function startPanelImageRelayServer(options) {
let active = 0;
const server = createServer((req, res) => {
void (async () => {
if (req.method !== "POST" || req.url !== PANEL_IMAGE_RELAY_HTTP_PATH) {
writeHttpJson(res, 404, { ok: false, error: "NOT_FOUND" });
return;
}
if (active >= PANEL_IMAGE_RELAY_MAX_CONCURRENT) {
writeHttpJson(res, 429, { ok: false, error: "BACKLOG_FULL" });
return;
}
active += 1;
try {
let body;
try {
body = await readRequestBody(req, PANEL_IMAGE_RELAY_MAX_HTTP_REQUEST_BYTES);
}
catch (error) {
const status = error instanceof PanelImageRelayError && error.code === "REQUEST_TOO_LARGE" ? 413 : 408;
writeHttpJson(res, status, { ok: false, error: error instanceof PanelImageRelayError ? error.code : "MALFORMED_REQUEST" });
return;
}
let raw;
try {
raw = JSON.parse(body.toString("utf8"));
}
catch {
writeHttpJson(res, 400, { ok: false, error: "MALFORMED_REQUEST" });
return;
}
const rawRecord = raw && typeof raw === "object" ? raw : undefined;
const requestId = rawRecord?.requestId;
const request = isSafeRelayId(requestId)
? rawRecord && hasOwn(rawRecord, "operation")
? validateReadRequest(raw, requestId)
: validateRequest(raw, requestId)
: undefined;
if (!request) {
writeHttpJson(res, 400, { ok: false, error: "MALFORMED_REQUEST" });
return;
}
const auth = options.resolvePanelAgent(request);
if (!auth || !isSafeRelaySecret(auth.secret)) {
writeHttpJson(res, 401, { ok: false, error: "UNAUTHORIZED" });
return;
}
const now = Date.now();
const age = now - request.createdAt;
let response;
if (age < -5_000 || age > PANEL_IMAGE_RELAY_STALE_MS || now >= requestDeadline(request)) {
response = failureResponse(request.requestId, now >= requestDeadline(request) ? "TIMEOUT" : "STALE_REQUEST", now >= requestDeadline(request) ? requestDeadline(request) : now);
}
else if (!relayTargetMatches(request, currentRelayTarget(options))) {
// A child can remain on the previous target while its agent turn drains
// across a retarget. Never let that authenticated child address the new
// live panel: the capability proves ownership, not target freshness.
response = failureResponse(request.requestId, "STALE_TARGET");
}
else {
const panelTab = options.resolvePanelTab(auth.agentKey);
if (!panelTab || !options.bridge.canReach(panelTab)) {
response = failureResponse(request.requestId, options.bridge.resolveFailure?.(auth.agentKey) === "ambiguous" ? "AMBIGUOUS_REQUESTER" : "NO_LIVE_PANEL");
}
else if (!relayTargetsMatch(options, request, panelTab)) {
// The global target fence does not prove that this particular tab
// fronts the child target. Missing, stale, or ambiguous tab proof
// must refuse before the targetless bridge command is dispatched.
response = failureResponse(request.requestId, "STALE_TARGET");
}
else {
const remainingMs = requestDeadline(request) - Date.now();
if (remainingMs <= 0) {
response = failureResponse(request.requestId, "TIMEOUT", requestDeadline(request));
}
else if (!relayTargetsMatch(options, request, panelTab)) {
response = failureResponse(request.requestId, "STALE_TARGET");
}
else {
try {
const reply = await withinDeadline(options.bridge.send(isReadRelayRequest(request)
? { cmd: "fetch_comfyui_read", operation: request.operation }
: { cmd: "fetch_image", filename: request.filename, subfolder: request.subfolder, type: request.type }, { tabId: panelTab, timeoutMs: Math.min(requestTimeoutMs(request), remainingMs) }), requestDeadline(request));
const replyRecord = reply && typeof reply === "object" ? reply : undefined;
const imagePayload = !isReadRelayRequest(request) && replyRecord
? validateImagePayload({ base64: replyRecord.base64, mimeType: replyRecord.mimeType, bytes: replyRecord.bytes })
: undefined;
if (!relayTargetsMatch(options, request, panelTab)) {
response = failureResponse(request.requestId, "STALE_TARGET");
}
else if (Date.now() >= requestDeadline(request)) {
response = failureResponse(request.requestId, "TIMEOUT", requestDeadline(request));
}
else if (isReadRelayRequest(request)) {
const payload = replyRecord ? validateReadPayload(replyRecord) : undefined;
if (!payload || payload.operation !== request.operation) {
response = failureResponse(request.requestId, "MALFORMED_REPLY");
}
else {
response = {
version: PANEL_IMAGE_RELAY_VERSION,
requestId: request.requestId,
ok: true,
...payload,
updated: Date.now(),
};
}
}
else if (replyRecord?.ok === false && hasOnlyKeys(replyRecord, ["ok", "error"]) && isSafeText(replyRecord.error, 160)) {
response = failureResponse(request.requestId, "PANEL_FETCH_FAILED");
}
else if (!replyRecord || replyRecord.ok !== true || !imagePayload || !hasOnlyKeys(replyRecord, ["ok", "base64", "mimeType", "bytes"])) {
response = failureResponse(request.requestId, "MALFORMED_REPLY");
}
else {
response = { version: PANEL_IMAGE_RELAY_VERSION, requestId: request.requestId, ok: true, ...imagePayload, updated: Date.now() };
}
}
catch (error) {
const timedOut = error instanceof PanelImageRelayError && error.code === "TIMEOUT";
response = failureResponse(request.requestId, timedOut ? "TIMEOUT" : "PANEL_FETCH_FAILED", timedOut ? requestDeadline(request) : Date.now(),
// #2703 - TIMEOUT already says what happened and by when; only
// PANEL_FETCH_FAILED is the code that stands for many causes,
// so only it needs the sentence.
timedOut ? undefined : panelFailureReason(error));
}
}
}
}
writeHttpJson(res, 200, addResponseMac(auth.secret, response));
}
catch {
if (!res.headersSent)
writeHttpJson(res, 503, { ok: false, error: "RELAY_UNAVAILABLE" });
}
finally {
active -= 1;
}
})();
});
server.headersTimeout = 2_000;
server.requestTimeout = PANEL_COMFYUI_READ_OBJECT_INFO_TIMEOUT_MS + 1_000;
await new Promise((resolve, reject) => {
const onError = (error) => {
server.removeListener("listening", onListening);
reject(error);
};
const onListening = () => {
server.removeListener("error", onError);
resolve();
};
server.once("error", onError);
server.once("listening", onListening);
server.listen({ host: "127.0.0.1", port: 0 });
});
const address = server.address();
if (!address || typeof address === "string" || address.address !== "127.0.0.1" || !address.port) {
await new Promise((resolve) => server.close(() => resolve()));
throw new Error("panel image relay did not bind to IPv4 loopback");
}
return {
endpointUrl: `http://127.0.0.1:${address.port}${PANEL_IMAGE_RELAY_HTTP_PATH}`,
close: () => new Promise((resolve) => {
server.close(() => resolve());
server.closeIdleConnections?.();
}),
};
}
/** Child-side production request over the authenticated loopback HTTP channel. */
export async function requestPanelImage(filename, type, subfolder) {
if (!isSafePanelImageRef(filename, subfolder, type)) {
throw new PanelImageRelayError("The image reference is unsafe and was refused.", "UNSAFE_REFERENCE");
}
const endpoint = relayEndpoint();
const secret = process.env.COMFYUI_MCP_RELAY_SECRET;
if (!endpoint || !isSafeRelaySecret(secret))
return undefined;
const target = relayTargetFromEnv();
if (!target)
return undefined;
const createdAt = Date.now();
const request = {
version: PANEL_IMAGE_RELAY_VERSION,
requestId: `${Date.now().toString(36)}-${process.pid.toString(36)}-${randomBytes(8).toString("hex")}`,
capability: "",
targetUrl: target.url,
targetGeneration: target.generation,
filename,
subfolder,
type,
createdAt,
deadlineAt: createdAt + PANEL_IMAGE_RELAY_TIMEOUT_MS,
};
request.capability = makePanelImageRelayCapability(secret, request);
const body = Buffer.from(JSON.stringify(request), "utf8");
if (body.byteLength > PANEL_IMAGE_RELAY_MAX_HTTP_REQUEST_BYTES) {
throw new PanelImageRelayError("The relay request exceeded its safety limit.", "REQUEST_TOO_LARGE");
}
let httpResponse;
try {
httpResponse = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json", "content-length": String(body.byteLength) },
body,
redirect: "error",
signal: AbortSignal.timeout(PANEL_IMAGE_RELAY_TIMEOUT_MS),
});
}
catch (error) {
if (error instanceof PanelImageRelayError)
throw error;
if (error instanceof DOMException && error.name === "TimeoutError") {
throw new PanelImageRelayError("The connected panel image relay timed out.", "TIMEOUT");
}
throw new PanelImageRelayError("The connected panel relay is unavailable.", "RELAY_UNAVAILABLE", true);
}
let decoded;
try {
decoded = await readHttpResponseBounded(httpResponse, PANEL_IMAGE_RELAY_MAX_HTTP_RESPONSE_BYTES);
}
catch (error) {
if (error instanceof PanelImageRelayError)
throw error;
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
}
if (!httpResponse.ok) {
const code = errorCodeFromHttpBody(decoded, httpResponse.status);
throw new PanelImageRelayError(code === "RELAY_UNAVAILABLE" ? "The connected panel relay is unavailable." : `The connected panel image relay failed (${code}).`, code, httpResponse.status >= 500);
}
const response = validateTransportResponse(decoded, request.requestId, secret);
const responseAge = Date.now() - response.updated;
const authenticatedTimeout = response.ok === false && response.error === "TIMEOUT";
if (!authenticatedTimeout &&
(response.updated < request.createdAt ||
response.updated > request.deadlineAt ||
responseAge < -5_000 ||
responseAge > PANEL_IMAGE_RELAY_STALE_MS)) {
throw new PanelImageRelayError("The panel returned a stale image relay reply.", "STALE_REPLY");
}
if (response.ok === false)
responseFailureMessage(response);
return { base64: response.base64, mimeType: response.mimeType, bytes: response.bytes };
}
/** Child-side production request for one fixed ComfyUI read over the same
* authenticated loopback channel as the image relay. */
export async function requestPanelComfyUIRead(operation) {
if (!isPanelComfyUIReadOperation(operation)) {
throw new PanelComfyUIReadRelayError("The ComfyUI read operation was refused.", "UNSAFE_OPERATION");
}
const endpoint = relayEndpoint();
const secret = process.env.COMFYUI_MCP_RELAY_SECRET;
if (!endpoint || !isSafeRelaySecret(secret))
return undefined;
const target = relayTargetFromEnv();
if (!target)
return undefined;
const createdAt = Date.now();
const timeoutMs = readTimeoutMs(operation);
const request = {
version: PANEL_IMAGE_RELAY_VERSION,
requestId: `${Date.now().toString(36)}-${process.pid.toString(36)}-${randomBytes(8).toString("hex")}`,
capability: "",
targetUrl: target.url,
targetGeneration: target.generation,
operation,
createdAt,
deadlineAt: createdAt + timeoutMs,
};
request.capability = makePanelComfyUIReadRelayCapability(secret, request);
const body = Buffer.from(JSON.stringify(request), "utf8");
if (body.byteLength > PANEL_IMAGE_RELAY_MAX_HTTP_REQUEST_BYTES) {
throw new PanelComfyUIReadRelayError("The relay request exceeded its safety limit.", "REQUEST_TOO_LARGE");
}
let httpResponse;
try {
httpResponse = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json", "content-length": String(body.byteLength) },
body,
redirect: "error",
signal: AbortSignal.timeout(timeoutMs),
});
}
catch (error) {
if (error instanceof PanelComfyUIReadRelayError)
throw error;
if (error instanceof DOMException && error.name === "TimeoutError") {
throw new PanelComfyUIReadRelayError("The connected panel read relay timed out.", "TIMEOUT");
}
throw new PanelComfyUIReadRelayError("The connected panel read relay is unavailable.", "RELAY_UNAVAILABLE", true);
}
let decoded;
try {
decoded = await readHttpResponseBounded(httpResponse, PANEL_IMAGE_RELAY_MAX_HTTP_RESPONSE_BYTES);
}
catch (error) {
if (error instanceof PanelComfyUIReadRelayError)
throw error;
throw new PanelComfyUIReadRelayError("The panel returned a malformed ComfyUI read reply.", "MALFORMED_REPLY");
}
if (!httpResponse.ok) {
const code = errorCodeFromHttpBody(decoded, httpResponse.status);
throw new PanelComfyUIReadRelayError(code === "RELAY_UNAVAILABLE" ? "The connected panel read relay is unavailable." : `The connected panel read relay failed (${code}).`, code, httpResponse.status >= 500);
}
const response = validateReadTransportResponse(decoded, request.requestId, secret, operation);
const responseAge = Date.now() - response.updated;
const authenticatedTimeout = response.ok === false && response.error === "TIMEOUT";
if (!authenticatedTimeout &&
(response.updated < request.createdAt ||
response.updated > request.deadlineAt ||
responseAge < -5_000 ||
responseAge > PANEL_IMAGE_RELAY_STALE_MS)) {
throw new PanelComfyUIReadRelayError("The panel returned a stale ComfyUI read reply.", "STALE_REPLY");
}
if (response.ok === false) {
const known = new Set([
"AMBIGUOUS_REQUESTER",
"BACKLOG_FULL",
"MALFORMED_REPLY",
"NO_LIVE_PANEL",
"PANEL_FETCH_FAILED",
"STALE_TARGET",
"STALE_REQUEST",
"TIMEOUT",
]);
const code = known.has(response.error) ? response.error : "PANEL_FETCH_FAILED";
// #2703 - see responseFailureMessage: validFailureReason is where "only
// PANEL_FETCH_FAILED carries a reason" is enforced, on the way in.
const reason = response.reason;
throw new PanelComfyUIReadRelayError(code === "PANEL_FETCH_FAILED"
? `The connected panel could not read ComfyUI.${reasonSuffix(reason)}`
: `The connected panel ComfyUI read relay failed (${code}).`, code, false, reason);
}
return {
operation: response.operation,
body: response.body,
contentType: response.contentType,
bytes: response.bytes,
};
}
/** Legacy file-channel request writer, retained for focused/unit coverage only. */
export async function requestPanelImageFromFileChannel(filename, type, subfolder) {
const dir = process.env.COMFYUI_MCP_PROGRESS_DIR?.trim() ?? "";
const secret = process.env.COMFYUI_MCP_RELAY_SECRET;
if (!dir || !isSafeRelaySecret(secret))
return undefined;
const target = relayTargetFromEnv();
if (!target)
return undefined;
if (!isSafePanelImageRef(filename, subfolder, type)) {
throw new PanelImageRelayError("The image reference is unsafe and was refused.", "UNSAFE_REFERENCE");
}
let dirStat;
try {
dirStat = await lstat(dir);
}
catch {
return undefined;
}
if (!dirStat.isDirectory())
return undefined;
const requestId = `${Date.now().toString(36)}-${process.pid.toString(36)}-${randomBytes(8).toString("hex")}`;
const createdAt = Date.now();
const request = {
version: PANEL_IMAGE_RELAY_VERSION,
requestId,
capability: "",
targetUrl: target.url,
targetGeneration: target.generation,
filename,
subfolder,
type,
createdAt,
deadlineAt: createdAt + PANEL_IMAGE_RELAY_TIMEOUT_MS,
};
request.capability = makePanelImageRelayCapability(secret, request);
const requestFile = requestPath(dir, requestId);
const responseStagingDir = await createPrivateStagingDir();
if (!responseStagingDir) {
throw new PanelImageRelayError("The connected panel relay is unavailable.", "REQUEST_WRITE_FAILED", true);
}
try {
writeJsonAtomically(requestFile, request, PANEL_IMAGE_RELAY_MAX_REQUEST_FILE_BYTES);
}
catch {
await removeStagedEntry(responseStagingDir);
throw new PanelImageRelayError("The connected panel relay is unavailable.", "REQUEST_WRITE_FAILED", true);
}
const deadline = request.deadlineAt;
try {
while (Date.now() < deadline) {
const stagedResponsePath = await handoffResponse(dir, requestId, responseStagingDir);
if (stagedResponsePath) {
if (Date.now() >= deadline) {
await removeStagedEntry(stagedResponsePath);
throw new PanelImageRelayError("The connected panel image relay timed out.", "TIMEOUT");
}
let response;
try {
response = validateResponse(await readBoundedJson(stagedResponsePath, PANEL_IMAGE_RELAY_MAX_RESPONSE_FILE_BYTES), requestId);
}
catch (error) {
await removeStagedEntry(stagedResponsePath);
if (error instanceof PanelImageRelayError)
throw error;
throw new PanelImageRelayError("The panel returned a malformed image relay reply.", "MALFORMED_REPLY");
}
await removeStagedEntry(stagedResponsePath);
if (Date.now() >= deadline) {
throw new PanelImageRelayError("The connected panel image relay timed out.", "TIMEOUT");
}
const responseAge = Date.now() - response.updated;
if (response.updated < request.createdAt ||
response.updated > request.deadlineAt ||
responseAge < -5_000 ||
responseAge > PANEL_IMAGE_RELAY_STALE_MS) {
throw new PanelImageRelayError("The panel returned a stale image relay reply.", "STALE_REPLY");
}
if (response.ok === false)
responseFailureMessage(response);
return { base64: response.base64, mimeType: response.mimeType, bytes: response.bytes };
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new PanelImageRelayError("The connected panel image relay timed out.", "TIMEOUT");
}
finally {
try {
unlinkSync(requestFile);
}
catch { /* orchestrator may already have consumed it */ }
await removeStagedEntry(responseStagingDir);
}
}
async function matchingFiles(dir, prefix, limit) {
let handle;
try {
handle = await opendir(dir);
}
catch {
return [];
}
const files = [];
let scanned = 0;
try {
while (scanned < PANEL_IMAGE_RELAY_MAX_DIRECTORY_ENTRIES_PER_TICK && files.length < limit) {
const entry = await handle.read();
if (!entry)
break;
scanned += 1;
if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith(".json"))
continue;
const id = entry.name.slice(prefix.length, -".json".length);
if (isSafeRelayId(id))
files.push(entry.name);
}
}
finally {
await handle.close().catch(() => undefined);
}
return files;
}
async function reapStaleResponseFiles(dir, now) {
const files = await matchingFiles(dir, PANEL_IMAGE_RELAY_RESPONSE_PREFIX, PANEL_IMAGE_RELAY_MAX_REQUESTS_PER_TICK);
for (const name of files) {
const path = join(dir, name);
try {
const stat = await lstat(path);
if (!stat.isFile() || stat.size > PANEL_IMAGE_RELAY_MAX_RESPONSE_FILE_BYTES || now - stat.mtimeMs > PANEL_IMAGE_RELAY_STALE_MS) {
await unlink(path);
}
}
catch {
// A child may be atomically replacing or consuming the response.
}
}
}
async function processOneRequest(dir, file, bridge, stagingDir, resolvePanelAgentKey, resolvePanelTab) {
const requestId = file.slice(PANEL_IMAGE_RELAY_REQUEST_PREFIX.length, -".json".length);
const stagedPath = await handoffRequest(dir, file, stagingDir);
if (!stagedPath)
return;
let request;
try {
request = validateRequest(await readBoundedJson(stagedPath, PANEL_IMAGE_RELAY_MAX_REQUEST_FILE_BYTES), requestId);
}
catch {
request = undefined;
}
if (!request) {
try {
writeJsonAtomically(responsePath(dir, requestId), failureResponse(requestId, "MALFORMED_REQUEST"), PANEL_IMAGE_RELAY_MAX_RESPONSE_FILE_BYTES);
}
catch { /* child will time out safely */ }
await removeStagedEntry(stagedPath);
return;
}
const now = Date.now();
const age = now - request.createdAt;
if (age < -5_000 || age > PANEL_IMAGE_RELAY_STALE_MS || now >= request.deadlineAt) {
try {
writeJsonAtomically(responsePath(dir, requestId), failureResponse(requestId, now >= request.deadlineAt ? "TIMEOUT" : "STALE_REQUEST"), PANEL_IMAGE_RELAY_MAX_RESPONSE_FILE_BYTES);
}
catch { /* best effort */ }
await removeStagedEntry(stagedPath);
return;
}
let response;
const agentKey = resolvePanelAgentKey(request);
const panelTab = agentKey ? resolvePanelTab(agentKey) : undefined;
if (!panelTab || !bridge.canReach(panelTab)) {
const failure = agentKey ? bridge.resolveFailure?.(agentKey) : undefined;
response = failureResponse(requestId, failure === "ambiguous" ? "AMBIGUOUS_REQUESTER" : "NO_LIVE_PANEL");
}
else {
const remainingMs = request.deadlineAt - Date.now();
if (remainingMs <= 0) {
response = failureResponse(requestId, "TIMEOUT");
}
else {
try {
const reply = await bridge.send({ cmd: "fetch_image", filename: request.filename, subfolder: request.subfolder, type: request.type }, { tabId: panelTab, timeoutMs: Math.min(PANEL_IMAGE_RELAY_TIMEOUT_MS, remainingMs) });
const payload = reply && typeof reply === "object"
? validateImagePayload({
base64: reply.base64,
mimeType: reply.mimeType,
bytes: reply.bytes,
})
: undefined;
const replyRecord = reply && typeof reply === "object" ? reply : undefined;
if (Date.now() >= request.deadlineAt) {
response = failureResponse(requestId, "TIMEOUT");
}
else if (replyRecord?.ok === false &&
hasOnlyKeys(replyRecord, ["ok", "error"]) &&
isSafeText(replyRecord.error, 160)) {
response = failureResponse(requestId, "PANEL_FETCH_FAILED");
}
else if (!replyRecord ||
replyRecord.ok !== true ||
!payload ||
!hasOnlyKeys(replyRecord, ["ok", "base64", "mimeType", "bytes"])) {
response = failureResponse(requestId, "MALFORMED_REPLY");
}
else {
response = {
version: PANEL_IMAGE_RELAY_VERSION,
requestId,
ok: true,
...payload,
updated: Date.now(),
};
}
}
catch {
response = failureResponse(requestId, Date.now() >= request.deadlineAt ? "TIMEOUT" : "PANEL_FETCH_FAILED");
}
}
}
try {
writeJsonAtomically(responsePath(dir, requestId), response, PANEL_IMAGE_RELAY_MAX_RESPONSE_FILE_BYTES);
}
catch {
// A bounded response that cannot be written is a failed relay; never retry
// the request with a different target or fall back to a child-supplied URL.
}
await removeStagedEntry(stagedPath);
}
function rejectQueuedRequest(dir, file) {
const requestId = file.slice(PANEL_IMAGE_RELAY_REQUEST_PREFIX.length, -".json".length);
try {
writeJsonAtomically(responsePath(dir, requestId), failureResponse(requestId, "BACKLOG_FULL"), PANEL_IMAGE_RELAY_MAX_RESPONSE_FILE_BYTES);
}
catch {
// The child will time out safely if the bounded failure cannot be published.
}
try {
unlinkSync(join(dir, file));
}
catch { /* best effort */ }
}
/** Orchestrator-side poll worker. Requests are consumed exactly once. */
export async function processPanelImageRequests({ dir, bridge, resolvePanelAgentKey, resolvePanelTab, inFlight = new Set(), }) {
if (!dir)
return;
let dirStat;
try {
dirStat = await lstat(dir);
}
catch {
return;
}
if (!dirStat.isDirectory())
return;
await reapStaleResponseFiles(dir, Date.now());
const pendingResponseCount = (await matchingFiles(dir, PANEL_IMAGE_RELAY_RESPONSE_PREFIX, PANEL_IMAGE_RELAY_MAX_PENDING_RESPONSES + 1)).length;
const responseCapacity = Math.max(0, PANEL_IMAGE_RELAY_MAX_PENDING_RESPONSES - pendingResponseCount);
const candidates = await matchingFiles(dir, PANEL_IMAGE_RELAY_REQUEST_PREFIX, PANEL_IMAGE_RELAY_MAX_DIRECTORY_ENTRIES_PER_TICK);
const pending = candidates.filter((file) => !inFlight.has(file));
const pendingCapacity = Math.max(0, PANEL_IMAGE_RELAY_MAX_PENDING_REQUESTS - inFlight.size);
for (const file of pending.slice(pendingCapacity))
rejectQueuedRequest(dir, file);
const available = Math.min(PANEL_IMAGE_RELAY_MAX_REQUESTS_PER_TICK, PANEL_IMAGE_RELAY_MAX_CONCURRENT - inFlight.size, pendingCapacity, responseCapacity);
if (available <= 0)
return;
const stagingDir = await createPrivateStagingDir();
if (!stagingDir)
return;
try {
const work = pending.slice(0, available).map(async (file) => {
inFlight.add(file);
try {
await processOneRequest(dir, file, bridge, stagingDir, resolvePanelAgentKey, resolvePanelTab);
}
finally {
inFlight.delete(file);
}
});
await Promise.all(work);
}
finally {
await removeStagedEntry(stagingDir);
}
}
//# sourceMappingURL=panel-image-relay.js.map