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