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

232 lines • 11.3 kB
// THE canonical dotenv store (`~/.comfyui-mcp/.env`) and the only place that // decides where it lives — shared by config.ts (which loads it at boot) and // services/panel-secrets.ts (which WRITES it when the panel collects a token). // // Why this module exists (issue #826): the orchestrator writes a panel-collected // credential here and then relies on RESPAWNING the comfyui MCP child so the new // value reaches it through the child's spawn env. That made every credential // structurally dependent on a respawn always firing — and when it didn't, the // token sat on disk, valid and 0600, while every download kept failing 401 with // nothing distinguishing "no token configured" from "token present but never // injected". `freshSecretValue()` removes that dependency: a credential is // resolved from the canonical file at ACCESS time, so a long-lived child sees a // token saved after it started. // // SECURITY: nothing here ever logs a value. Callers get the value or `undefined`; // `secretKeyPresent()` exists so status can be reported without handling one. import dotenv from "dotenv"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; /** * Keys whose process.env value came FROM the env file at boot rather than from a * real environment variable. Those are stale as soon as the file changes, so * `freshSecretValue()` must NOT let them outrank a re-read — that inversion is * precisely what kept a just-saved token invisible. Keys absent from this set * are genuine environment variables (set by the shell or by the parent at spawn) * and keep winning, so the shell stays the escape hatch. */ const fileDerivedKeys = new Set(); /** * Env var (KEY NAMES ONLY — never values) by which a parent process tells a child * "these keys are in your spawn env, but the canonical .env is their AUTHORITY". * * The orchestrator injects current credential values into the comfyui child's * spawn env (buildComfyuiMcpEnv) so the credential arrives even if the child * cannot read the file. Without this marker the child would see those as REAL * environment variables and pin them forever — so a rotate or a revoke would be * ignored for the life of the child while the save reported that the running * tools re-read the file (codex gate, round 1, finding 1: the remaining #826 * false-success path). Marking them file-derived makes a later re-read * supersede the inherited copy, while `freshSecretValue`'s last fallback still * uses that copy when the file is unreadable. */ export const MANAGED_SECRET_KEYS_ENV = "COMFYUI_MCP_MANAGED_SECRET_KEYS"; /** Path to the canonical dotenv. `COMFYUI_MCP_ENV_FILE` overrides it (tests, and * unusual installs) — resolved here ONCE so the writer and every reader, in the * orchestrator and in the spawned child alike, can never disagree about which * file "the token was saved" refers to. */ export function comfyuiEnvFilePath() { return process.env.COMFYUI_MCP_ENV_FILE || join(homedir(), ".comfyui-mcp", ".env"); } /** * Parse the canonical env file. Returns `null` ONLY when the file could not be * read or parsed — never merely because it is absent. * * The distinction this function exists for is "the file says this key is gone" * (a revoke) versus "I could not read the file at all" (unknown), because * collapsing them makes a revoked credential look live or a live one look * revoked. The docstring said that; the code did not (codex gate P0). An ABSENT * file was returning `null`, the unknown answer — so `freshSecretValue` skipped * its revoke branch and fell back to the copy seeded from the file at boot. A * long-lived child then kept using, and could re-inject, a token whose file had * since disappeared. * * An absent file is not an unreadable one: it is a successful observation that * the file carries NO keys, which is exactly what an existing-but-empty file * says. So ENOENT returns `{}` and joins that path. First boot is unaffected — * nothing was file-derived yet, so a shell-provided value still wins. */ export function parseEnvFile() { try { const p = comfyuiEnvFilePath(); if (!existsSync(p)) return {}; return dotenv.parse(readFileSync(p, "utf-8")); } catch { return null; } } /** * Boot load: copy file values into process.env for keys it does not already * define (identical precedence to `dotenv.config()` — a real env var always * wins) and REMEMBER which keys came from the file. Returns the keys applied. * config.ts calls this instead of dotenv.config() so the provenance is recorded. */ export function loadEnvFileIntoProcess() { // A parent that injected credential values into our spawn env tells us here // which of them the canonical file OWNS, so a later rotate/revoke supersedes // the inherited copy instead of being pinned for this process's whole life. for (const k of (process.env[MANAGED_SECRET_KEYS_ENV] ?? "").split(",")) { const key = k.trim(); if (key) fileDerivedKeys.add(key); } const parsed = parseEnvFile(); if (!parsed) return []; const applied = []; for (const [k, v] of Object.entries(parsed)) { if (process.env[k] === undefined) { process.env[k] = v; fileDerivedKeys.add(k); applied.push(k); } } return applied; } /** True when this key's process.env value was seeded from the env file at boot * (so a file re-read supersedes it) rather than from a real env var. */ export function isFileDerived(key) { return fileDerivedKeys.has(key); } /** Record that the canonical file is now this key's AUTHORITY, even though its * current process.env value was assigned in-process. Called by the secret * writer: after a save, the file is where the value lives, so a later re-read * (a rotate by another process, a revoke) must supersede the in-memory copy. */ export function markFileDerived(key) { fileDerivedKeys.add(key); } /** Undo `markFileDerived`. Used when a save is rolled back, so a failed write * cannot leave the RESTORED value's precedence changed behind it. */ export function unmarkFileDerived(key) { fileDerivedKeys.delete(key); } /** * True when this key's current value came from a REAL environment variable — the * shell / the parent's explicit env — rather than from the canonical file. * * This is the provenance test a parent must use when telling a child which * injected credentials the file owns. Comparing the injected value against the * file's CURRENT value is not equivalent: if the file was rotated by another * writer after this process booted, its in-memory copy is merely STALE, not * shell-provided — and treating it as shell-provided would inject the stale * value unmarked and pin it in the child, making a configured on-disk credential * invisible all over again (codex gate, round 2, finding 2). */ export function isShellProvided(key) { const v = process.env[key]; return typeof v === "string" && v.trim() !== "" && !fileDerivedKeys.has(key); } /** Test-only: forget the boot provenance so a suite can re-simulate startup. */ export function resetEnvFileProvenanceForTests() { fileDerivedKeys.clear(); } function freshValueFromParsed(key, parsed) { const envValue = process.env[key]; if (typeof envValue === "string" && envValue.trim() && !fileDerivedKeys.has(key)) { return envValue; } if (parsed) { const fileValue = parsed[key]; if (typeof fileValue === "string" && fileValue.trim()) return fileValue; // A readable file without the key means a file-derived boot value is stale, // not that the old value should be resurrected. if (fileDerivedKeys.has(key)) return undefined; } if (typeof envValue === "string" && envValue.trim()) return envValue; return undefined; } /** * Resolve a credential NOW, in precedence order: * 1. a REAL environment variable (shell / spawn env) — the escape hatch wins; * 2. the canonical .env, RE-READ on every call — this is what lets a token * saved after the process started become visible without a respawn (#826); * 3. a boot-seeded process.env value, but ONLY when the file is unreadable — * if the file IS readable and no longer carries the key, the key is GONE * (a revoke) and this returns undefined rather than resurrecting it. * * `keys` are aliases for the same credential, most-canonical first (e.g. * HF_TOKEN then HUGGINGFACE_TOKEN). Never logs. */ export function freshSecretValue(...keys) { // ALIAS ORDER OUTRANKS SOURCE. Each alias is resolved fully (env, then file) // before the next alias is considered, so the canonical name always beats the // legacy one whichever side it came from. // // Checking every alias's ENV value first was wrong: a pre-existing shell // HUGGINGFACE_TOKEN then beat a freshly saved HF_TOKEN in the file, so the // save reported success and every download kept using the old credential — // the #826 shape all over again (codex gate, round 7, finding 1). It also // contradicted the boot-time rule this replaced, which was plainly // `HF_TOKEN || HUGGINGFACE_TOKEN`. // // The shell escape hatch is untouched: within ONE alias, a real environment // variable still outranks the file. const parsed = parseEnvFile(); for (const k of keys) { const value = freshValueFromParsed(k, parsed); if (value !== undefined) return value; } return undefined; } /** Resolve one non-aliased environment value at access time. The same * real-environment vs canonical-file precedence applies to configuration values * that are not credentials, while a file-derived boot value is still superseded * by a later readable file change. Never logs the value. */ export function freshEnvValue(key) { return freshValueFromParsed(key, parseEnvFile()); } /** Whether a credential resolves to SOMETHING right now — presence only, so * status can be reported without a caller ever holding the value. */ export function secretKeyPresent(...keys) { return freshSecretValue(...keys) !== undefined; } /** * Resolve MANY independent keys against ONE snapshot of the canonical file. * * Calling `freshSecretValue` per key re-reads the file per key, which is both * wasteful and inconsistent: a rotation landing mid-loop yields a view where * some keys are pre-rotation and some post. Callers that build a whole env (the * child's spawn env, the masked settings view) need one coherent picture. * Precedence per key is identical to `freshSecretValue`. */ export function freshSecretValues(keys) { // Per-key, INDEPENDENT resolution: these are distinct variables, not aliases // for one credential, so there is no ordering between them. (Alias ordering is // freshSecretValue's job, and the child applies it to what it inherits.) const parsed = parseEnvFile(); const out = {}; for (const k of keys) { const value = freshValueFromParsed(k, parsed); if (value !== undefined) out[k] = value; } return out; } //# sourceMappingURL=env-file.js.map