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),
318 lines • 12.5 kB
JavaScript
/**
* Cross-process handoff for apply_manifest's outstanding custom-node outcome.
*
* apply_manifest normally runs in a panel agent's stdio child while
* panel_node_queue_status runs in the orchestrator. The old process-local
* manifest-partial record therefore could never reach the queue-status reader.
* This channel uses the already nonce-scoped, mode-0700 progress directory,
* but adds a per-child HMAC so a stray file or an untrusted payload cannot turn
* into an annotation. Writes are atomic: readers see either the previous
* complete record or the next complete record, never a partial JSON document.
*/
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
import { join } from "node:path";
// The operation and target-generation fields make this incompatible with the
// pre-follow-up envelope; stale version-1 files must never be interpreted here.
export const MANIFEST_OUTCOME_CHANNEL_VERSION = 2;
export const MANIFEST_OUTCOME_TTL_MS = 6 * 60 * 60 * 1000;
const FILE_PREFIX = "cmcp-manifest-outcome-";
const MAX_RECORD_BYTES = 128 * 1024;
const MAX_OPERATION_ID_LENGTH = 128;
const MAX_SCOPE_LENGTH = 512;
const MAX_TARGET_LENGTH = 2048;
const MAX_SOURCE_LENGTH = 1024;
const MAX_MESSAGE_LENGTH = 16 * 1024;
const MAX_ITEM_LENGTH = 1024;
const MAX_ITEMS = 512;
let readerConfig;
let writeSequence = 0;
/** Configure the orchestrator-side reader. The callback keeps the scoped
* credentials live as new agent sessions are spawned, without exposing secrets
* to a file. A valid secret for another scope is deliberately not enough. */
export function configureManifestOutcomeReader(dir, credentials) {
readerConfig = { dir: dir.trim(), credentials };
}
function configuredWriterDir() {
return process.env.COMFYUI_MCP_PROGRESS_DIR?.trim() ?? "";
}
function configuredWriterSecret() {
return process.env.COMFYUI_MCP_MANIFEST_OUTCOME_SECRET?.trim() ?? "";
}
function configuredWriterScope() {
return process.env.COMFYUI_MCP_TAB?.trim() || `stdio:${process.pid}`;
}
function configuredWriterOperationId() {
return process.env.COMFYUI_MCP_MANIFEST_OPERATION?.trim() || "legacy";
}
function configuredWriterTargetGeneration() {
const value = Number(process.env.COMFYUI_MCP_TARGET_GENERATION);
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
}
/** Keep only the identity-bearing URL components. Query strings may contain
* credentials and are not part of the ComfyUI target identity. */
export function canonicalManifestOutcomeTarget(input) {
if (typeof input !== "string" || input.length === 0 || input.length > MAX_TARGET_LENGTH) {
return null;
}
try {
const url = new URL(input);
if (url.protocol !== "http:" && url.protocol !== "https:")
return null;
if (url.username || url.password || url.search || url.hash)
return null;
const path = url.pathname.replace(/\/+$/, "");
return `${url.protocol}//${url.host}${path}`;
}
catch {
return null;
}
}
function validStringArray(value) {
return (Array.isArray(value) &&
value.length <= MAX_ITEMS &&
value.every((item) => typeof item === "string" && item.length <= MAX_ITEM_LENGTH));
}
function validPartial(value) {
if (value === null || typeof value !== "object" || Array.isArray(value))
return false;
const partial = value;
if (partial.kind !== "custom_nodes_not_started" ||
typeof partial.source !== "string" ||
partial.source.length > MAX_SOURCE_LENGTH ||
typeof partial.message !== "string" ||
partial.message.length > MAX_MESSAGE_LENGTH ||
!validStringArray(partial.not_started) ||
!validStringArray(partial.still_installing)) {
return false;
}
for (const key of ["outcome_unknown", "local_fallback", "local_fallback_failed"]) {
if (partial[key] !== undefined && !validStringArray(partial[key]))
return false;
}
const stillInstalling = partial.still_installing;
const outcomeUnknown = partial.outcome_unknown;
const localFallback = partial.local_fallback;
return ((outcomeUnknown ?? []).every((id) => stillInstalling.includes(id)) &&
(localFallback ?? []).every((id) => stillInstalling.includes(id)));
}
function payloadFor(envelope) {
return JSON.stringify({
version: envelope.version,
operation_id: envelope.operation_id,
scope: envelope.scope,
target: envelope.target,
target_generation: envelope.target_generation,
updated: envelope.updated,
partial: envelope.partial,
});
}
function signatureFor(payload, secret) {
return createHmac("sha256", secret).update(payload).digest("hex");
}
function fileFor(dir, secret, operationId) {
const key = createHash("sha256")
.update(`${secret}\0${operationId}`)
.digest("hex")
.slice(0, 32);
return join(dir, `${FILE_PREFIX}${key}.json`);
}
function copyPartial(partial) {
if (!partial)
return null;
return {
...partial,
not_started: [...partial.not_started],
still_installing: [...partial.still_installing],
...(partial.outcome_unknown ? { outcome_unknown: [...partial.outcome_unknown] } : {}),
...(partial.local_fallback ? { local_fallback: [...partial.local_fallback] } : {}),
...(partial.local_fallback_failed
? { local_fallback_failed: [...partial.local_fallback_failed] }
: {}),
};
}
/** Publish from the stdio child, or from a test that supplies explicit seams. */
export function publishManifestOutcome(partial, options = {}) {
const dir = options.dir?.trim() ?? configuredWriterDir();
const secret = options.secret?.trim() ?? configuredWriterSecret();
const operationId = options.operationId?.trim() ?? configuredWriterOperationId();
const scope = options.scope?.trim() ?? configuredWriterScope();
const targetGeneration = options.targetGeneration ?? configuredWriterTargetGeneration();
const target = canonicalManifestOutcomeTarget(options.target?.trim() || process.env.COMFYUI_URL?.trim() || "");
if (!dir ||
!secret ||
!operationId ||
operationId.length > MAX_OPERATION_ID_LENGTH ||
!scope ||
scope.length > MAX_SCOPE_LENGTH ||
!Number.isSafeInteger(targetGeneration) ||
targetGeneration < 0 ||
!target)
return false;
if (partial !== null && !validPartial(partial))
return false;
const envelopeWithoutSignature = {
version: MANIFEST_OUTCOME_CHANNEL_VERSION,
operation_id: operationId,
scope,
target,
target_generation: targetGeneration,
updated: Date.now(),
partial: copyPartial(partial),
};
const envelope = {
...envelopeWithoutSignature,
signature: signatureFor(payloadFor(envelopeWithoutSignature), secret),
};
const finalPath = fileFor(dir, secret, operationId);
try {
mkdirSync(dir, { recursive: true, mode: 0o700 });
if (partial === null) {
rmSync(finalPath, { force: true });
return true;
}
const tempPath = `${finalPath}.${process.pid}-${writeSequence++}.tmp`;
writeFileSync(tempPath, JSON.stringify(envelope));
try {
renameSync(tempPath, finalPath);
return true;
}
catch {
try {
rmSync(tempPath, { force: true });
}
catch {
/* ignore a stray .tmp; readers only inspect .json */
}
return false;
}
}
catch {
return false;
}
}
function parseEnvelope(raw, secret) {
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
return null;
const envelope = raw;
if (envelope.version !== MANIFEST_OUTCOME_CHANNEL_VERSION ||
typeof envelope.operation_id !== "string" ||
envelope.operation_id.length === 0 ||
envelope.operation_id.length > MAX_OPERATION_ID_LENGTH ||
typeof envelope.scope !== "string" ||
envelope.scope.length === 0 ||
envelope.scope.length > MAX_SCOPE_LENGTH ||
typeof envelope.target !== "string" ||
typeof envelope.target_generation !== "number" ||
!Number.isSafeInteger(envelope.target_generation) ||
envelope.target_generation < 0 ||
typeof envelope.updated !== "number" ||
!Number.isFinite(envelope.updated) ||
typeof envelope.signature !== "string" ||
!/^[a-f0-9]{64}$/.test(envelope.signature) ||
(envelope.partial !== null && !validPartial(envelope.partial))) {
return null;
}
const target = canonicalManifestOutcomeTarget(envelope.target);
if (!target || target !== envelope.target)
return null;
const unsigned = {
version: envelope.version,
operation_id: envelope.operation_id,
scope: envelope.scope,
target: envelope.target,
target_generation: envelope.target_generation,
updated: envelope.updated,
partial: copyPartial(envelope.partial ?? null),
};
const expected = Buffer.from(signatureFor(payloadFor(unsigned), secret), "hex");
const actual = Buffer.from(envelope.signature, "hex");
if (expected.length !== actual.length || !timingSafeEqual(expected, actual))
return null;
return {
...unsigned,
signature: envelope.signature,
};
}
/** Read only records authenticated by the orchestrator and bound to its
* current target. Invalid, stale, or foreign-target records are ignored. */
export function readPublishedManifestOutcomes(target, scope, targetGeneration) {
const cfg = readerConfig;
const expectedTarget = canonicalManifestOutcomeTarget(target);
const expectedScope = scope.trim();
if (!cfg?.dir ||
!expectedTarget ||
!expectedScope ||
expectedScope.length > MAX_SCOPE_LENGTH ||
!Number.isSafeInteger(targetGeneration) ||
targetGeneration < 0) {
return [];
}
let files;
try {
files = readdirSync(cfg.dir).filter((name) => name.startsWith(FILE_PREFIX) && name.endsWith(".json"));
}
catch {
return [];
}
const credentials = [...cfg.credentials()].filter((credential) => typeof credential?.secret === "string" &&
credential.secret.length > 0 &&
typeof credential.scope === "string" &&
credential.scope === expectedScope);
const out = [];
for (const name of files.slice(0, 128)) {
let text;
try {
const path = join(cfg.dir, name);
const stat = readFileSync(path, "utf8");
if (Buffer.byteLength(stat, "utf8") > MAX_RECORD_BYTES)
continue;
text = stat;
}
catch {
continue;
}
let raw;
try {
raw = JSON.parse(text);
}
catch {
continue;
}
for (const credential of credentials) {
const envelope = parseEnvelope(raw, credential.secret);
if (!envelope ||
envelope.scope !== credential.scope ||
envelope.scope !== expectedScope ||
envelope.target !== expectedTarget ||
envelope.target_generation !== targetGeneration)
continue;
if (Date.now() - envelope.updated > MANIFEST_OUTCOME_TTL_MS) {
try {
rmSync(join(cfg.dir, name), { force: true });
}
catch {
/* stale cleanup is best-effort */
}
break;
}
if (envelope.partial)
out.push({ updated: envelope.updated, partial: envelope.partial });
break;
}
}
out.sort((a, b) => b.updated - a.updated);
const seen = new Set();
return out.flatMap(({ partial }) => {
const key = JSON.stringify(partial);
if (seen.has(key))
return [];
seen.add(key);
return [copyPartial(partial)];
});
}
/** Test seam for isolated channel tests. */
export function resetManifestOutcomeReader() {
readerConfig = undefined;
}
//# sourceMappingURL=manifest-outcome-channel.js.map