UNPKG

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
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