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

318 lines • 12.5 kB
/** * 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