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),
314 lines • 15.8 kB
JavaScript
// Persisted panel settings for the orchestrator's background agent. Survives
// soft reloads and full restarts (it's a small JSON file on disk), so a setting
// like the adult-content consent gate stays put and is queryable across the
// session — the agent reads it before deciding whether to surface NSFW work.
//
// The NSFW gate is a SAFETY control: it defaults OFF (keep everything SFW), and
// only flips ON after an explicit, verified-adult opt-in (18+ and adult content
// legal in the user's region). It governs what the system SURFACES and records
// the user's consent. It never overrides hard limits (no minors, no real-person
// sexual deepfakes, no depictions of actual non-consensual acts).
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { writeFileDurable } from "../utils/durable-write.js";
import { logger } from "../utils/logger.js";
import { assertNotWritingRealHomeInTests } from "./test-isolation-guard.js";
/** Env override for the pin (wins over the settings file, per env > .env > json). */
export const PANEL_PIN_ENV_VAR = "COMFYUI_MCP_PANEL_PIN";
/** Env values that explicitly assert "no pin", overriding a persisted one. */
const PIN_ENV_OFF = new Set(["off", "none", "no", "0", "false", "unpinned"]);
/** Settings file path. Overridable for tests. */
export function panelSettingsPath() {
return (process.env.COMFYUI_MCP_PANEL_SETTINGS ||
join(homedir(), ".comfyui-mcp", "panel-settings.json"));
}
/**
* Read the settings file, reporting whether the read was CONCLUSIVE.
*
* `unreadable` is true only when the file exists but could not be read/parsed —
* i.e. `{}` here is NOT proof that a key is unset. Most callers don't care (a
* missing NSFW consent and an unreadable one both mean "not consented"), but the
* panel pin does: an unreadable file must not be reported as "no pin".
*/
function readRaw() {
const p = panelSettingsPath();
if (!existsSync(p))
return { settings: {}, unreadable: false };
try {
const raw = readFileSync(p, "utf-8");
// A zero-byte file is the signature of a torn write (crash between
// truncate and write). It provably contains NO settings — treating it as
// unreadable would report an indeterminate pin (which counts as PINNED)
// forever, over a file that holds nothing. Non-empty unparseable content
// stays unreadable: it could have held a pin, so it must fail closed.
if (raw.length === 0)
return { settings: {}, unreadable: false };
const parsed = JSON.parse(raw);
// Valid JSON but not a plain object (`null`, `"x"`, and CRUCIALLY `[]`):
// the file is present and structurally wrong, so a key's absence is not
// proven. An array is the nasty one — `typeof [] === "object"`, so an
// unguarded check would accept it, report "no pin" on a file we never
// understood, and then silently DROP a pin written to it (an expando
// property on an array does not survive JSON.stringify).
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? { settings: parsed, unreadable: false }
: { settings: {}, unreadable: true };
}
catch (err) {
logger.warn(`[panel-settings] could not parse ${p}: ${err instanceof Error ? err.message : String(err)}`);
return { settings: {}, unreadable: true };
}
}
function read() {
return readRaw().settings;
}
function write(settings) {
const p = panelSettingsPath();
// This file carries the panel version pin — a test run writing the real one
// changes what updates a live install will accept. Refuse at the write.
assertNotWritingRealHomeInTests(p, "the panel settings");
mkdirSync(dirname(p), { recursive: true });
// Durable AND atomic (#798): this file carries the panel version PIN — a
// protection record the user is told is in force the moment this returns. A
// buffered write can be lost to a crash while the pin-write's report (and
// the user's belief) survives, leaving the panel unpinned when everyone
// thinks it is pinned; a torn write would read as unreadable, which the pin
// reader must treat as indeterminate (pinned) forever. Write to a temp file,
// fsync, and rename over the target so neither can happen.
try {
writeFileDurable(p, JSON.stringify(settings, null, 2));
}
catch (err) {
// The write MAY have landed (the atomic rename can precede a directory
// sync failure), so a bare failure would misreport a possibly-in-force
// pin as absent. Say exactly what is and is not proven.
throw new Error(`Could not durably write the panel settings at ${p}: ${err instanceof Error ? err.message : String(err)}. The change may still have landed on disk without a proven-durable ` +
`directory entry — re-read the setting before assuming it did not take.`);
}
}
/** Current NSFW consent state. Defaults to OFF when never set.
*
* FAIL-CLOSED: `read()` casts arbitrary on-disk JSON, so a tampered or
* legacy/corrupt settings file could carry a non-boolean `allowed` (e.g. the
* truthy STRING "false", 1, "true"). Adult content must be enabled ONLY on a
* strict boolean `true`; every other value is treated as NOT consented. We also
* normalize `decidedAt` to a string-or-undefined so callers never see junk. */
export function getNsfwConsent() {
const raw = read().nsfwConsent;
const allowed = raw?.allowed === true;
const decidedAt = typeof raw?.decidedAt === "string" ? raw.decidedAt : undefined;
return decidedAt === undefined ? { allowed } : { allowed, decidedAt };
}
/**
* Persist an NSFW consent decision. `allowed` true ONLY after a verified-adult
* opt-in; false revokes. Stamps the decision time.
*/
export function setNsfwConsent(allowed) {
const decidedAt = new Date().toISOString();
const settings = read();
settings.nsfwConsent = { allowed, decidedAt };
write(settings);
return settings.nsfwConsent;
}
// ---------------------------------------------------------------------------
// Panel version pin
// ---------------------------------------------------------------------------
/** Normalize a pin version string; returns undefined for junk/blank. */
function normalizePinVersion(raw) {
if (typeof raw !== "string")
return undefined;
const v = raw.trim();
return v.length > 0 && v.length <= 64 ? v : undefined;
}
/**
* Resolve the ACTIVE panel-version pin.
*
* Precedence mirrors the rest of the project's config (env > `~/.comfyui-mcp/.env`
* > the JSON settings store): `~/.comfyui-mcp/.env` is loaded into `process.env`
* at boot, so a single `process.env` check covers both env layers.
* `COMFYUI_MCP_PANEL_PIN=off` (or none/no/0/false/unpinned) is an explicit
* "no pin" that overrides a persisted one — the env escape hatch when the
* settings file can't be edited.
*
* Never throws: a pin we cannot read is reported `indeterminate`, which callers
* MUST treat as pinned.
*/
export function getPanelPinState(env = process.env) {
const rawEnv = env[PANEL_PIN_ENV_VAR];
// PRESENCE, not truthiness-after-trim. A whitespace-only value (" ") is a
// SET variable we cannot interpret; trimming first and falling through made it
// resolve `unpinned` and let the mutation proceed — the same present-but-
// unreadable fail-open as the settings file. An EMPTY value is treated as
// unset, which is the ordinary meaning of `FOO=` and of an unset dotenv key.
if (typeof rawEnv === "string" && rawEnv.length > 0) {
const trimmed = rawEnv.trim();
if (PIN_ENV_OFF.has(trimmed.toLowerCase())) {
// The explicit no-pin escape hatch, and the ONLY way an env value clears.
return { pinned: false, source: "none" };
}
const version = normalizePinVersion(trimmed);
if (version)
return { pinned: true, version, source: "env" };
// Present but unusable (whitespace-only, or junk) — we cannot tell what the
// user meant, so we do NOT fall through to "unpinned".
return { pinned: true, source: "env", indeterminate: true };
}
const { settings, unreadable } = readRaw();
if (unreadable)
return { pinned: true, source: "settings", indeterminate: true };
// ONLY an absent key means unpinned. A key that is PRESENT but malformed
// (`null`, `false`, `0`, `"0.11.3"`, `[]`) is somebody's hand-edit that we
// failed to understand — reading it as "no pin" would move a user who
// believed they were pinned. Present-but-unreadable resolves to pinned.
if (!("panelPin" in settings) || settings.panelPin === undefined) {
return { pinned: false, source: "none" };
}
const raw = settings.panelPin;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { pinned: true, source: "settings", indeterminate: true };
}
const version = normalizePinVersion(raw.version);
if (!version) {
// A `panelPin` key exists but its version is junk: something pinned this
// install, so refuse to move it rather than guessing it away.
return { pinned: true, source: "settings", indeterminate: true };
}
const state = { pinned: true, version, source: "settings" };
if (typeof raw.pinnedAt === "string")
state.pinnedAt = raw.pinnedAt;
if (typeof raw.reason === "string")
state.reason = raw.reason;
return state;
}
/** Human-readable one-liner for a pin state, for messages the user reads. */
export function describePanelPin(pin) {
if (!pin.pinned)
return "not pinned";
if (pin.indeterminate) {
return pin.source === "env"
? `pinned via ${PANEL_PIN_ENV_VAR}, but its value is unusable`
: `possibly pinned — ${panelSettingsPath()} could not be read`;
}
const where = pin.source === "env" ? `${PANEL_PIN_ENV_VAR} env var` : panelSettingsPath();
return `pinned to ${pin.version} (via ${where})${pin.reason ? ` — ${pin.reason}` : ""}`;
}
function assertSettingsWritable() {
const { settings, unreadable } = readRaw();
if (unreadable) {
throw new Error(`Refusing to rewrite ${panelSettingsPath()}: it exists but could not be ` +
`parsed, so writing would silently discard whatever else is in it. Fix or ` +
`delete that file and retry (or set ${PANEL_PIN_ENV_VAR} in the environment, ` +
`which takes precedence).`);
}
return settings;
}
/**
* Persist an explicit pin. Throws on a blank version or an unparseable settings
* file (see assertSettingsWritable) — never silently drops the request.
*/
export function setPanelVersionPin(version, reason) {
const normalized = normalizePinVersion(version);
if (!normalized) {
throw new Error("A panel version pin needs a non-empty version string (e.g. \"0.11.20\").");
}
const settings = assertSettingsWritable();
const pin = { version: normalized, pinnedAt: new Date().toISOString() };
const trimmedReason = typeof reason === "string" ? reason.trim() : "";
if (trimmedReason)
pin.reason = trimmedReason;
settings.panelPin = pin;
write(settings);
// VERIFY the pin actually landed, by re-reading it. Telling a user "pinned"
// when nothing persisted is the same fabricated-success failure this whole
// feature exists to prevent — and it is the worse direction, because they
// would then believe they are protected. (Env is deliberately excluded here:
// we are confirming the FILE, not an override that would mask a failed write.)
const persisted = getPanelPinState({});
if (!persisted.pinned || persisted.version !== pin.version) {
throw new Error(`Pin did NOT persist: after writing ${panelSettingsPath()} the stored pin reads ` +
`back as ${persisted.version ?? "absent/unreadable"} rather than ${pin.version}. ` +
`NOT reporting the panel as pinned. Check that file is writable and well-formed.`);
}
return pin;
}
/**
* Remove the persisted pin. Returns the pin that was removed, or undefined when
* there wasn't one. Does NOT (and cannot) clear an env-var pin — callers must
* report that separately so the user isn't told they're unpinned when they
* aren't.
*/
export function clearPanelVersionPin() {
const settings = assertSettingsWritable();
// Keyed on PRESENCE, not truthiness: a malformed falsy pin (`null`, `false`,
// `0`) is an active indeterminate pin, so `!previous` would have refused to
// delete it and left the user permanently blocked with "no pin was set".
if (!("panelPin" in settings) || settings.panelPin === undefined)
return undefined;
const raw = settings.panelPin;
// A malformed stored pin still gets removed; describe it as unreadable rather
// than returning junk (or undefined, which callers render as "no pin existed").
// The version is validated too, not just the container: `{ version: null }`
// and `{ version: " " }` are OBJECTS, and returning them verbatim made unpin
// report "was null" / "was " for a pin that getPanelPinState called
// indeterminate — two parts of the system describing the same pin differently.
const rawVersion = raw && typeof raw === "object" && !Array.isArray(raw)
? normalizePinVersion(raw.version)
: undefined;
const previous = rawVersion
? raw
: { version: "(unreadable)" };
delete settings.panelPin;
write(settings);
// Symmetrically verified: a "pin removed" we did not observe would leave the
// user believing a sync can now proceed when it still cannot.
const persisted = getPanelPinState({});
if (persisted.pinned) {
throw new Error(`Pin was NOT removed: ${panelSettingsPath()} still reads back as pinned after ` +
`the write. NOT reporting the pin as cleared.`);
}
return previous;
}
/** Persisted agent backend/model preferences ({} when never set). */
export function getAgentSettings() {
return read().agent ?? {};
}
/**
* Merge a partial update into the persisted agent settings. `preferredModels`
* replaces the whole list (the panel sends the full edited list); `ollama`
* fields merge per-key so e.g. a model change doesn't clobber the base URL.
*/
/**
* Canonical form of a preferred-models list: trim, drop blanks, dedup, cap at 50.
* Exported so the set_config handler can compare an INCOMING list against the
* persisted one on the SAME footing — comparing a raw payload against this
* normalized list would report "changed" on every heartbeat and revive the
* config-repush loop (#393 follow-up).
*/
export function normalizePreferredModels(list) {
return [...new Set(list.map((m) => m.trim()).filter(Boolean))].slice(0, 50);
}
export function setAgentSettings(patch) {
const settings = read();
const prev = settings.agent ?? {};
const next = { ...prev };
if (patch.preferredModels !== undefined) {
next.preferredModels = normalizePreferredModels(patch.preferredModels);
}
if (patch.ollama !== undefined) {
next.ollama = { ...prev.ollama, ...patch.ollama };
}
if (patch.lmstudio !== undefined) {
next.lmstudio = { ...prev.lmstudio, ...patch.lmstudio };
}
if (patch.llamacpp !== undefined) {
next.llamacpp = { ...prev.llamacpp, ...patch.llamacpp };
}
if (patch.custom !== undefined) {
next.custom = { ...prev.custom, ...patch.custom };
}
settings.agent = next;
write(settings);
return next;
}
//# sourceMappingURL=panel-settings.js.map