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

2,504 lines • 136 kB
// Persisted TOOL secrets for the orchestrator's BUILT-IN comfyui MCP server.
//
// The orchestrator spawns the comfyui MCP server (this build, in normal/stdio
// mode) as a subprocess with a FIXED env it controls (COMFYUI_URL, progress dir,
// COMFYUI_PATH…). Tool secrets the user supplies at runtime through the panel —
// e.g. a CivitAI API token for download_model action:"download_civitai", a HuggingFace token for
// download_model — must reach THAT subprocess's env. They can't go into the
// user's ~/.claude.json mcpServers map (user-mcp-config.ts), because that map is
// for the user's OWN, inherited MCP servers; the built-in comfyui server doesn't
// read it. So we persist them here, the orchestrator merges them into the comfyui
// server's spawn env (buildComfyuiMcpEnv), and respawns the server so a live one
// picks up the new value WITHOUT the user fighting reloads.
//
// SECURITY: the file holds raw secrets, so it is written 0600 (owner-only). The
// raw value NEVER enters a log or the agent's chat context — callers pass it
// straight from the panel's secure input, and only the env-var KEYS are ever
// logged (see comfyuiSecretKeys()).
import dotenv from "dotenv";
import { EventEmitter } from "node:events";
import { randomBytes } from "node:crypto";
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
import { homedir } from "node:os";
import { basename, dirname, join } from "node:path";
import { comfyuiEnvFilePath, freshEnvValue, freshSecretValue, freshSecretValues, isShellProvided, markFileDerived, parseEnvFile, unmarkFileDerived, MANAGED_SECRET_KEYS_ENV, } from "../env-file.js";
import { logger } from "../utils/logger.js";
import { downloadsAtRiskOfRespawn } from "./download-jobs.js";
import { runningUnderTestRunner } from "./test-isolation-guard.js";
import { OPENAI_KEY_PROVIDERS, providerModelHint } from "./openai-provider-registry.js";
// STRICT ALLOWLIST of env keys a panel-collected secret may set on the comfyui
// MCP child process. The child is a Node subprocess (process.execPath), so an
// arbitrary key (NODE_OPTIONS, PATH, COMFYUI_PATH, LD_PRELOAD, …) could hijack or
// clobber it. We therefore permit ONLY known credential vars the comfyui tools
// read — both on SAVE (reject otherwise) and on LOAD (filter), so even a hand-
// edited or corrupt panel-secrets.json can never inject anything else.
//   CIVITAI_API_TOKEN  → download_model action:"download_civitai" (config.civitaiApiToken)
//   HUGGINGFACE_TOKEN  → HuggingFace downloads   (config.huggingfaceToken)
//   HF_TOKEN           → HuggingFace alias some tooling/hub libs honor
//   COMFYUI_AUTH_*     → ComfyUI/reverse-proxy authentication headers
//   CF_ACCESS_*        → Cloudflare Access service-token headers
export const COMFYUI_SECRET_ENV_ALLOWLIST = [
    "CIVITAI_API_TOKEN",
    "HUGGINGFACE_TOKEN",
    "HF_TOKEN",
    "COMFYUI_AUTH_TOKEN",
    "COMFYUI_AUTH_HEADER",
    "COMFYUI_AUTH_SCHEME",
    "CF_ACCESS_CLIENT_ID",
    "CF_ACCESS_CLIENT_SECRET",
    "GEMINI_API_KEY",
    "GOOGLE_GENERATIVE_AI_API_KEY",
    "GOOGLE_API_KEY",
    "RUNCOMFY_API_KEY",
    "RUNPOD_API_KEY",
    "REGISTRY_ACCESS_TOKEN",
];
const ALLOWLIST_SET = new Set(COMFYUI_SECRET_ENV_ALLOWLIST);
/** Is `key` a permitted comfyui tool-secret env var? */
export function isAllowedComfyuiSecretKey(key) {
    return ALLOWLIST_SET.has(key);
}
// STRICT ALLOWLIST of env keys the ORCHESTRATOR itself may read from the store.
// These configure the agent provider backends in-process (never a subprocess),
// so the injection surface is different from the comfyui child's — but we keep
// the same allowlist discipline so a corrupt file can't set arbitrary env.
//   OPENROUTER_API_KEY → the OpenRouter provider backend (OllamaBackend openai)
//   COMFYUI_MCP_CUSTOM_API_KEY → the user-defined Custom endpoint provider
// The registry providers' keys (GLM_API_KEY/ZHIPU*/ZAI_API_KEY, KIMI_API_KEY,
// MOONSHOT_API_KEY) are DERIVED from openai-provider-registry so a new api-key
// provider is allowlisted by adding one registry entry, not editing this array.
export const AGENT_SECRET_ENV_ALLOWLIST = [
    "OPENROUTER_API_KEY",
    "COMFYUI_MCP_CUSTOM_API_KEY",
    ...OPENAI_KEY_PROVIDERS.flatMap((p) => p.envKeys),
];
const AGENT_ALLOWLIST_SET = new Set(AGENT_SECRET_ENV_ALLOWLIST);
/** Is `key` a permitted orchestrator agent-secret env var? */
export function isAllowedAgentSecretKey(key) {
    return AGENT_ALLOWLIST_SET.has(key);
}
/** Secrets file path. Overridable for tests.
 *
 *  `home` scopes the read to a caller-supplied home directory. It exists because
 *  `readOAuthStatus(home)` already took one and threaded it to `nativeCliStatus`,
 *  while the mirror half reached this function and silently used the REAL home —
 *  so a test that injected a temp home still read the developer's actual logins,
 *  and its result flipped depending on whether that machine happened to be signed
 *  into codex (#859). The docstring there promised "tests never read the
 *  developer's real logins"; for one of its two halves that was false.
 *
 *  Precedence is deliberately env > `home` > real home. `COMFYUI_MCP_PANEL_SECRETS`
 *  is the explicit global redirect and must keep winning, so this adds scoping
 *  where a `home` is passed without changing any path that already worked. */
export function panelSecretsPath(home) {
    return (process.env.COMFYUI_MCP_PANEL_SECRETS ||
        join(home ?? homedir(), ".comfyui-mcp", "panel-secrets.json"));
}
// In-process change channel: the tool handler that saves a secret runs in the
// SAME process as the orchestrator (both the in-process Claude panel server and
// the Codex loopback HTTP MCP are hosted by the orchestrator), so a module-level
// emitter is enough to tell the orchestrator to re-inject + respawn.
const emitter = new EventEmitter();
// ── Emission suspension ─────────────────────────────────────────────────────
// A multi-key operation (a Settings SLOT save fans out to a slot's alias keys)
// must not fire a change per key: each successful alias emits BEFORE the later
// alias's failure is known, so a save that ultimately fails — and is rolled back
// — would still have respawned every idle agent against a half-written state,
// and the rollback would respawn them all again (codex gate, round 5, finding
// 7). Suspend for the duration, then emit ONCE for the state actually left.
let emitSuspendDepth = 0;
let suspendedComfyuiChange = false;
/** What the last suspended operation's single emit cost. Read once, then cleared. */
let lastSuspendedAtRisk = [];
let suspendedAgentChange = false;
/**
 * Fire a change event WITHOUT letting a listener's failure escape.
 *
 * `emit` is synchronous, and these listeners do real work — rebuilding an MCP
 * server's env, scheduling a respawn. They run AFTER the store has already been
 * rewritten, so a throw from one of them propagated out of `setEnvSecret`, past
 * the receipt, and reached the caller as a generic failure for a save that had
 * in fact landed (codex gate). Notifying is not part of the write's transaction:
 * the credential is on disk either way, and a listener that fails is a
 * respawn-didn't-happen problem, which the receipt's own `respawn` field already
 * reports as "nothing was scheduled".
 */
function emitGuarded(event, payload) {
    try {
        if (payload === undefined)
            emitter.emit(event);
        else
            emitter.emit(event, payload);
    }
    catch (err) {
        logger.warn(`[panel-secrets] a "${event}" listener failed after the store was written ` +
            `(${err instanceof Error ? err.message : String(err)}); the credential IS saved, ` +
            `but whatever that listener drives — a tool-session respawn, a readiness refresh — did not happen.`);
    }
}
/** Downloads in flight at the moment a credential save is about to respawn the tool
 *  session (#1378). Best-effort: a records store this process cannot read must never break
 *  a credential save, and an unreadable store is reported as "none" rather than throwing. */
function snapshotAtRiskDownloads() {
    try {
        return downloadsAtRiskOfRespawn();
    }
    catch (err) {
        logger.debug("[secrets] could not enumerate in-flight downloads for the respawn warning", {
            error: err instanceof Error ? err.message : String(err),
        });
        return [];
    }
}
/**
 * Emit, and report what the emit COST (#1378).
 *
 * The snapshot lives here, at the one point where a respawn is actually triggered, rather
 * than at each call site. Three defects came out of having it anywhere else:
 *
 *  - a call site that emits without snapshotting reports nothing at risk (the revoke path,
 *    which had the emit and not the snapshot for a full release);
 *  - a call site that snapshots without emitting warns about a loss that never happened —
 *    a rollback under `withSuspendedEmissions` removes a key, emits nothing because
 *    nothing changed, and still told the user their downloads would not resume;
 *  - and the ordering, which must be snapshot-then-emit because the emit is synchronous
 *    and a listener replaces its tool session inside it, was restated at every site and
 *    therefore could be got wrong at any of them.
 *
 * Suspended: nothing is emitted, so nothing is at risk YET. The outer
 * `withSuspendedEmissions` makes exactly one real emit at the end, and that one returns
 * the snapshot for the whole operation.
 */
function emitComfyuiChange(payload) {
    if (emitSuspendDepth > 0) {
        suspendedComfyuiChange = true;
        return [];
    }
    const atRisk = snapshotAtRiskDownloads();
    emitGuarded("change", payload);
    return atRisk;
}
function emitAgentChange() {
    if (emitSuspendDepth > 0) {
        suspendedAgentChange = true;
        return;
    }
    emitGuarded("agentChange");
}
/**
 * Run `fn` with change events suspended, then emit at most one of each for the
 * FINAL state. `decide` sees whether anything was suppressed and returns what to
 * emit — a caller whose operation failed AND was fully rolled back can emit
 * nothing, because nothing changed.
 */
function withSuspendedEmissions(fn, decide) {
    const outerComfyui = suspendedComfyuiChange;
    const outerAgent = suspendedAgentChange;
    suspendedComfyuiChange = false;
    suspendedAgentChange = false;
    emitSuspendDepth++;
    let result;
    try {
        result = fn();
        return result;
    }
    finally {
        emitSuspendDepth--;
        const suppressed = { comfyui: suspendedComfyuiChange, agent: suspendedAgentChange };
        suspendedComfyuiChange = outerComfyui;
        suspendedAgentChange = outerAgent;
        // A `finally` that throws REPLACES the outcome of the block it guards — the
        // receipt, or the real error — so nothing in it may escape. The emits are
        // already guarded; `decide` is guarded here for the same reason.
        try {
            const plan = decide(result, suppressed);
            // The ONE real emit for the whole suspended operation, so its snapshot is the
            // operation's at-risk list. Stashed rather than returned because `finally` cannot
            // change what the block returns — the caller reads it back with
            // `takeSuspendedAtRisk()` after the fact.
            if (plan.comfyui)
                lastSuspendedAtRisk = emitComfyuiChange(plan.comfyui);
            if (plan.agent)
                emitAgentChange();
        }
        catch (err) {
            logger.warn(`[panel-secrets] deciding what to notify after a secret write failed ` +
                `(${err instanceof Error ? err.message : String(err)}); the store is unaffected, but no change event was fired.`);
        }
    }
}
/** The at-risk list from the last suspended operation's single emit, cleared on read so a
 *  later operation cannot inherit it. */
function takeSuspendedAtRisk() {
    const at = lastSuspendedAtRisk;
    lastSuspendedAtRisk = [];
    return at;
}
/** Subscribe to "a comfyui tool secret changed". The callback receives whether
 *  the change answered an outstanding secret REQUEST and, if so, the requesting
 *  tab (see ComfyuiSecretChange). Returns an unsubscribe fn. */
export function onComfyuiSecretsChanged(cb) {
    const handler = (payload) => cb({
        requested: payload?.requested === true,
        // The VERDICT must survive this wrapper. It is what the retry nudge is
        // gated on, and dropping it here made the gate always-false — a confirmed
        // save stopped nudging at all, which is #164 broken in the other
        // direction (codex gate). This wrapper normalises; it does not get to
        // decide what the listener may know.
        persisted: payload?.persisted,
        tabId: payload?.requested === true ? payload?.tabId : undefined,
        report: payload?.report,
    });
    emitter.on("change", handler);
    return () => {
        emitter.off("change", handler);
    };
}
// ── What a receipt OBLIGES its renderer to say ───────────────────────────────
//
// These live next to the receipt, not next to any one renderer, on purpose. Each
// of the three consumers (the panel_request_secret ack, the agent-secret ack,
// and the console's /api/secrets) grew its own idea of which fields mattered,
// and `lostKeys` reached exactly one of them — so a save that destroyed the
// user's other tokens was narrated as a success by the other two (codex gate).
// A new obligation added to the receipt belongs in `receiptDisclosures` once,
// and every consumer picks it up at the same moment.
/** The store lost OTHER credentials while saving this one. This outranks every
 *  other clause: "your token is saved" while the user's other tokens were
 *  destroyed is a fabricated success on top of data loss. Names only, no values. */
export function storeDamageNote(receipt) {
    const lost = receipt.lostKeys ?? [];
    if (!lost.length)
        return null;
    return (`🛑 "${receipt.key}" was written to ${receipt.path}, but the store NO LONGER carries ${lost.join(", ")} — ` +
        `${lost.length > 1 ? "those credentials were" : "that credential was"} lost during the write. Do not treat this as a successful save. ` +
        `Inspect ${receipt.path} and restore the missing ${lost.length > 1 ? "entries" : "entry"} before relying on anything in it.`);
}
/** The rewrite did not preserve the user's comment lines, which this store
 *  promises to keep. Not a failed save — the credential is there — but it is the
 *  user's data going missing, and it was computed and then discarded before it
 *  ever reached a receipt (codex gate). A count only: a comment in a credential
 *  file can itself contain a credential. */
export function commentLossNote(receipt) {
    const n = receipt.lostCommentLines ?? 0;
    if (n <= 0)
        return null;
    return (`⚠️ ${n} comment line${n > 1 ? "s" : ""} in ${receipt.path} did not survive this write. ` +
        `The credential itself is unaffected, but this store is meant to preserve them — check the file if those notes mattered.`);
}
/** The write landed but was not established to survive a power loss. */
export function durabilityNote(receipt) {
    if (!receipt.durabilityGap)
        return null;
    return (`⚠️ The value IS in ${receipt.path} now, but this write was not made crash-durable: ${receipt.durabilityGap}. ` +
        `If the machine loses power before the filesystem flushes on its own, set the credential again.`);
}
/** The store holds the value, but a real environment variable of the same name
 *  outranks it, so the readers use THAT. Reporting "saved" without this is a
 *  configured state the tools do not use. */
export function shadowedNote(receipt) {
    if (!receipt.shadowedByEnv)
        return null;
    return (`⚠️ "${receipt.key}" was stored in ${receipt.path}, but it is NOT the value in use: a real environment variable named ${receipt.key} is set outside this app and takes precedence over the store. ` +
        `Nothing changed for the tools. To make the value you just supplied take effect, unset ${receipt.key} in the environment this app was started from and restart it — or leave the environment variable as the source of truth. ` +
        `(The value itself is never shown or logged.)`);
}
/**
 * EVERY clause this receipt obliges its renderer to state, most severe first.
 *
 * THE choke point: a consumer renders these and is done, instead of knowing
 * which receipt fields exist. Empty means the receipt carries no obligation —
 * which is not the same as "the save succeeded"; that question is `persisted`.
 */
export function receiptDisclosures(receipt) {
    return [
        atRiskNote(receipt.atRiskDownloads ?? [], receipt.respawn),
        storeDamageNote(receipt),
        strayCopyNote(receipt),
        commentLossNote(receipt),
        durabilityNote(receipt),
        shadowedNote(receipt),
    ].filter((n) => n !== null);
}
/**
 * What the respawn this change triggers is about to cost, or has already cost (#1378).
 *
 * Lives beside the other disclosures rather than in one caller's renderer, because the
 * caller that had it was the agent-facing one and the Settings endpoint — which saves
 * through the same path and orphans the same transfers — rendered nothing at all (codex).
 *
 * THE RESPAWN'S TIMING DECIDES THE SENTENCE, and "not reported" is its own case. A save
 * that collects no respawn report has not established that no respawn happened; saying
 * "will not resume" invites the user to act on a transfer that may be dead already, and
 * saying "already lost" claims an event nobody observed.
 */
export function atRiskNote(atRisk, 
// `undefined` is a THIRD answer: no report was collected on this path, which is not
// `null` ("a listener reported doing nothing"). The wording distinguishes them.
respawn) {
    if (!atRisk.length)
        return null;
    const listed = atRiskDownloadSummary(atRisk);
    // Why this is unrecoverable, stated once: the auth header is part of each download's
    // cache identity, so the bytes on disk can no longer be found under the new one.
    const consequence = `The new credentials change each download's cache identity, so those transfers will ` +
        `NOT resume: re-issuing them starts from 0% even though the partial files remain on ` +
        `disk (#1378).`;
    if (respawn?.applied) {
        return (`🛑 The tool session was replaced immediately, and ${listed} were in flight. ${consequence} ` +
            `Nothing can recover them under the new identity — that is the cost already paid, not a ` +
            `warning you can act on.`);
    }
    if (respawn?.scheduled) {
        return (`⚠️ ${listed} are in flight and the tool session is queued to be rebuilt at the end of ` +
            `this turn. ${consequence} If that matters, let them finish before the rebuild.`);
    }
    return (`⚠️ ${listed} were in flight when this credential change fired its tool-session rebuild. ` +
        `Whether that rebuild has already happened was not reported here, so this is not a claim ` +
        `either way about transfers you may still be able to save. ${consequence}`);
}
/**
 * #1567 — what a DEFERRED respawn is orphaning, said at the moment it fires.
 *
 * `atRiskNote` above is for the save. It is built from a snapshot taken before the emit,
 * which is correct for an `applied` respawn — the emit IS the damage — and structurally
 * incapable of covering a `scheduled` one, where the damage happens arbitrarily later. A
 * reporter saved a token with nothing in flight (so the save-time check correctly warned
 * about nothing), started nine downloads over the next two turns, and lost all ~48GB when
 * the queued respawn landed, with no warning at any point.
 *
 * ## Why this does NOT reuse `atRiskNote`'s consequence
 *
 * That sentence says the transfers will not resume and re-issuing restarts from 0%,
 * because the new credential changes each download's cache identity. True when the
 * credential changes MID-TRANSFER. It is wrong for the case this note exists for, and the
 * reporter proved it: their downloads began AFTER the save, so identity never moved and
 * re-issuing resumed from the `.partial` files at 4%/1%/2%. ~11GB was recoverable — but
 * only because they checked the partials rather than believing the prediction.
 *
 * So this states what is observable here (these are being killed now, the partials are on
 * disk) and is explicit that resumability depends on something this code cannot see: which
 * side of the credential change each transfer started on. Claiming either outcome would be
 * asserting the thing that actually varies.
 *
 * ## `heldMs` — why the rebuild is going ahead anyway
 *
 * A queued respawn now WAITS for in-flight transfers instead of killing them (#1567 item
 * 2), so reaching this note at all means the wait was given up on: every remaining transfer
 * stopped making progress for the whole stall window. That is a different event from the
 * original report — "it killed them the moment it came due" — and saying so is what stops
 * the note reading as though the wait never happened. Omitted when there was no wait (the
 * transfers are being killed immediately), because inventing one would be a false claim
 * about how long the user had.
 */
export function orphanedByDeferredRespawnNote(orphaned, heldMs) {
    if (!orphaned.length)
        return null;
    const waited = typeof heldMs === "number" && heldMs > 0
        ? ` The rebuild WAITED ${Math.round(heldMs / 1000)}s for them rather than killing them ` +
            `when it came due, and gave up because they stopped making progress — so each of these ` +
            `is either stalled or already dead, and re-issuing is how you find out which.`
        : ``;
    return (`🛑 The queued tool-session rebuild is happening NOW, and ${atRiskDownloadSummary(orphaned)} ` +
        `were still transferring. They belong to the session being replaced, so they are being ` +
        `killed — this is the rebuild that was queued when a comfyui credential was saved earlier ` +
        `(#1567).${waited}\n\n` +
        `The partial files are still on disk, so re-issuing each download is worth doing: it can ` +
        `RESUME from them rather than starting over. A transfer that was already running when the ` +
        `credential changed has a different cache identity and definitely restarts from 0%; one ` +
        `started after that save keeps its identity and usually resumes — but resumption also needs ` +
        `the server to still offer the same validator and honour a range request, so it is not ` +
        `guaranteed either way. Read the progress each re-issue reports instead of assuming.\n\n` +
        `Re-issue them now if they are still wanted; nothing else will pick them up.`);
}
/** A pre-write snapshot the write could not delete: a readable copy of the store
 *  holding the PREVIOUS credential for this key. Harmless clutter after a
 *  rotation on a single-user machine, but the user should know it is there. */
export function strayCopyNote(receipt) {
    if (!receipt.strayCopy)
        return null;
    // Say only what the FILE KIND establishes. `.pre-*` is a snapshot of the store
    // as it was, so it holds the previous value; `.tmp-*` is a failed writer's
    // proposed new content, which is a different thing entirely — and both were
    // being described as "the previous value", asserting contents nobody looked at
    // (codex gate). Neither is ever opened to check: that would mean reading a
    // credential in order to describe it.
    const kinds = [
        /\.pre-/.test(receipt.strayCopy) ? "a snapshot of the store from before a write" : null,
        /\.tmp-/.test(receipt.strayCopy) ? "an unfinished write's proposed new store" : null,
    ].filter((k) => k !== null);
    return (`⚠️ A leftover file beside ${receipt.path} could not be deleted: ${receipt.strayCopy}. ` +
        `${kinds.length ? `It is ${kinds.join(" / ")}` : "Its provenance was not established"}, ` +
        `so it may contain credential values — the file itself was not read. Delete it by hand.`);
}
/**
 * The same obligations for a REMOVAL. A revoke is a store rewrite, so it can
 * lose credentials, drop comments, and fail to be durable exactly as a save can
 * — and its consumer was reading only "did anything change", so an incomplete
 * loss account came back as a clean `200 {ok:true}` (codex gate). Defined here,
 * beside the save's, so the two cannot drift into disagreeing about what a store
 * write owes its caller.
 */
/**
 * The one description of what a respawn is about to cost, shared by both paths (#1378).
 *
 * Save and revoke reach the same emit and lose the same transfers, so they say it the same
 * way — two hand-rolled summaries would drift, and the revoke half was missing entirely
 * until codex pointed at it.
 */
export function atRiskDownloadSummary(atRisk) {
    const gb = atRisk.reduce((n, d) => n + d.bytes, 0) / 1024 ** 3;
    const names = atRisk
        .map((d) => d.filename)
        .slice(0, 3)
        .join(", ");
    return `${atRisk.length} download(s) (${names}${atRisk.length > 3 ? ", …" : ""}), about ${gb.toFixed(2)} GB fetched`;
}
export function removeDisclosures(outcome, path) {
    const out = [];
    if (outcome.atRiskDownloads.length) {
        // WHAT THE REVOKE COST (#1378, #1409). Removing the credential a gated transfer is
        // authenticated with respawns the tool session, which changes each download's cache
        // identity — the `.partial` at 96% becomes unreachable and re-issuing starts from zero.
        //
        // Unlike the save path, this one collects no respawn reports, so whether the rebuild
        // has ALREADY happened is not established here. The sentence says so rather than
        // picking one: "already lost" would be a claim we did not check, and "will be lost"
        // invites the user to act on a transfer that may be dead already.
        out.push(`⚠️ ${atRiskDownloadSummary(outcome.atRiskDownloads)} — these were in flight when the ` +
            `credential was removed. Whether the tool session has been rebuilt yet is not reported ` +
            `on this path, but a rebuild changes each download's cache identity, so those transfers ` +
            `do NOT resume: re-issuing starts from 0% even though the partial files remain on disk ` +
            `(#1378).`);
    }
    if (outcome.lostKeys.length) {
        out.push(`🛑 The store no longer carries ${outcome.lostKeys.join(", ")} — ` +
            `${outcome.lostKeys.length > 1 ? "those credentials were" : "that credential was"} lost during this removal. ` +
            `Inspect ${path} and restore ${outcome.lostKeys.length > 1 ? "them" : "it"} before relying on anything in it.`);
    }
    if (outcome.uncertainty) {
        out.push(`⚠️ Whether this removal cost anything else is NOT established: ${outcome.uncertainty}. ` +
            `The key itself is gone; check ${path} before relying on the rest of it.`);
    }
    if (outcome.lostCommentLines > 0) {
        out.push(`⚠️ ${outcome.lostCommentLines} comment line${outcome.lostCommentLines > 1 ? "s" : ""} in ${path} did not survive this removal.`);
    }
    if (outcome.durabilityGap) {
        out.push(`⚠️ The removal was not made crash-durable: ${outcome.durabilityGap}. If the machine loses power, the credential may come back.`);
    }
    if (outcome.resurrectionRisk)
        out.push(`🛑 ${outcome.resurrectionRisk}`);
    if (outcome.incomplete)
        out.push(`🛑 ${outcome.incomplete}`);
    return out;
}
/**
 * May a caller present this removal as a clean revoke?
 *
 * Only when the store demonstrably no longer carries the credential AND the
 * removal can account for what it cost. A revoke whose loss account was never
 * completed, that will be undone by the boot migration, or that only partly
 * ran, was still answering `200 {ok:true}` next to a warning saying exactly
 * that (codex gate) — and a consumer reads the flag, not the prose.
 *
 * Comment loss and a durability gap are deliberately NOT here: they are things
 * the revoke cost, not doubts about the revoke itself.
 */
export function revokeIsClean(outcome) {
    return (outcome.lostKeys.length === 0 &&
        !outcome.uncertainty &&
        !outcome.resurrectionRisk &&
        !outcome.incomplete);
}
/**
 * May this change fire the "your token is now active — retry the action" nudge?
 *
 * The nudge is the loudest thing in the system: it is injected straight into the
 * agent's turn as an instruction, and it OUTRANKS the receipt the tool reply is
 * built from. It was gated on `requested` alone, so a save whose read-back could
 * not be taken — or one that destroyed other credentials — produced an honest
 * tool reply beside a message telling the agent it had worked, and the agent
 * follows the instruction (codex gate). That is #826 itself, in the one place
 * that speaks over the honest answer.
 *
 * A change with no verdict (a revoke, a background reload) never carries
 * `requested`, so it cannot reach here claiming a save.
 */
export function changeJustifiesRetryNudge(change) {
    return change.requested === true && change.persisted === "yes" && !!change.tabId;
}
/**
 * Keys whose EVERY reader resolves them at ACCESS time from the canonical .env
 * (via env-file.ts freshSecretValue), so a value saved after a process started
 * is visible to it with no respawn:
 *   CIVITAI_API_TOKEN / HF_TOKEN / HUGGINGFACE_TOKEN → config.ts lazy getters
 *   RUNPOD_API_KEY                                    → services/runpod-client.ts
 *   REGISTRY_ACCESS_TOKEN                             → services/node-authoring.ts
 *
 * DELIBERATELY EXCLUDED, though they are allowlisted and do take effect in the
 * orchestrator's own process.env immediately:
 *   GEMINI_API_KEY / GOOGLE_* — forwarded into the Gemini CLI SUBPROCESS's spawn
 *     env (gemini-backend buildAgentSpawnEnv keep-list), so a live CLI keeps the
 *     old key until it is respawned (codex gate, round 1, answer A).
 *   OPENROUTER/GLM/KIMI/… — keyed backends capture the credential at
 *     construction (#278), so a live one keeps the old key until rebuilt.
 *   RUNCOMFY_API_KEY — no reader resolves it lazily.
 * For those the receipt says a respawn is needed and reports its actual
 * disposition, rather than claiming a pickup that a running subprocess will not
 * make. Over-claiming here is exactly the #826 defect in miniature.
 */
const LIVE_RELOAD_KEYS = new Set([
    "CIVITAI_API_TOKEN",
    "HF_TOKEN",
    "HUGGINGFACE_TOKEN",
    "RUNPOD_API_KEY",
    "REGISTRY_ACCESS_TOKEN",
]);
/** True when a saved value for `key` is picked up by EVERY one of its readers
 *  without any process being respawned. Presence-only; never touches a value. */
export function hasLivePickup(key) {
    return LIVE_RELOAD_KEYS.has(key);
}
/**
 * The legacy store, or NULL when it exists but could not be read.
 *
 * The lenient `read()` below folds "unreadable" into "empty", and for a REVOKE
 * that is the difference between "there is nothing to purge" and "we cannot see
 * whether there is something to purge". The revoke reported a clean result on
 * the strength of the first while the file may still hold the key — and once it
 * becomes readable again the boot migration puts the credential back (codex
 * gate). Callers that only need best-effort content keep using `read()`.
 *
 * Rebase resolution: `main` (#859) threaded an optional `home` through this
 * reader so the suite can point the credential store at a temp dir instead of
 * the developer's real ~/.comfyui-mcp. That parameter is carried onto BOTH
 * functions here — dropping it would put the tests back to reading and writing
 * live credentials, which is the bug #859 fixed.
 */
function readOrNull(home) {
    const p = panelSecretsPath(home);
    if (!existsSync(p))
        return {}; // genuinely absent: nothing to purge, and that IS known
    try {
        const parsed = JSON.parse(readFileSync(p, "utf-8"));
        return parsed && typeof parsed === "object" ? parsed : {};
    }
    catch (err) {
        // Never echo file contents (they're secret) — just the failure.
        logger.warn(`[panel-secrets] could not parse ${p}: ${err instanceof Error ? err.message : String(err)}`);
        return null;
    }
}
function read(home) {
    return readOrNull(home) ?? {};
}
function write(secrets) {
    // The LEGACY store is a credential file too, redirected by its own env var —
    // and the runtime guard only ever covered the canonical one, so a test could
    // still rewrite the developer's real panel-secrets.json (codex gate).
    assertPanelSecretsRedirectedInTests();
    const p = panelSecretsPath();
    mkdirSync(dirname(p), { recursive: true });
    // Atomic for the same reason as the .env: an in-place truncate+write that is
    // interrupted leaves the OAuth status mirror and any legacy tokens destroyed.
    // (0600 is applied to the temp file before the rename, so the credential is
    // never briefly world-readable.)
    const durabilityGap = writeFileAtomic(p, JSON.stringify(secrets, null, 2));
    // RETURNED as well as logged. A caller that HAS a receipt must be able to
    // carry this: a revoke whose legacy purge was not made crash-durable can be
    // undone by a power loss, and the boot migration then resurrects the
    // credential the user just revoked — so `revokeIsClean` was able to answer
    // ok:true over it (codex gate). Callers with nowhere to put it still get the
    // log. Never the contents: a path and an errno only.
    if (durabilityGap)
        logger.warn(`[panel-secrets] ${durabilityGap}`);
    return durabilityGap;
}
// ── Canonical env-secret store: ~/.comfyui-mcp/.env ─────────────────────────
// The SINGLE source of truth for flat API-token secrets (RUNPOD/CIVITAI/HF/…).
// config.ts loads this file into process.env at boot for BOTH the orchestrator
// and every spawned comfyui-mcp agent, so a token here reaches everywhere with
// no separate injection. (Structured OAuth login state stays in the JSON store —
// it isn't a flat KEY=value env var.) Writes are a surgical single-line upsert:
// the rest of the user's .env — comments, other keys — is preserved byte-for-byte.
/** Path to the canonical dotenv. Delegates to env-file.ts, which is the SINGLE
 *  resolver config.ts (the reader, in this process AND in the spawned comfyui
 *  child) also uses — so the file we write can never be a different file from the
 *  one the tools read (#826). */
export function envFilePath() {
    return comfyuiEnvFilePath();
}
/**
 * Encode a value for a .env line.
 *
 * The previous encoder used JSON.stringify, which is NOT what dotenv decodes: a
 * double-quoted dotenv value only expands \n and \r, so a credential containing
 * a quote or a backslash was written JSON-escaped and read back with the escapes
 * still in it (codex gate, round 1, finding 3). That silently stored the WRONG
 * value. Encode in a form dotenv actually reverses:
 *   - bare when the value needs no quoting at all;
 *   - single-quoted (dotenv treats these literally — no escape expansion) when
 *     the value contains no single quote and no newline;
 *   - double-quoted with \n / \r escaped otherwise, which needs the value to
 *     carry no double quote and no backslash (dotenv would not undo those).
 * `encodeEnvValue` returns null when the value cannot be represented faithfully;
 * the caller REFUSES rather than writing something that reads back different.
 */
function envValueCandidates(value) {
    return [
        value, // bare — only survives when the value needs no quoting at all
        `'${value}'`, // single-quoted: dotenv takes these literally
        `"${value}"`, // double-quoted
        `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`, // double-quoted, escaped
    ];
}
/**
 * Build the `KEY=value` line and PROVE dotenv reads it back as the same value.
 *
 * Candidates are TRIED, not guessed: a hand-picked encoding plus a "does this
 * round-trip?" check only validates the encoding it happened to choose, so a
 * value another encoding could store faithfully would be refused for no reason
 * (codex gate, round 2, finding 5). The first candidate dotenv reverses exactly
 * wins; only if none does is the value refused — before the file is touched, and
 * without ever including the value in the error.
 */
function encodeEnvLine(key, value) {
    // Refuse CONTROL characters outright, before any encoding is considered.
    //   - a line break: this store's upsert/remove are LINE-based, so the next
    //     save of any OTHER key would match only the value's first physical line
    //     and leave its continuation behind as garbage;
    //   - a NUL: dotenv will round-trip it happily, so the read-back would CONFIRM
    //     the save — but process.env truncates at NUL, so the value the readers and
    //     the spawned child actually get is a different, shorter string (codex
    //     gate, round 3, finding 4). A confirmed save that delivers something else
    //     is the worst outcome of all;
    //   - any other C0 control: same class of hazard, no credential contains one.
    // Written with \u escapes on purpose: a literal control byte in this
    // source would make the file read as a binary blob to git, unreviewable
    // in a diff.
    if (/[\u0000-\u001f\u007f]/.test(value)) {
        throw new Error(`The value supplied for "${key}" cannot be stored faithfully in ${envFilePath()} — it contains a control character ` +
            `(a line break, a NUL, or similar), which neither this line-based store nor a process environment can carry unchanged. Nothing was written. ` +
            `Set ${key} as a real environment variable instead (it takes precedence over the file), or re-issue it without control characters.`);
    }
    for (const candidate of envValueCandidates(value)) {
        const line = `${key}=${candidate}`;
        try {
            if (dotenv.parse(line)[key] === value)
                return line;
        }
        catch {
            // Unrepresentable in this form — try the next.
        }
    }
    throw new Error(`The value supplied for "${key}" cannot be stored faithfully in ${envFilePath()} — ` +
        `no dotenv encoding of it reads back as the value given (this happens with combinations of quote, backslash and newline characters). ` +
        `Nothing was written. Set ${key} as a real environment variable instead (it takes precedence over the file), ` +
        `or re-issue a token without those characters.`);
}
/**
 * Every line dotenv would read as an assignment to `key` — including the
 * `export KEY=…` form it accepts. Matching only `KEY=` left an `export` line
 * behind: an upsert would append a second assignment and a revoke would remove
 * the wrong one, leaving the OLD credential effective while the tool reported
 * the key cleared (codex gate, round 3, finding 2). Upsert and revoke share this
 * so they can never disagree about what "a line for this key" means.
 */
function envKeyLinePattern(key) {
    // Mirrors dotenv's own assignment forms: optional leading whitespace, an
    // optional `export `, then either `KEY=` (with optional space around the `=`)
    // or `KEY: ` — the colon form dotenv also accepts (codex gate, round 4,
    // finding 3). Missing the colon form meant an upsert APPENDED a second
    // assignment and a revoke left the colon line supplying the old credential.
    // The key is escaped even though every caller passes an allowlisted shell
    // identifier, so this can never become a pattern-injection surface.
    const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    return new RegExp(`^\\s*(?:export\\s+)?${escaped}(?:\\s*=|:\\s)`);
}
/**
 * REFUSE to write the real credential store from a test run.
 *
 * `console-secrets.test.ts` drove the live /api/secrets endpoint without
 * redirecting COMFYUI_MCP_ENV_FILE, so every `npm test` overwrote the
 * developer's actual ~/.comfyui-mcp/.env — their CivitAI token became a dummy
 * and a revoke case deleted whichever slot it cleared. That ran unnoticed for
 * weeks.
 *
 * A static sweep over test sources cannot close this: a test that imports the
 * setter under an alias, or reaches a write through a helper, matches no marker
 * and passes the sweep. The RUNTIME can, because it sees the write itself
 * however the caller spelled it — so a forgotten redirect becomes a failing test
 * at the moment of the write instead of a silent overwrite (coordinator finding).
 */
function assertStoreIsRedirectedInTests() {
    if (!runningUnderTestRunner())
        return; // detection is uncertain → ALLOW the write
    if (process.env.COMFYUI_MCP_ENV_FILE)
        return;
    throw new Error("Refusing to write the credential store from a test run: COMFYUI_MCP_ENV_FILE is not set, " +
        `so this would write the developer's real ${comfyuiEnvFilePath()} and can destroy live tokens. ` +
        "Point COMFYUI_MCP_ENV_FILE at a temp file in the test's setup (see secret-store-test-isolation.test.ts).");
}
/** The same refusal for the LEGACY json store, which has its own redirect. */
function assertPanelSecretsRedirectedInTests() {
    if (!runningUnderTestRunner())
        return; // uncertain → ALLOW the write
    if (process.env.COMFYUI_MCP_PANEL_SECRETS)
        return;
    throw new Error("Refusing to write the legacy credential store from a test run: COMFYUI_MCP_PANEL_SECRETS is not set, " +
        `so this would write the developer's real ${panelSecretsPath()} and can destroy live tokens and OAuth state. ` +
        "Point COMFYUI_MCP_PANEL_SECRETS at a temp file in the test's setup.");
}
// `runningUnderTestRunner` lives in test-isolation-guard.ts, which is a leaf:
// the other real-home writers (panel-pin-guard, panel-settings,
// prompt-overrides) need the same predicate and must not pull this module in
// to get it. Re-exported so existing importers keep working.
export { runningUnderTestRunner } from "./test-isolation-guard.js";
/** An errno for a message, without ever quoting file contents. */
function errCode(err) {
    const code = err?.code;
    return code ?? (err instanceof Error ? err.name : "unknown error");
}
/**
 * Write EVERY byte, or throw before anything is renamed.
 *
 * `writeSync` is allowed to write fewer bytes than it was given, and both
 * writers took its word for it: a short write followed by an fsync makes a
 * TRUNCATED temp file durable, and the rename then installs it over the store —
 * the exact whole-file truncation this atomic write exists to prevent, arriving
 * by the one route nobody was checking (codex gate). Loop until the buffer is
 * out, and refuse to progress if it ever stops making progress; both happen
 * BEFORE the rename, so the store is untouched and refusing is correct.
 */
function writeAllSync(fd, body) {
    const buf = Buffer.from(body, "utf8");
    let written = 0;
    while (written < buf.length) {
        const n = writeSync(fd, buf, written, buf.length - written);
        if (n <= 0) {
            throw new Error(`Could not write the credential store: the filesystem accepted ${written} of ${buf.length} bytes and then stopped. Nothing was written — the store is unchanged.`);
        }
        written += n;
    }
}
/**
 * Delete the temp files and pre-write snapshots a CRASHED writer left behind.
 *
 * The snapshot is a hard link (or a copy) of the credential store, so a process
 * killed between the link and its cleanup leaves a readable file containing the
 * credentials as they were — including one the user has since revoked. Nothing
 * reads those files, but "revoked" has to mean the credential is gone, and a
 * later revoke was reporting a clean result while a copy of the token sat in the
 * same directory (codex gate).
 *
 * Only files OLDER than `ageMs` are touched: a live writer's sentinel exists for
 * microseconds, so this can never delete one out from under a concurrent save
 * (and if it somehow did, that save's account degrades to "unknown" — it does
 * not silently claim to be clean).
 *
 * Best effort by design; returns the paths it could NOT remove so a revoke can
 * disclose them.
 */
function sweepStaleSidecars(target, ageMs = 60_000) {
    const dir = dirname(target);
    const base = `${basename(target)}.`;
    const survivors = [];
    let entries;
    try {
        entries = readdirSync(dir);
    }
    catch {
        return survivors; // cannot list; nothing claimed either way
    }
    const cutoff = Date.now() - ageMs;
    for (const entry of entries) {
        if (!entry.startsWith(base))
            continue;
        if (!/\.(tmp|pre)-/.test(entry.slice(base.length - 1)))
            continue;
        const full = join(dir, entry);
        try {
            if (statSync(full).mtimeMs > cutoff)
                continue; // a live writer's, leave it
            rmSync(full, { force: true });
        }
        catch {
            survivors.push(full);
        }
    }
    return survivors;
}
/**
 * Flush the DIRECTORY so a rename that happened is still there after a power
 * loss. `fsync` on the file makes its CONTENT durable; it says nothing about the
 * directory entry that now points at it, and a rename whose directory was never
 * synced can be lost while the temp file is gone too — leaving no file at all
 * where the store used to be. Omitting this is why "the write is durable" was a
 * claim rather than an observation (codex gate).
 *
 * Windows has no syncable directory handle: the open succeeds and `fsync`
 * answers EPERM, on every version. There is therefore nothing to attempt and
 * nothing that failed — NTFS journals the rename's own metadata. This function
 * says so by returning null there rather than reporting a durability gap on
 * every single save on that platform; what it never does is claim, anywhere,
 * that a directory sync happened when it did not.
 *
 * Returns null when there is no gap, or a sentence naming the gap.
 */
function syncDirectory(dir) {
    if (process.platform === "win32")
        return null;
    let fd;
    try {
        fd = openSync(dir, "r");
        fsyncSync(fd);
        return null;
    }
    catch (err) {
        const code = errCode(err);
        // ONLY "this filesystem does not implement directory syncing" is the
        // platform's answer rather than a failure of this write. EACCES and EISDIR
        // were also being swallowed, and neither means that: a directory can be
        // writable and searchable but not readable, so the rewrite succeeds while
        // `openSync(dir, "r")` is refused — no sync happened, and the receipt said
        // there was no gap. That is an unobserved durability claim, which is the
        // thing this whole field exists to stop (codex gate).
        if (code === "EINVAL" || code === "ENOTSUP")
            return null;
        return (`the rename was not made durable — syncing the directory ${dir} failed (${code}), ` +
            `so a power loss immediately after this save may leave the store without it`);
    }
    finally {
        if (fd !== undefined) {
            try {
                closeSync(fd);
            }
            catch {
                /* nothing to do; the sync verdict above already stands */
            }
        }
    }
}
/**
 * Rewrite the canonical .env ATOMICALLY, and prove nothing else was lost.
 *
 * The old in-place read/modify/write had two accidental failure modes on a real
 * machine, and BOTH were invisible because the read-back only checked our own
 * key (coordinator finding):
 *   - a crash or a full disk between truncate and flush left the user's whole
 *     credential store truncated — every token, not just the one being set;
 *   - two writers each read the old file and the later write silently dropped
 *     the other's newly saved key.
 * And in both cases the per-key read-back still said "yes", so we confirmed a
 * save over data loss — a fabricated success on top of destroyed credentials.
 *
 * So: write a temp file in the SAME directory, fsync it, then rename over the
 * target (rename is atomic on both POSIX and Windows, so a reader sees either
 * the whole old file or the whole new one — never a truncated one), then fsync
 * the directory so the rename itself is durable. Before the rename, re-read the
 * target and abort if it changed since we read it, which is the compare-and-swap
 * that stops a concurrent writer's key from being dropped; the caller retries
 * against the newer content. After the rename, compare the parsed keys and the
 * comment lines against what we started with, so a loss can be REPORTED rather
 * than confirmed as success.
 *
 * THE LOSS ACCOUNT IS TAKEN FROM THE FILE WE ACTUALLY REPLACED. This is the
 * part that was wrong. The compare-and-swap is a CHECK followed by a RENAME, and
 * those are two operations: a writer that lands between them is replaced by our
 * rename, and if the account is computed from the read we did EARLIER it appears
 * in neither snapshot — so its key was silently destroyed and the receipt still
 * said "saved, nothing lost" (codex gate). So immediately before the rename we
 * `link` the target to a sentinel — atomic, and it leaves the target in place —
 * and the account is computed against the SENTINEL: the content that was really
 * there at the swap. A writer that landed after the compare-and-swap check is
 * then reported as lost instead of vanishing.
 *
 * What remains unobservable is a writer landing between the `link` and the
 * `rename` — two adjacent syscalls with nothing in between. Closing even that
 * needs an OS-level lock this store does not take. So on top of the account we
 * refuse to claim it is COMPLETE whenever there is evidence of a live competitor
 * (a compare-and-swap conflict on an earlier attempt, a post-rename read that is
 * not what we wrote, or a sentinel we could not capture), which is what
 * `lossCheck` reports.
 *
 * Everything after the rename is a DISCLOSURE, never a refusal. The store has
 * already changed by then, so a failure to verify, chmod or sync must ride out
 * on the outcome. Throwing there turned a completed destructive operation into a
 * generic error the caller retried against a store that had already lost keys
 * (codex gate).
 */
function rewriteEnvFile(mutate, opts = {}) {
    assertStoreIsRedirectedInTests();
    const p = envFilePath();
    mkdirSync(dirname(p), { recursive: true });
    // Clear out anything a CRASHED writer left: a stale pre-write snapshot is a
    // readable copy of the credentials as they were, including ones since revoked.
    // Survivors are REPORTED, not dropped — the revoke path carried this
    // obligation and the save path did not, so a save could return
    // `persisted: "yes"` with an old credential still readable beside the store
    // (codex gate).
    const preexistingStrays = sweepStaleSidecars(p);
    const ATTEMPTS = 5;
    // OBSERVED evidence that another process is writing this store right now. It
    // survives a successful retry on purpose: the writer we collided with a moment
    // ago can equally have landed inside this attempt's check→rename window, where
    // nothing can see it.
    let sawConcurrentWriter = false;
    for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
        const existed = existsSync(p);
        const raw = existed ? readFileSync(p, "utf-8") : "";
        const next = mutate(raw.length ? raw.split(/\r?\n/) : []);
        if (next === null) {
            // Nothing to change: no rename, so nothing to account for — except a
            // crashed writer's snapshot the sweep could not clear, which is still a
            // readable copy of the credentials and still the caller's to know about.
            return {
                changed: false,
                lostKeys: [],
                lostCommentLines: 0,
                lossCheck: "clean",
                durabilityGap: null,
                ...(preexistingStrays.length
                    ? { strayCopy: `${preexistingStrays.join(", ")} (left by an interrupted earlier write)` }
                    : {}),
            };
        }
        const body = next.join("\n");
        const nonce = randomBytes(6).toString("hex");
        const tmp = `${p}.tmp-${process.pid}-${nonce}`;
        // The snapshot of what we REPLACE, captured at the swap itself.
        const sentinel = `${p}.pre-${process.pid}-${nonce}`;
        let sentinelTaken = false;
        /** The snapshot came from a COPY, not a link. A copy is not atomic and takes
         *  measurable time, so it is a weaker observation than the link: a writer
         *  landing between the copy and the rename is invisible to it. */
        let sentinelViaCopy = false;
        /** Was there a file to replace at the moment of the swap? When there was
         *  not, "nothing was there" IS the snapshot — there is nothing to lose. */
        let targetExistedAtSwap = false;
        let fd;
        let durabilityGap = null;
        /** A pre-write snapshot that could not be deleted — this operation's own, or
         *  a crashed writer's that the sweep above could not clear. */
        let strayCopy = preexistingStrays.length
            ? `${preexistingStrays.join(", ")} (left by an interrupted earlier write)`
            : null;
        try {
            fd = openSync(tmp, "wx", 0o600);
            writeAllSync(fd, body);
            // Flush to the device before the rename: a rename that lands while the
            // bytes are still in the page cache can leave an empty file after a power
            // loss, which is the truncation this whole change exists to prevent.
            try {
                fsyncSync(fd);
            }
            catch (err) {
                // NOT swallowed. Proceeding is right — the rename is still atomic, so
                // the store is never left half-written — but a receipt that then says
                // "saved" over an unflushed write claims a durability nobody
                // established (codex gate). The gap rides out on the outcome instead.
                durabilityGap =
                    `the new content could not be flushed to the device before the rename (fsync: ${errCode(err)}), ` +
                        `so a power loss immediately after this save may leave ${p} without it`;
            }
            closeSync(fd);
            fd = undefined;
            // COMPARE-AND-SWAP: if the target changed since we read it, someone else
            // wrote in the meantime and our `next` was computed from stale content.
            // Retry against the newer file rather than clobbering their save.
            const current = existsSync(p) ? readFileSync(p, "utf-8") : "";
            if (current !== raw) {
                rmSync(tmp, { force: true });
                sawConcurrentWriter = true;
                continue;
            }
            // Capture the file we are about to replace, as late as it can be captured.
            // `link` is atomic and does NOT disturb the target, so unlike a
            // rename-aside it never leaves a moment where the store does not exist for
            // readers. Filesystems without hard links (exFAT, some network mounts) fall
            // back to a copy; if neither works the account falls back to `raw` and the
            // outcome says the swap could not be snapshotted.
            // The link is ATTEMPTED unconditionally — never gated on an `existsSync`
            // first. Asking "does it exist?" and then acting is another check-then-act
            // pair, and it had exactly the bug the sentinel exists to fix: a writer
            // that CREATED the store between the check and the rename was clobbered
            // while we recorded "there was nothing there" and reported a clean save
            // (codex gate). ENOENT from the link is itself the observation, taken at
            // the same instant as the snapshot would have been.
            try {
                linkSync(p, sentinel);
                sentinelTaken = true;
                targetExistedAtSwap = true;
            }
            catch (err) {
                if (err?.code === "ENOENT") {
                    targetExistedAtSwap = false; // OBSERVED: nothing to replace
                }
                else {
                    // Filesystems without hard links (exFAT, some network mounts). A copy
                    // is not atomic, but it is still a snapshot taken at the swap.
                    try {
                        copyFileSync(p, sentinel);
                        sentinelTaken = true;
                        sentinelViaCopy = true;
                        targetExistedAtSwap = true;
                        // copyFileSync creates with the default mode, and this file is a
                        // copy of the credential store — put it back to owner-only at once.
                        try {
                            chmodSync(sentinel, 0o600);
                        }
                        catch {
                            /* no-op on Windows */
                        }
                    }
                    catch (copyErr) {
                        if (copyErr?.code === "ENOENT") {
                            targetExistedAtSwap = false;
                        }
                        else {
                            // Neither worked: we are about to replace something we could not
                            // look at. Reported below as an incomplete account.
                            targetExistedAtSwap = true;
                        }
                    }
                }
            }
            // #881 — THE SWAP WINDOW. The compare-and-swap above closes the gap
            // between our first read and our decision. It cannot close the gap between
            // that check and this rename, and a writer landing in THAT window was
            // overwritten silently: the loss account compares the store against the
            // sentinel, which was captured before their write, so it saw only our own
            // intended changes and reported the save clean. Losing another process's
            // credential is bad; reporting it as a clean save is what made it
            // undiagnosable.
            //
            // Re-read at the LAST possible moment and retry if the file moved under
            // us. Retrying (rather than refusing) is what preserves their key: the
            // next pass recomputes `next` from their content, so both writes survive.
            //
            // Compared by CONTENT, not by inode. st_ino is the more precise signal —
            // the sentinel is a hard link, so a replacement gives `p` a new inode
            // while the sentinel keeps the old one — but Node reports an unstable or
            // zero `ino` on Windows, which is the platform this store is most used on.
            // A content comparison is exact everywhere and costs one small read.
            //
            // HONEST LIMIT: this NARROWS the window, it does not close it. A writer
            // landing between this read and the rename below is still unobservable
            // without an OS-level atomic compare-and-swap, which no portable API
            // offers. What it removes is the SILENT part — after this, a collision we
            // can see is retried instead of clobbered.
            if (sentinelTaken) {
                // readFileSync + catch, NOT existsSync-then-read: this is a
                // check-then-act pair otherwise, and the read is the observation anyway.
                let atSwap = "";
                try {
                    atSwap = readFileSync(p, "utf-8");
                }
                catch {
                    atSwap = ""; // gone entirely — also a change
                }
                if (atSwap !== raw) {
                    rmSync(tmp, { force: true });
                    rmSync(sentinel, { force: true });
                    sentinelTaken = false;
                    sawConcurrentWriter = true;
                    continue;
                }
            }
            renameSync(tmp, p);
        }
        catch (err) {
            if (fd !== undefined) {
                try {
                    closeSync(fd);
                }
                catch {
                    /* ignore */
                }
            }
            // Cleanup must not replace the real error with its own — a catch that can
            // throw is not a catch.
            try {
                rmSync(tmp, { force: true });
            }
            catch {
                /* a stray temp file is not worth losing the reason for */
            }
            let leftBehind = null;
            try {
                rmSync(sentinel, { force: true });
            }
            catch (rmErr) {
                // The snapshot is a link to the store as it is RIGHT NOW, and it is
                // FRESH — so the stale sweep will skip it for the next minute as a live
                // writer's. Dropping this failure left a readable copy of the
                // credentials behind with nothing saying so, and a revoke moments later
                // could report clean over it (codex gate). The refusal below is still
                // correct — the store is untouched — but it must carry this.
                leftBehind = `${sentinel} (${errCode(rmErr)})`;
            }
            // Still BEFORE the rename: the store is untouched, so refusing is correct
            // and the caller's "nothing was written" is true.
            if (leftBehind) {
                throw new Error(`${err instanceof Error ? err.message : String(err)} ` +
                    `Nothing was written to ${p}. However, a pre-write snapshot could not be cleaned up: ${leftBehind} — ` +
                    `it is a readable copy of the credential store as it is now. Delete it by hand.`);
            }
            throw err;
        }
        // ─── AFTER THE RENAME. The store has changed. Nothing below may throw. ───
        // The BASIS for the loss account: what was really under the rename, not what
        // we read before deciding. When the two differ, a writer landed after the
        // compare-and-swap check — the exact case that used to disappear without
        // trace, and the reason `basisRaw` exists at all.
        let basisRaw = targetExistedAtSwap ? raw : "";
        let basisIsSnapshot = !targetExistedAtSwap || sentinelTaken;
        if (sentinelTaken) {
            try {
                basisRaw = readFileSync(sentinel, "utf-8");
            }
            catch {
                basisIsSnapshot = false;
                basisRaw = raw;
            }
        }
        // GUARDED. `rmSync` can fail (EPERM, EBUSY on Windows), and this is after
        // the rename — an unguarded cleanup that throws here would discard the whole
        // disclosure about a store that has already changed, which is the very
        // failure mode this section exists to prevent (codex gate). A sentinel we
        // cannot remove is clutter in the user's own 0600 directory, so it is
        // reported to the log by NAME and never allowed to cost the receipt.
        try {
            rmSync(sentinel, { force: true });
        }
        catch (err) {
            // REPORTED, not only logged. This snapshot holds the store as it was —
            // for a REVOKE, that is the credential the user just removed, sitting in a
            // readable file next to the store. The sweep will not catch it either: it
            // deliberately skips fresh files, because a fresh one belongs to a live
            // writer. So a revoke could come back clean with the old token still on
            // disk (codex gate). It rides out on the outcome instead.
            strayCopy = strayCopy
                ? `${strayCopy}; ${sentinel} (${errCode(err)})`
                : `${sentinel} (${errCode(err)})`;
            logger.warn(`[panel-secrets] could not remove the pre-write snapshot ${sentinel} (${errCode(err)}); ` +
                `it is a copy of the credential store and can be deleted by hand.`);
        }
        const before = dotenvParseSafe(basisRaw);
        const beforeComments = commentLines(basisRaw);
        const dirGap = syncDirectory(dirname(p));
        if (dirGap && !durabilityGap)
            durabilityGap = dirGap;
        try {
            chmodSync(p, 0o600);
        }
        catch {
            /* chmod is a no-op on Windows; ignore */
        }
        // WHOLE-FILE verification. "Our key is present" is not enough — that is
        // exactly the check that confirmed a save while other tokens were lost.
        let afterRaw;
        try {
            afterRaw = existsSync(p) ? readFileSync(p, "utf-8") : "";
        }
        catch (err) {
            // The rename already happened. We cannot say what is in the store, and we
            // must not say "nothing was lost" from an account we could not take.
            return {
                changed: body !== raw,
                lostKeys: [],
                lostCommentLines: 0,
                lossCheck: "unknown",
                lossCheckNote: `${p} was rewritten but could not be read back afterwards (${errCode(err)}), ` +
                    `so whether it still carries the other credentials it held is not established`,
                durabilityGap,
                ...(strayCopy ? { strayCopy } : {}),
            };
        }
        const after = dotenvParseSafe(afterRaw);
        const lostKeys = [];
        for (const k of Object.keys(before)) {
            if (opts.intentionalKey && k === opts.intentionalKey)
                continue;
            if (!(k in after))
                lostKeys.push(k);
        }
        // Verify against what we ACTUALLY WROTE, not merely against what we expected
        // to find. A file that is no longer our content means a writer landed after
        // our rename — observable, unlike the link→rename window, and it makes the
        // key account incomplete in exactly the same way.
        const raced = afterRaw !== body;
        // `lostKeys` is a COMPLETE account only when we snapshotted what we replaced
        // and nothing rewrote the file afterwards. Anything else and the empty list
        // means "we did not see any", not "there were none".
        const lossCheckNote = raced
            ? `${p} does not read back as the content this save wrote, so something else rewrote it in the meantime; ` +
                `what that write kept or dropped is not established here`
            : !basisIsSnapshot
                ? `the state this save replaced in ${p} could not be snapshotted, so the loss account rests on a read taken before the swap ` +
                    `and cannot cover a write that landed after it`
                : sentinelViaCopy
                    ? `this filesystem does not support hard links, so the state this save replaced was snapshotted by COPYING it — ` +
                        `which is not atomic and takes measurable time, unlike the link used elsewhere. A write landing during that copy, ` +
                        `or between it and the rename, is not covered by the account above`
                    : sawConcurrentWriter
                        ? `another process was writing ${p} during this save (a compare-and-swap conflict was hit and retried). ` +
                            `The store is re-read immediately before the rename, so a write that landed up to that point was seen and retried ` +
                            `against rather than replaced (#881); but that read and the rename are still two adjacent operations, and a write ` +
                            `landing in the gap between them is replaced by this one without appearing in any read — this save cannot rule that out`
                        : null;
        return {
            changed: afterRaw !== raw,
            lostKeys,
            lostCommentLines: Math.max(0, beforeComments - commentLines(afterRaw)),
            lossCheck: lossCheckNote ? "unknown" : "clean",
            ...(lossCheckNote ? { lossCheckNote } : {}),
            durabilityGap,
            ...(strayCopy ? { strayCopy } : {}),
        };
    }
    throw new Error(`Could not update ${p}: another process changed it during each of ${ATTEMPTS} attempts. ` +
        `Nothing was written — retry, or close whatever else is writing the credential store.`);
}
/** dotenv.parse that never throws — an unreadable/garbage store yields {}. */
function dotenvParseSafe(raw) {
    try {
        return dotenv.parse(raw);
    }
    catch {
        return {};
    }
}
/** How many non-empty, non-assignment lines (i.e. comments) the text carries.
 *  Used only to DETECT loss; the lines themselves are never reported. */
function commentLines(raw) {
    return raw.split(/\r?\n/).filter((l) => l.trim().startsWith("#")).length;
}
/** Upsert `KEY=value` into the canonical .env, 0600, preserving every other line
 *  (comments included). Replaces the FIRST uncommented `KEY=` line and DROPS any
 *  later duplicates, else appends. Dropping the duplicates matters: dotenv lets a
 *  later assignment win, so replacing only the first would leave a stale line
 *  authoritative — the save would report a value the readers never resolve
 *  (codex gate, round 2, finding 3). The read-back would catch it, but leaving
 *  one line for the key means there is nothing to catch. */
function upsertEnvFile(key, value) {
    // Encode + verify BEFORE touching the file, so an unrepresentable value never
    // half-lands (it would read back different and look like tampering).
    const line = encodeEnvLine(key, value);
    const re = envKeyLinePattern(key);
    return rewriteEnvFile((original) => {
        const lines = [];
        let replaced = false;
        for (const existing of original) {
            if (re.test(existing)) {
                if (!replaced) {
                    lines.push(line);
                    replaced = true;
                }
                continue; // a later duplicate would outrank the line we just wrote
            }
            lines.push(existing);
        }
        if (!replaced) {
            // Drop a single trailing empty line so we don't accumulate blanks, then add.
            if (lines.length && lines[lines.length - 1] === "")
                lines.pop();
            lines.push(line);
            lines.push("");
        }
        return lines;
    }, { intentionalKey: key });
}
/**
 * Remove EVERY uncommented assignment to `key` from the canonical .env
 * (including the `export KEY=` form dotenv honors). A revoke that leaves one
 * form behind reports the credential cleared while it is still in effect.
 *
 * This used to THROW when the rewrite also lost other keys. That inverted the
 * repo's own rule: the removal had already happened, so the throw turned a
 * COMPLETED destructive operation into a generic failure, which the console then
 * rendered as `ok:false` and the caller retried — a second removal against a
 * store that had already lost credentials (codex gate). Everything after the
 * rename is a DISCLOSURE. The loss now rides out on the outcome.
 */
function removeEnvFileKey(key) {
    // BEFORE the early return. `rewriteEnvFile` asserts this, but the "no file, so
    // nothing to do" shortcut returned in front of it — and the caller
    // (`removeEnvSecret`) then went on to purge and REWRITE the legacy
    // panel-secrets.json, so a test that reached a removal through a helper could
    // still delete the developer's real legacy credential (codex gate). A guard
    // with a path around it is not a guard.
    assertStoreIsRedirectedInTests();
    const p = envFilePath();
    if (!existsSync(p)) {
        return { changed: false, lostKeys: [], lostCommentLines: 0, lossCheck: "clean", durabilityGap: null };
    }
    const re = envKeyLinePattern(key);
    return rewriteEnvFile((lines) => {
        const kept = lines.filter((l) => !re.test(l));
        return kept.length === lines.length ? null : kept; // nothing to remove
    }, { intentionalKey: key, removing: true });
}
/** Legacy JSON store writer — kept atomic, and durable, for the same reasons.
 *  Returns a durability gap sentence, or null. Like the .env writer it never
 *  throws for anything that happens AFTER the rename. */
function writeFileAtomic(target, body) {
    const tmp = `${target}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
    let fd;
    let durabilityGap = null;
    try {
        fd = openSync(tmp, "wx", 0o600);
        writeAllSync(fd, body);
        try {
            fsyncSync(fd);
        }
        catch (err) {
            // Same rule as the .env writer: an ignored fsync failure is a durability
            // claim nobody checked (codex gate). Proceed, but report it.
            durabilityGap =
                `the new content could not be flushed to the device before the rename (fsync: ${errCode(err)}), ` +
                    `so a power loss immediately after this write may leave ${target} without it`;
        }
        closeSync(fd);
        fd = undefined;
        renameSync(tmp, target);
    }
    catch (err) {
        if (fd !== undefined) {
            try {
                closeSync(fd);
            }
            catch {
                /* ignore */
            }
        }
        rmSync(tmp, { force: true });
        // Still BEFORE the rename — the target is untouched, so refusing is correct.
        throw err;
    }
    // ─── AFTER THE RENAME. Nothing below may throw. ───
    // A rename is not durable until the DIRECTORY it happened in is synced: the
    // new name can be lost while the temp name is gone too, leaving no file where
    // the store used to be. This was missing here as well as in the .env writer.
    const dirGap = syncDirectory(dirname(target));
    if (dirGap && !durabilityGap)
        durabilityGap = dirGap;
    try {
        chmodSync(target, 0o600);
    }
    catch {
        /* ignore */
    }
    return durabilityGap;
}
/** True when `key` may be persisted (union of the comfyui-tool + agent-provider
 *  allowlists — both now land in the same canonical .env). */
export function isAllowedSecretKey(key) {
    return isAllowedComfyuiSecretKey(key) || isAllowedAgentSecretKey(key);
}
/**
 * THE canonical secret setter: persist a flat token to ~/.comfyui-mcp/.env,
 * apply it to process.env immediately (so in-process readers see it now), and
 * emit so the orchestrator re-probes provider readiness AND respawns the agent
 * on idle (the respawn reloads .env → the child gets the new key). Rejects a
 * non-allowlisted key so a stray key can never be written.
 */
export function setEnvSecret(key, value, opts = {}) {
    const trimmed = key.trim();
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
        throw new Error(`Invalid env var name "${key}" — use a valid shell identifier.`);
    }
    if (!isAllowedSecretKey(trimmed)) {
        throw new Error(`Env var "${trimmed}" is not an accepted secret. Allowed: ${[...new Set([...COMFYUI_SECRET_ENV_ALLOWLIST, ...AGENT_SECRET_ENV_ALLOWLIST])].join(", ")}.`);
    }
    if (value.trim() === "") {
        // A blank/whitespace-only value WRITES and READS BACK fine, so the save
        // would be confirmed — while every reader (freshSecretValue) treats a blank
        // as absent and every request stays unauthenticated. That is precisely the
        // #826 shape: a confirmed save nothing downstream can use (codex gate,
        // round 2, finding 1). Refuse it up front; nothing is written.
        throw new Error(`No value was supplied for "${trimmed}" (it was empty or whitespace only). Nothing was saved — the credential is still unset.`);
    }
    const previous = process.env[trimmed];
    const previouslyShellProvided = isShellProvided(trimmed);
    const writeOutcome = upsertEnvFile(trimmed, value);
    // A REAL environment variable outranks the store — that is this codebase's own
    // rule, and the panel does not get to break it. Overwriting process.env here
    // (and calling markFileDerived on it) replaced the live shell value AND
    // relabelled its provenance, so behaviour changed again after a full restart
    // when the shell value came back (coordinator finding). The value IS written
    // to the store, so it takes effect the moment the env var is unset; until then
    // the receipt says plainly that it is not the value in use.
    if (!previouslyShellProvided) {
        process.env[trimmed] = value; // live in-process effect
        // The canonical file is now this key's AUTHORITY even though we just assigned
        // process.env, so a later re-read (a rotate by another process, a revoke) wins
        // over this in-memory copy instead of being pinned by it.
        markFileDerived(trimmed);
    }
    // VERIFY the write by reading the canonical file back. Reporting "saved" from
    // the mere absence of a throw is how a caller ends up believing it is
    // configured while nothing downstream can see the value (#826). The comparison
    // is against the value we already hold; neither the stored nor the supplied
    // value is logged, and only the verdict leaves this function.
    //
    // ORDER MATTERS. "yes" is the verdict for a save that is BOTH proven and
    // clean, so every weaker outcome has to be able to displace it:
    //   - the store unreadable            → unknown (nothing established)
    //   - the key not carrying the value  → no
    //   - other credentials proven gone   → damaged (worse than unknown; say so)
    //   - the loss account not completed  → unknown (an empty lostKeys from an
    //                                       account never taken is not "nothing
    //                                       was lost")
    const readBack = parseEnvFile();
    let persisted;
    let uncertainty;
    if (readBack === null) {
        persisted = "unknown";
        uncertainty = `${envFilePath()} could not be read back, so whether the value reached the store is not established`;
    }
    else if (readBack[trimmed] !== value) {
        persisted = "no";
    }
    else if (writeOutcome.lostKeys.length) {
        persisted = "damaged";
    }
    else if (writeOutcome.lossCheck === "unknown") {
        persisted = "unknown";
        uncertainty =
            `${trimmed} does read back from ${envFilePath()} with the value supplied, but this save cannot account for the rest of the store: ` +
                `${writeOutcome.lossCheckNote ?? "the whole-file check could not be completed"}`;
    }
    else {
        persisted = "yes";
    }
    if (persisted === "no") {
        // The durable store demonstrably does not carry this value. ROLL BACK the
        // in-process assignment and emit NOTHING, so the system is left exactly as it
        // was and the caller's "nothing is configured" is TRUE (codex gate, round 1,
        // finding 2: without this, the value was live in the orchestrator's env — and
        // would be injected into the next child — while the tool reported failure,
        // the opposite false verdict).
        if (previous === undefined)
            delete process.env[trimmed];
        else
            process.env[trimmed] = previous;
        // Restore the key's PRECEDENCE too — leaving it marked file-derived would
        // change how the restored value resolves from here on (codex gate, round 2,
        // finding 3).
        if (previouslyShellProvided)
            unmarkFileDerived(trimmed);
        return {
            key: trimmed,
            path: envFilePath(),
            persisted,
            ...(uncertainty ? { uncertainty } : {}),
            livePickup: hasLivePickup(trimmed),
            respawn: null,
            // The store may ALSO have lost other keys on the way to not saving this
            // one. That is the same disclosure as on any other verdict, and dropping
            // it here because the headline is already a refusal would hide the worse
            // half of what happened.
            ...(writeOutcome.lostKeys.length ? { lostKeys: writeOutcome.lostKeys } : {}),
            ...(writeOutcome.lostCommentLines ? { lostCommentLines: writeOutcome.lostCommentLines } : {}),
            ...(writeOutcome.durabilityGap ? { durabilityGap: writeOutcome.durabilityGap } : {}),
            ...(writeOutcome.strayCopy ? { strayCopy: writeOutcome.strayCopy } : {}),
            // Whether a credential for this key is STILL in effect after the rollback.
            // "Nothing is configured" is false when a previous working value survived,
            // and telling the user that would send them hunting the wrong problem.
            // Presence only — the value is neither returned nor logged.
            stillConfigured: freshSecretValue(trimmed) !== undefined,
        };
    }
    // Only a COMFYUI tool secret should restart the comfyui MCP child + inject the
    // "retry the download that needed this token" nudge (#269): saving an
    // agent-ONLY provider key (OPENROUTER/GLM/KIMI…) previously restarted every
    // agent with that nonsensical download-retry message. Agent-only keys fire
    // just "agentChange" (readiness/model refresh) below.
    // `requested` rides the change so the orchestrator only injects the retry
    // nudge when this save ANSWERS an outstanding panel_request_secret — a
    // Settings slot save / background reload / revoke leaves it false (#164).
    // Collect what listeners actually did. `emit` is SYNCHRONOUS, so every report
    // has landed by the time this returns — the receipt describes observed work,
    // not an intention. No listener → `respawn: null`, never a fabricated zero.
    const reports = [];
    // #1378 — SNAPSHOT BEFORE THE EMIT, because the emit is where the damage happens.
    //
    // `emitComfyuiChange` is synchronous and a listener may replace its tool session inside
    // it, so by the time the receipt is rendered those downloads are already orphaned. My
    // first version enumerated them afterwards and told the user "let them finish and save
    // the credential afterwards" about transfers that had been dead for a tick — advice that
    // was not merely useless but wrong about what had happened.
    //
    // Taken before, the list is what WAS in flight, which is the right thing to report in
    // both cases: still running for a queued respawn, already lost for an applied one. The
    // receipt words those differently.
    let atRiskDownloads = [];
    if (isAllowedComfyuiSecretKey(trimmed))
        atRiskDownloads = emitComfyuiChange({
            requested: opts.requested === true,
            // The verdict rides along, so a listener cannot make a louder claim than
            // the receipt does. `persisted` is already settled at this point.
            persisted,
            tabId: opts.tabId,
            report: (r) => reports.push(r),
        });
    if (isAllowedAgentSecretKey(trimmed))
        emitAgentChange(); // flip provider readiness live
    return {
        atRiskDownloads,
        key: trimmed,
        path: envFilePath(),
        persisted,
        ...(uncertainty ? { uncertainty } : {}),
        livePickup: hasLivePickup(trimmed),
        ...(previouslyShellProvided ? { shadowedByEnv: true } : {}),
        ...(writeOutcome.lostKeys.length ? { lostKeys: writeOutcome.lostKeys } : {}),
        // Computed by the writer and, until now, dropped on the floor right here —
        // so a rewrite that lost the user's comments was acknowledged as a healthy
        // save on every consumer path while the code claimed it preserved them
        // (codex gate).
        ...(writeOutcome.lostCommentLines ? { lostCommentLines: writeOutcome.lostCommentLines } : {}),
        ...(writeOutcome.durabilityGap ? { durabilityGap: writeOutcome.durabilityGap } : {}),
        ...(writeOutcome.strayCopy ? { strayCopy: writeOutcome.strayCopy } : {}),
        respawn: reports.length
            ? reports.reduce((a, b) => ({
                live: a.live + b.live,
                applied: a.applied + b.applied,
                scheduled: a.scheduled + b.scheduled,
            }), { live: 0, applied: 0, scheduled: 0 })
            : null,
    };
}
/** Canonical remover: drop a token from .env + process.env + emit. Also PURGES
 *  the legacy panel-secrets.json maps (#269): without this a key revoked from
 *  .env would RESURRECT on the next boot, because migrateSecretsToEnv() re-adds
 *  any key still present in the JSON store that .env no longer provides. */
export function removeEnvSecret(key) {
    const outcome = removeEnvFileKey(key);
    const removed = outcome.changed;
    // Dropping the IN-PROCESS value is a change in its own right. A SHELL-provided
    // key has no line in the store, so `removed` is false — and gating the emit on
    // `removed` alone meant no child was ever respawned for it, leaving a live
    // tool session using a credential the user had just revoked while the console
    // answered that it no longer resolves (codex gate, round 6, finding 1). The
    // respawn is what actually takes it out of the child's env.
    const envDeleted = process.env[key] !== undefined;
    if (envDeleted)
        delete process.env[key];
    // Purge the legacy JSON maps so a revoked key can't resurrect via migration.
    // This runs AFTER the .env removal has already landed, so it must not throw:
    // the revoke has happened, and a throw here made the console answer a generic
    // failure with no `cleared` and no "do not retry" — turning a completed
    // destructive operation back into a refusal (codex gate). A purge that failed
    // is a DISCLOSURE, and a specific one: the key comes back on the next start.
    let purgedJson = false;
    let resurrectionRisk;
    try {
        const s = readOrNull();
        if (s === null) {
            // NOT "there is nothing to purge" — we could not look. The file may still
            // hold this key, and the boot migration re-adds any legacy key the
            // canonical store no longer provides (codex gate).
            resurrectionRisk =
                `"${key}" was removed from ${envFilePath()}, but the legacy store ${panelSecretsPath()} could not be READ, ` +
                    `so whether it still holds an entry for this key is not established. If it does, the boot migration re-adds this credential on the next start. ` +
                    `Check that file before relying on the revoke.`;
            logger.warn(`[panel-secrets] ${resurrectionRisk}`);
        }
        else {
            for (const map of [s.comfyuiEnv, s.agentEnv]) {
                if (map && Object.prototype.hasOwnProperty.call(map, key)) {
                    delete map[key];
                    purgedJson = true;
                }
            }
            if (purgedJson) {
                const jsonGap = write(s);
                if (jsonGap) {
                    // The purge LANDED but is not established to survive a power loss — and
                    // the consequence is specific: if it is lost, the legacy entry is back
                    // and the boot migration re-adds the credential. That is the same
                    // outcome as a failed purge, so it is reported the same way rather than
                    // as a generic durability note nobody connects to the revoke.
                    resurrectionRisk =
                        `"${key}" was removed from both stores, but purging the legacy store ${panelSecretsPath()} was not made crash-durable: ${jsonGap}. ` +
                            `If the machine loses power before the filesystem flushes on its own, that entry returns and the boot migration re-adds this credential. ` +
                            `Re-check ${panelSecretsPath()} after any unclean shutdown.`;
                }
            }
        }
    }
    catch (err) {
        purgedJson = false;
        resurrectionRisk =
            `"${key}" was removed from ${envFilePath()}, but the legacy store ${panelSecretsPath()} could not be purged (${errCode(err)}). ` +
                `It still holds an entry for this key, and the boot migration re-adds any legacy key the canonical store does not provide — so this credential COMES BACK on the next start. ` +
                `Remove it from that file by hand to revoke it permanently.`;
        logger.warn(`[panel-secrets] ${resurrectionRisk}`);
    }
    // A pre-write snapshot a crashed writer left behind is a readable copy of the
    // store as it was — with this credential still in it. `rewriteEnvFile` sweeps
    // them, so this only names the ones that could not be removed: a revoke must
    // not report the credential gone while a copy of it is sitting next to the
    // store (codex gate).
    // The snapshot THIS removal took and could not delete is fresh, so the sweep
    // below deliberately leaves it — it cannot tell a live writer's from ours.
    // It holds the credential we just revoked, so it must be named here.
    if (outcome.strayCopy && !resurrectionRisk) {
        resurrectionRisk =
            `"${key}" was removed from ${envFilePath()}, but the pre-write snapshot this removal took could not be deleted: ${outcome.strayCopy}. ` +
                `That file is a link to the store as it stood BEFORE the revoke — so it still holds the credential just removed. Delete it by hand.`;
        logger.warn(`[panel-secrets] ${resurrectionRisk}`);
    }
    const strayCopies = sweepStaleSidecars(envFilePath());
    if (strayCopies.length && !resurrectionRisk) {
        // Provenance only as far as the file KIND establishes it. A `.pre-*` is a
        // snapshot of the store as it was; a `.tmp-*` is an unfinished write's
        // proposed content — a different thing, and neither is opened to look.
        resurrectionRisk =
            `"${key}" was removed from ${envFilePath()}, but ${strayCopies.length} leftover file${strayCopies.length > 1 ? "s" : ""} from an interrupted write could not be deleted ` +
                `(${strayCopies.join(", ")}). ${strayCopies.length > 1 ? "They are" : "It is"} a store snapshot or an unfinished write's content — not read here — so ${strayCopies.length > 1 ? "they" : "it"} may still hold this credential. ` +
                `Delete ${strayCopies.length > 1 ? "them" : "it"} by hand.`;
        logger.warn(`[panel-secrets] ${resurrectionRisk}`);
    }
    const changed = removed || purgedJson || envDeleted;
    // #1378 — REVOKE ORPHANS DOWNLOADS TOO (codex P1, filed separately as #1409).
    //
    // The fix landed on the SET path only, and revoke reaches the same emit two functions
    // later. A listener replacing its tool session inside that synchronous emit kills an
    // in-flight transfer whether the credential was written or removed — and removing the
    // credential a gated download is authenticated with is, if anything, the likelier way to
    // lose one. Snapshot before the emit for the same reason as above: afterwards the
    // transfers are already gone and the list is empty, which reads as "nothing was at risk".
    let atRiskDownloads = [];
    if (changed) {
        // comfyui-only restart (#269). The emit reports what it cost — see emitComfyuiChange:
        // under suspended emissions (a rollback) it emits nothing and costs nothing, which is
        // why this is not a snapshot taken beside the call.
        if (isAllowedComfyuiSecretKey(key))
            atRiskDownloads = emitComfyuiChange({});
        if (isAllowedAgentSecretKey(key))
            emitAgentChange();
    }
    return {
        changed,
        atRiskDownloads,
        lostKeys: outcome.lostKeys,
        lostCommentLines: outcome.lostCommentLines,
        ...(outcome.lossCheck === "unknown"
            ? {
                uncertainty: outcome.lossCheckNote ??
                    `the whole-file check could not be completed, so whether removing "${key}" cost anything else is not established`,
            }
            : {}),
        ...(outcome.durabilityGap ? { durabilityGap: outcome.durabilityGap } : {}),
        ...(resurrectionRisk ? { resurrectionRisk } : {}),
    };
}
/**
 * One-time migration to the canonical .env: any flat token still living in the
 * legacy panel-secrets.json (comfyuiEnv / agentEnv) is upserted into .env unless
 * .env / a real env var already provides it. NON-DESTRUCTIVE — it only ADDS
 * missing keys; it never rewrites unrelated .env lines and never deletes from the
 * JSON store (left inert). Idempotent. Returns the keys migrated.
 *
 * This runs at BOOT, where there is no receipt to hand anything back on — so
 * what the write cost is LOGGED rather than dropped. It was being discarded
 * outright, and the key then reported as migrated and hydrated into process.env,
 * so a migration that destroyed another credential looked like a clean start
 * (codex gate). Key NAMES only ever reach the log.
 */
export function migrateSecretsToEnv() {
    const s = read();
    const migrated = [];
    for (const map of [s.comfyuiEnv, s.agentEnv]) {
        if (!map || typeof map !== "object")
            continue;
        for (const [k, v] of Object.entries(map)) {
            if (typeof v !== "string" || !v)
                continue;
            if (!isAllowedSecretKey(k))
                continue;
            if (process.env[k])
                continue; // .env / real env already wins
            const outcome = upsertEnvFile(k, v);
            if (outcome.lostKeys.length) {
                logger.warn(`[panel-secrets] migrating ${k} into ${envFilePath()} lost ${outcome.lostKeys.join(", ")} — ` +
                    `inspect that file and restore the missing entries before relying on it.`);
            }
            else if (outcome.lossCheck === "unknown") {
                logger.warn(`[panel-secrets] migrating ${k} into ${envFilePath()}: whether it cost any other credential is NOT established — ` +
                    `${outcome.lossCheckNote ?? "the whole-file check could not be completed"}.`);
            }
            if (outcome.durabilityGap) {
                logger.warn(`[panel-secrets] migrating ${k}: ${outcome.durabilityGap}`);
            }
            // VERIFY OUR OWN KEY, exactly as the setter does. The migration never
            // did: it wrote, then reported the key migrated and hydrated it into
            // process.env from the value it happened to hold — so a competitor that
            // replaced the file after the rename produced a "migrated" key that the
            // resolver, reading the file, treats as absent (codex gate). A boot line
            // saying a credential is in place while nothing can resolve it is the
            // #826 shape at startup.
            const readBack = parseEnvFile();
            if (readBack === null || readBack[k] !== v) {
                logger.warn(`[panel-secrets] ${k} was written to ${envFilePath()} during migration, but ` +
                    `${readBack === null ? "the file could not be re-read" : "it does not read back with that value"} — ` +
                    `NOT reporting it as migrated. The legacy entry is untouched, so the next start retries.`);
                continue; // no process.env, no file-derived mark, not counted as migrated
            }
            process.env[k] = v;
            // The canonical file is this value's AUTHORITY now — it lives there. Without
            // the mark it would read as SHELL-provided, so buildComfyuiMcpEnv would
            // inject it to the child unmarked and the child would pin it: a later
            // rotate or revoke invisible to that child while the save reports live
            // pickup (codex gate, round 3, finding 1).
            markFileDerived(k);
            migrated.push(k);
        }
    }
    return migrated;
}
// SANITIZE on every write: copy only the five known status fields and coerce
// their types. Even a hand-edited or corrupt panel-secrets.json therefore can
// never inject anything beyond this shape into the mirror — critically, it
// can never smuggle in token material via an unexpected key.
function sanitizeOAuthStatus(rec) {
    const out = {
        provider: String(rec?.provider ?? "").trim(),
        account_label: String(rec?.account_label ?? "").trim(),
        obtained_at: typeof rec?.obtained_at === "number" && Number.isFinite(rec.obtained_at)
            ? rec.obtained_at
            : Date.now(),
    };
    if (typeof rec?.expires_at === "number" && Number.isFinite(rec.expires_at)) {
        out.expires_at = rec.expires_at;
    }
    if (typeof rec?.experimental === "boolean") {
        out.experimental = rec.experimental;
    }
    return out;
}
/** Upsert the status-only OAuth mirror entry for `rec.provider`. Sanitizes the
 *  record first (see `sanitizeOAuthStatus`) — callers pass status fields only,
 *  never token material. */
export function setOAuthStatus(rec) {
    const sanitized = sanitizeOAuthStatus(rec);
    if (!sanitized.provider)
        throw new Error("setOAuthStatus: record is missing a provider id.");
    const secrets = read();
    const status = secrets.oauthStatus && typeof secrets.oauthStatus === "object" ? secrets.oauthStatus : {};
    status[sanitized.provider] = sanitized;
    secrets.oauthStatus = status;
    write(secrets);
}
/** All stored OAuth status records (re-sanitized on read, defense in depth).
 *  `home` scopes the read — see `panelSecretsPath` (#859). */
export function listOAuthStatus(home) {
    const status = read(home).oauthStatus;
    if (!status || typeof status !== "object")
        return [];
    return Object.values(status).map(sanitizeOAuthStatus);
}
/** Remove a provider's status mirror entry. No-op if absent. */
export function clearOAuthStatus(provider) {
    const secrets = read();
    const status = secrets.oauthStatus;
    if (!status || typeof status !== "object" || !(provider in status))
        return;
    delete status[provider];
    secrets.oauthStatus = status;
    write(secrets);
}
/** The persisted env vars to inject into the comfyui MCP server. Never logged.
 *  FILTERED through the allowlist (defense in depth): even a hand-edited/corrupt
 *  panel-secrets.json can only ever contribute allowlisted credential keys. */
export function loadComfyuiSecretEnv() {
    // Resolved through the env-file snapshot, allowlist-filtered: a real env var
    // still wins, otherwise the canonical .env is re-read NOW. Reading raw
    // process.env here would inject a value that went stale when another writer
    // rotated the file, and the child would then pin that stale copy (codex gate,
    // round 2, finding 2). ONE snapshot for all keys, so a rotation landing
    // mid-build cannot produce a half-old/half-new env (round 4, finding 6).
    return freshSecretValues(COMFYUI_SECRET_ENV_ALLOWLIST);
}
/** The env-var KEYS currently stored (e.g. for a redacted log line). No values. */
export function comfyuiSecretKeys() {
    return Object.keys(loadComfyuiSecretEnv());
}
/**
 * Persist a secret as an env var for the built-in comfyui MCP server, then emit
 * a change so the orchestrator re-injects it and respawns the server. `value` is
 * the raw secret (the caller already applied any prefix); it is never logged.
 */
export function setComfyuiSecret(key, value, opts = {}) {
    const trimmed = key.trim();
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) {
        throw new Error(`Invalid env var name "${key}" — use a valid shell identifier (letters, digits, underscore).`);
    }
    if (!isAllowedComfyuiSecretKey(trimmed)) {
        // SECURITY: never let an arbitrary key reach the comfyui Node child's env.
        throw new Error(`Env var "${trimmed}" is not an accepted comfyui tool secret. Allowed: ${COMFYUI_SECRET_ENV_ALLOWLIST.join(", ")}.`);
    }
    // `opts.requested` is set ONLY by panel_request_secret (an agent-driven token
    // ask) so the orchestrator can nudge "retry the action"; the Settings slot
    // save path (setPanelSecret) omits it → no spurious nudge (#164).
    return setEnvSecret(trimmed, value, opts); // canonical store = ~/.comfyui-mcp/.env
}
/** Remove a stored comfyui secret. Returns false if absent. Emits on removal. */
export function removeComfyuiSecret(key) {
    return removeEnvSecret(key);
}
/** The persisted agent-provider secrets (e.g. OPENROUTER_API_KEY), filtered
 *  through the agent allowlist. Never logged. */
export function loadAgentSecretEnv() {
    // Same access-time resolution as loadComfyuiSecretEnv, from one snapshot: env
    // wins, else the canonical .env re-read now, so the readiness/masked-slot
    // views never report a value the file no longer carries.
    return freshSecretValues(AGENT_SECRET_ENV_ALLOWLIST);
}
/**
 * Copy stored agent secrets into process.env so every in-process reader
 * (openrouterDeps, backendReadiness, the ollama key fallback) sees one source
 * of truth. An EXPLICIT env value WINS — the shell/.env stays the escape hatch;
 * the store only fills what env didn't provide. Called at orchestrator startup
 * and whenever an agent secret changes. Returns the keys it hydrated.
 */
export function hydrateAgentSecretsIntoEnv() {
    // Canonical secrets already come from ~/.comfyui-mcp/.env (dotenv at boot). This
    // now performs the one-time, non-destructive migration of any legacy tokens
    // still in panel-secrets.json into .env, so everything converges to one place.
    // Idempotent — a no-op once migrated. (Kept this name so the boot/agent-change
    // callers are unchanged.)
    return migrateSecretsToEnv();
}
/** Subscribe to "an agent provider secret changed". Returns an unsubscribe fn. */
export function onAgentSecretsChanged(cb) {
    emitter.on("agentChange", cb);
    return () => {
        emitter.off("agentChange", cb);
    };
}
/**
 * Persist an agent-provider secret (e.g. OPENROUTER_API_KEY) to the 0600 store
 * and hydrate it into process.env immediately, then emit so the orchestrator
 * re-probes readiness / re-pushes the model list. Rejects non-allowlisted keys.
 */
export function setAgentSecret(key, value) {
    const trimmed = key.trim();
    if (!isAllowedAgentSecretKey(trimmed)) {
        throw new Error(`Env var "${trimmed}" is not an accepted agent secret. Allowed: ${AGENT_SECRET_ENV_ALLOWLIST.join(", ")}.`);
    }
    return setEnvSecret(trimmed, value); // canonical store = ~/.comfyui-mcp/.env
}
/** Remove a stored agent secret. Returns false if absent. Also drops it from
 *  process.env (setAgentSecret put it there — a revoked key must stop applying
 *  NOW, not on the next restart). Emits on removal. */
export function removeAgentSecret(key) {
    return removeEnvSecret(key);
}
/**
 * Build the comfyui MCP server's spawn env: the orchestrator's `base` env
 * (COMFYUI_URL, progress dir, COMFYUI_PATH…) MERGED with the persisted tool
 * secrets. Secrets win over base on a key clash (a user-supplied token overrides
 * any inherited default). This is THE single env-builder both provider paths
 * (Claude in-process + Codex stdio) use, so a saved secret reaches either.
 */
export function buildComfyuiMcpEnv(base) {
    const secrets = loadComfyuiSecretEnv();
    // The orchestrator constructs the child env instead of inheriting it. Read
    // this non-secret configuration value from the same canonical env file as the
    // rest of the process, so a path added after the orchestrator booted reaches a
    // panel_reload respawn even when it was never present in the parent's env.
    // download-cache.ts remains authoritative for path normalization and its
    // existing volume/containment checks; this forwards the raw configured value.
    const downloadCacheDir = freshEnvValue("COMFYUI_DOWNLOAD_CACHE_DIR");
    // Which of the injected credentials does the canonical FILE own? Those must be
    // marked so the child treats its inherited copy as file-derived and a later
    // rotate/revoke supersedes it (codex gate, round 1, finding 1). Only a
    // SHELL-provided key — a real environment variable, the escape hatch — is left
    // unmarked, so the child keeps pinning it. Provenance, not a value comparison:
    // a value that merely went stale because another writer rotated the file is
    // still file-owned (codex gate, round 2, finding 2).
    const managed = Object.keys(secrets).filter((k) => !isShellProvided(k));
    const out = {
        // The orchestrator constructs the tool child's env instead of inheriting
        // process.env. Forward the split-install code root here, at the choke point
        // shared by every provider lane, or panel-spawned MCP servers silently lose
        // the setting even though direct stdio launches honor it. A call-scoped base
        // may still override this value in the future.
        ...(process.env.COMFYUI_CODE_PATH
            ? { COMFYUI_CODE_PATH: process.env.COMFYUI_CODE_PATH }
            : {}),
        ...base,
        // PIN the canonical dotenv path into the child (#826). The child resolves
        // credentials from that file at access time, so if the orchestrator writes
        // to a non-default path (COMFYUI_MCP_ENV_FILE) while the child resolves the
        // default one, a saved token is written to a file nothing reads — a "saved
        // successfully" that the tools can never see. Forwarding the override makes
        // writer and reader the same file by construction. Not a secret: a path.
        ...(process.env.COMFYUI_MCP_ENV_FILE
            ? { COMFYUI_MCP_ENV_FILE: process.env.COMFYUI_MCP_ENV_FILE }
            : {}),
        ...(downloadCacheDir !== undefined
            ? { COMFYUI_DOWNLOAD_CACHE_DIR: downloadCacheDir }
            : {}),
        // #873 — THE OPERATOR'S TOOL-SURFACE POLICY, forwarded HERE because this is the one
        // function BOTH comfyui spawn lanes share.
        //
        // I first put this in comfyuiBaseEnv(), which only the Codex/Gemini lane spreads. The
        // Claude lane — the DEFAULT backend — builds its own literal and calls this function
        // directly, so it kept spawning a child with all 37 tools while the panel surface was
        // correctly withheld and logged as withheld. An operator setting PRESET=readonly got
        // a server that reported itself restricted and still offered restart_comfyui,
        // install_custom_node and download_model.
        //
        // That is the same defect I had just written a commit message about — fixing one of
        // two registration paths and testing only the one I fixed — committed again, in the
        // same change, one file over. Two call sites is the bug; the choke point is the fix.
        // The child's env is CONSTRUCTED rather than inherited (a spread cannot remove a
        // revoked credential, see below), so an unforwarded variable simply does not exist
        // over there.
        ...(process.env.COMFYUI_MCP_TOOL_PRESET
            ? { COMFYUI_MCP_TOOL_PRESET: process.env.COMFYUI_MCP_TOOL_PRESET }
            : {}),
        ...(process.env.COMFYUI_MCP_TOOL_ALLOW
            ? { COMFYUI_MCP_TOOL_ALLOW: process.env.COMFYUI_MCP_TOOL_ALLOW }
            : {}),
        ...(process.env.COMFYUI_MCP_TOOL_DENY
            ? { COMFYUI_MCP_TOOL_DENY: process.env.COMFYUI_MCP_TOOL_DENY }
            : {}),
        ...(process.env.COMFYUI_MCP_TOOL_ACTION_ALLOW !== undefined
            ? { COMFYUI_MCP_TOOL_ACTION_ALLOW: process.env.COMFYUI_MCP_TOOL_ACTION_ALLOW }
            : {}),
        // KEY NAMES only — never values.
        ...(managed.length ? { [MANAGED_SECRET_KEYS_ENV]: managed.join(",") } : {}),
        ...secrets,
    };
    // The RESOLVER is authoritative for the WHOLE allowlist, not just for the keys
    // it happens to supply. Object spreading can only add and overwrite — it can
    // never REMOVE — so a credential the caller copied into `base` from raw
    // process.env survived an external revoke that `loadComfyuiSecretEnv` had
    // correctly dropped, and the next child inherited the stale token unmarked and
    // treated it as a real env override (coordinator finding). Deleting what the
    // resolver did not provide makes a revoke actually revoke, by construction.
    for (const key of COMFYUI_SECRET_ENV_ALLOWLIST) {
        if (!(key in secrets))
            delete out[key];
    }
    return out;
}
// ── Agent-provider spawn env (tool-secret scoping) ───────────────────────────
// Tool secrets (RunPod/HF/CivitAI/RunComfy/Registry tokens…) live in process.env
// because config.ts loads ~/.comfyui-mcp/.env at boot and setEnvSecret applies
// live. That is CORRECT for the comfyui tool child (buildComfyuiMcpEnv), but the
// agent-provider subprocesses (Codex app-server, Gemini/Grok CLI…) must NEVER
// inherit them — a tool credential has no business in an LLM vendor's process.
// buildAgentSpawnEnv is the single spawn-env builder those backends use: a copy
// of process.env with every TOOL-ONLY secret key stripped.
/** Secret env keys that are TOOL-only (comfyui allowlist minus agent allowlist)
 *  — these must never reach an agent-provider subprocess's env. */
export const TOOL_ONLY_SECRET_ENV_KEYS = COMFYUI_SECRET_ENV_ALLOWLIST.filter((k) => !AGENT_ALLOWLIST_SET.has(k));
/**
 * Build the env an AGENT-PROVIDER subprocess (Codex/Gemini/Grok CLI…) spawns
 * with: `base` (default process.env) with all tool-only secret keys removed.
 * `keep` re-admits specific keys when they double as the provider's OWN
 * credential (e.g. GEMINI_API_KEY for the Gemini CLI — same vendor, not a leak).
 */
export function buildAgentSpawnEnv(base = process.env, opts = {}) {
    const keep = new Set(opts.keep ?? []);
    const out = { ...base };
    for (const k of TOOL_ONLY_SECRET_ENV_KEYS) {
        if (!keep.has(k))
            delete out[k];
    }
    return out;
}
/** UI credential slots. Each slot writes ALL its envKeys (alias fan-out) into its
 *  store. `store` decides which allowlist/setter applies. */
export const CREDENTIAL_SLOTS = [
    { id: "openrouter", label: "OpenRouter", envKeys: ["OPENROUTER_API_KEY"], store: "agent", help: "Hosted models (MiMo, MiniMax, GPT, Claude…)" },
    // The simple api-key providers (glm/kimi/moonshot) are DERIVED from the
    // openai-provider-registry — one entry there feeds its slot here automatically.
    ...OPENAI_KEY_PROVIDERS.map((p) => ({
        id: p.id,
        label: p.slotLabel,
        envKeys: p.envKeys,
        store: "agent",
        // Append the generated model hint so the card says which model the
        // provider is actually on and how to change it — the override env var
        // existed but was invisible outside the source.
        help: `${p.slotHelp} · ${providerModelHint(p)}`,
    })),
    { id: "civitai", label: "Civitai", envKeys: ["CIVITAI_API_TOKEN"], store: "comfyui", help: "Model downloads" },
    { id: "huggingface", label: "HuggingFace", envKeys: ["HF_TOKEN", "HUGGINGFACE_TOKEN"], store: "comfyui", help: "Model downloads" },
    { id: "comfyui-auth-token", label: "ComfyUI gateway token", envKeys: ["COMFYUI_AUTH_TOKEN"], store: "comfyui", help: "Remote ComfyUI / reverse-proxy authentication" },
    { id: "comfyui-auth-header", label: "ComfyUI gateway header", envKeys: ["COMFYUI_AUTH_HEADER"], store: "comfyui", help: "Custom reverse-proxy authentication header" },
    { id: "comfyui-auth-scheme", label: "ComfyUI gateway scheme", envKeys: ["COMFYUI_AUTH_SCHEME"], store: "comfyui", help: "Reverse-proxy authentication scheme" },
    { id: "cf-access-client-id", label: "Cloudflare Access client ID", envKeys: ["CF_ACCESS_CLIENT_ID"], store: "comfyui", help: "Cloudflare Access service token" },
    { id: "cf-access-client-secret", label: "Cloudflare Access client secret", envKeys: ["CF_ACCESS_CLIENT_SECRET"], store: "comfyui", help: "Cloudflare Access service token" },
    { id: "google", label: "Google / Gemini", envKeys: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_API_KEY"], store: "comfyui", help: "Nano Banana concept images" },
    { id: "runcomfy", label: "RunComfy", envKeys: ["RUNCOMFY_API_KEY"], store: "comfyui", help: "Cloud pods / training" },
    { id: "runpod", label: "RunPod", envKeys: ["RUNPOD_API_KEY"], store: "comfyui", help: "Manage GPU pods (status/start/stop/connect)" },
    { id: "registry", label: "Comfy Registry", envKeys: ["REGISTRY_ACCESS_TOKEN"], store: "comfyui", help: "Publishing custom nodes" },
];
const SLOT_BY_ID = new Map(CREDENTIAL_SLOTS.map((s) => [s.id, s]));
/** Mask a secret for display: first 4 + ellipsis + last 3. Short values fully masked. */
export function maskSecret(v) {
    if (v.length <= 8)
        return "•".repeat(v.length);
    return `${v.slice(0, 4)}…${v.slice(-3)}`;
}
/**
 * Set every env key of a slot (alias fan-out) into its store.
 *
 * ALL-OR-NOTHING across the aliases. The fan-out is serial, so a slot like
 * HuggingFace could land HF_TOKEN and then fail on HUGGINGFACE_TOKEN — leaving
 * the first alias live and effective while the caller reported failure and said
 * nothing was half-applied (codex gate, round 4, finding 1). On any failure the
 * aliases this call changed are restored to their previous values, and any that
 * could NOT be restored are reported as stranded rather than glossed over.
 *
 * Throws on unknown slot, and re-throws a per-key validation failure (a control
 * character, an unrepresentable value) AFTER restoring — so the caller still
 * gets the specific reason.
 */
export function setPanelSecret(slotId, value) {
    const slot = SLOT_BY_ID.get(slotId);
    if (!slot)
        throw new Error(`unknown credential slot "${slotId}"`);
    const before = slot.envKeys.map((key) => ({
        key,
        previous: freshSecretValue(key),
        // Provenance matters for the restore: writing a SHELL-provided value back
        // through the setter would persist it into the store and mark it
        // file-derived — restoring the text while silently changing where the value
        // lives and how it resolves (codex gate, round 5, finding 2).
        wasShellProvided: isShellProvided(key),
    }));
    const receipts = [];
    let thrown = null;
    let failed = false;
    let damaged = false;
    let strandedKeys = [];
    let unverifiedKeys = [];
    /** Credentials the ROLLBACK's own writes cost, on top of the fan-out's. */
    const restoreLostKeys = new Set();
    /** Everything else the rollback's writes owe the caller. */
    const restoreWarnings = [];
    /** The aliases this call actually tried to write. The rollback restores ONLY
     *  these: it used to walk the whole alias list, so an alias the fan-out never
     *  reached — one another writer may legitimately have updated in the meantime
     *  — was overwritten with a stale snapshot, and because that key is the
     *  restore's own intentional key the clobber was excluded from `lostKeys` and
     *  reported as a successful restore (codex gate). */
    const attempted = new Set();
    /** Aggregated once, here, so no consumer of a slot save has to reach into the
     *  receipts to learn that the store lost credentials. */
    const lostKeysOf = (rs) => [
        ...new Set(rs.flatMap((r) => r.lostKeys ?? [])),
    ];
    const outcome = withSuspendedEmissions(() => {
        for (const key of slot.envKeys) {
            try {
                // Recorded BEFORE the call: a write that throws part-way may still
                // have touched the store, so "attempted" is the set the rollback is
                // allowed to act on — never the whole alias list.
                attempted.add(key);
                const receipt = setEnvSecret(key, value);
                receipts.push(receipt);
                if (receipt.persisted === "damaged") {
                    // The store lost OTHER credentials. Stop the fan-out — but do NOT
                    // roll back: a rollback cannot bring the lost keys back, and it is
                    // one more destructive rewrite of a store already known to be
                    // damaged. What the user needs is the truth about the state we are
                    // in, not another write on top of it.
                    damaged = true;
                    break;
                }
                if (receipt.persisted !== "yes") {
                    // "unknown" is not a proven failure, but it is not a proven success
                    // either — stop rather than layering more unverified writes on it.
                    failed = true;
                    break;
                }
            }
            catch (err) {
                thrown = err;
                failed = true;
                break;
            }
        }
        if (damaged) {
            return {
                slot: slotId,
                receipts,
                // Derived from the receipts, so a damaged one can never read as
                // confirmed anywhere: `slotSaveConfirmed` is this field.
                confirmed: false,
                rolledBack: false,
                strandedKeys: [],
                unverifiedKeys: [],
                lostKeys: lostKeysOf(receipts),
                // Filled in after the suspended emit below — it has not happened yet here.
                atRiskDownloads: [],
            };
        }
        if (!failed) {
            return {
                slot: slotId,
                receipts,
                confirmed: true,
                rolledBack: false,
                strandedKeys: [],
                unverifiedKeys: [],
                lostKeys: [],
                atRiskDownloads: [],
            };
        }
        // Restore every alias this call actually changed, and PROVE each restore
        // from the store rather than from freshSecretValue — which falls back to
        // the in-process copy when the store is unreadable and would therefore
        // "confirm" a restore that never reached disk.
        for (const { key, previous, wasShellProvided } of before) {
            // An alias this call never wrote may be reconciled IN PROCESS (that is
            // free and cannot clobber anything), but it must never be WRITTEN back:
            // another ordinary writer may have updated it while we were failing, and
            // the snapshot in hand is older than that.
            const { state, lostKeys, disclosures } = restoreEnvKey(key, previous, wasShellProvided, {
                mayWrite: attempted.has(key),
            });
            if (state === "stranded")
                strandedKeys.push(key);
            else if (state === "unverified")
                unverifiedKeys.push(key);
            // A ROLLBACK is a store write like any other, so it can lose credentials
            // like any other — and dropping that here because the headline is
            // already a failure would hide the worse half of what happened. Same
            // rule, same place, no second mechanism.
            for (const k of lostKeys)
                restoreLostKeys.add(k);
            restoreWarnings.push(...disclosures);
        }
        return {
            slot: slotId,
            receipts,
            confirmed: false,
            rolledBack: strandedKeys.length === 0 && unverifiedKeys.length === 0,
            strandedKeys,
            unverifiedKeys,
            lostKeys: [...new Set([...lostKeysOf(receipts), ...restoreLostKeys])],
            atRiskDownloads: [],
            ...(restoreWarnings.length ? { restoreWarnings } : {}),
        };
    }, (result) => {
        // Emit ONCE, for the state actually left. A save that failed and was fully
        // rolled back changed nothing, so it must not respawn anything.
        const changed = !!result && (result.confirmed || !result.rolledBack);
        if (!changed)
            return {};
        // Keyed off the SLOT, not off whether a per-key emit happened to be
        // suppressed during the fan-out: a proven-failed write returns before
        // emitting, so a slot left partially STRANDED would suppress nothing and
        // therefore emit nothing — leaving a child that needs a respawn to see the
        // change running on its old spawn env until something unrelated restarted
        // it (codex gate, final round).
        return {
            comfyui: slot.store === "comfyui" ? {} : false,
            agent: slot.store === "agent",
        };
    });
    // WHAT THE SINGLE EMIT COST (#1378). The fan-out suspends per-alias emits and makes one
    // at the end, inside `withSuspendedEmissions`' finally — which cannot change what the
    // block returns, so the snapshot is read back here. A rolled-back save emits nothing and
    // this is empty, which is the point: it must not warn about a loss that did not happen.
    outcome.atRiskDownloads = takeSuspendedAtRisk();
    if (thrown) {
        // The rethrow is a refusal — correct, because the value the caller supplied
        // is not in place. But the ROLLBACK on the way out is a store write, and
        // what it cost must not be swallowed by that refusal: `restoreLostKeys` was
        // collected and then discarded here, so a rollback that destroyed another
        // credential vanished behind the original validation error (codex gate).
        const damageOnTheWayOut = [
            ...(restoreLostKeys.size
                ? [
                    `🛑 Rolling the slot back also lost ${[...restoreLostKeys].join(", ")} from ${envFilePath()} — ` +
                        `restore that file before relying on anything in it.`,
                ]
                : []),
            ...restoreWarnings,
        ];
        const mixed = [...strandedKeys, ...unverifiedKeys];
        if (mixed.length || damageOnTheWayOut.length) {
            throw new Error(`${thrown instanceof Error ? thrown.message : String(thrown)} ` +
                `${damageOnTheWayOut.length ? `${damageOnTheWayOut.join(" ")} ` : ""}` +
                `${mixed.length
                    ? `Restoring the slot could not be confirmed for ${mixed.join(", ")}, so it may be in a MIXED state. Set "${slotId}" again, or clear it, before relying on it.`
                    : `The slot itself was restored.`}`);
        }
        throw thrown;
    }
    return outcome;
}
/**
 * Put one alias key back to its pre-save state, and say whether that is PROVEN.
 *   "restored"   — the store agrees the key is back to `previous`.
 *   "stranded"   — the store proves it still carries the new value.
 *   "unverified" — the store could not be read, so neither is established.
 * Never collapses "unverified" into either verdict.
 *
 * Also returns any OTHER credentials the restore's own writes cost. A rollback
 * is a store rewrite like any other and can lose keys like any other; the
 * caller folds these into the slot outcome so a rollback's damage travels the
 * same path as a save's rather than needing its own reporting.
 */
function restoreEnvKey(key, previous, wasShellProvided, opts = { mayWrite: true }) {
    const lostKeys = new Set();
    // EVERYTHING the rollback's own writes owe the caller — not just lost keys.
    // A rollback that dropped comments, could not be made durable, or could not
    // complete its loss account is the user's data affected by an operation they
    // never asked for, and it was being discarded (codex gate).
    const disclosures = [];
    const done = (state) => ({
        state,
        // A key this restore is itself putting back is not collateral.
        lostKeys: [...lostKeys].filter((k) => k !== key),
        disclosures,
    });
    // Already as it was? Then this call never changed it — rewriting would be a
    // pointless write that can itself fail and manufacture a "stranded" verdict
    // for a key that was never touched.
    const current = parseEnvFile();
    if (current !== null) {
        const wanted = wasShellProvided || previous === undefined ? undefined : previous;
        if (current[key] === wanted) {
            // Keep the in-process copy AND the provenance in step with the store.
            // Both directions matter: a shell value must stop being file-owned, and a
            // value the FILE supplies must be marked file-owned — otherwise a key the
            // file gained after boot would be treated as a real env override from here
            // on, and a later legitimate rotation of that file entry would be masked
            // (codex gate, round 10).
            if (previous === undefined)
                delete process.env[key];
            else
                process.env[key] = previous;
            if (wasShellProvided)
                unmarkFileDerived(key);
            else if (previous !== undefined)
                markFileDerived(key);
            return done("restored");
        }
    }
    // Past the no-write reconciliation above, every remaining path WRITES the
    // store. For an alias this call never touched that is not a restore, it is a
    // clobber: the value on disk is newer than the snapshot in hand, and putting
    // the snapshot back would overwrite another writer's legitimate update — and
    // because this key is the restore's own intentional key, the loss would be
    // excluded from `lostKeys` and reported as a successful restore (codex gate).
    if (!opts.mayWrite)
        return done("skipped");
    try {
        if (wasShellProvided) {
            // The value belongs to the ENVIRONMENT, not to the store: drop whatever we
            // wrote and hand precedence back, rather than persisting a shell secret.
            const out = removeEnvFileKey(key);
            for (const k of out.lostKeys)
                lostKeys.add(k);
            if (out.lossCheck === "unknown" && out.lossCheckNote) {
                disclosures.push(`⚠️ Rolling "${key}" back: ${out.lossCheckNote}.`);
            }
            if (out.durabilityGap) {
                disclosures.push(`⚠️ Rolling "${key}" back was not made crash-durable: ${out.durabilityGap}.`);
            }
            if (out.lostCommentLines > 0) {
                disclosures.push(`⚠️ Rolling "${key}" back cost ${out.lostCommentLines} comment line${out.lostCommentLines > 1 ? "s" : ""} in the store.`);
            }
            if (previous === undefined)
                delete process.env[key];
            else
                process.env[key] = previous;
            unmarkFileDerived(key);
        }
        else if (previous === undefined) {
            const out = removeEnvSecret(key);
            for (const k of out.lostKeys)
                lostKeys.add(k);
            disclosures.push(...removeDisclosures(out, envFilePath()).map((d) => `Rolling "${key}" back: ${d}`));
        }
        else {
            const receipt = setEnvSecret(key, previous);
            for (const k of receipt.lostKeys ?? [])
                lostKeys.add(k);
            disclosures.push(...receiptDisclosures(receipt).map((d) => `Rolling "${key}" back: ${d}`));
        }
    }
    catch {
        return done("stranded");
    }
    const parsed = parseEnvFile();
    if (parsed === null)
        return done("unverified");
    const inStore = parsed[key];
    if (wasShellProvided)
        return done(inStore === undefined ? "restored" : "stranded");
    if (previous === undefined)
        return done(inStore === undefined ? "restored" : "stranded");
    return done(inStore === previous ? "restored" : "stranded");
}
/** True when EVERY key of a slot save is proven to have landed. A single alias
 *  that did not persist means the slot is not reliably configured — the reader
 *  that happens to consult that alias would find nothing. */
export function slotSaveConfirmed(outcome) {
    return outcome.confirmed;
}
/** The keys of a slot save whose persistence could NOT be confirmed, with their
 *  verdict — for an honest answer that names what is uncertain. No values. */
export function unconfirmedSlotKeys(receipts) {
    return receipts
        .filter((r) => r.persisted !== "yes")
        .map((r) => ({ key: r.key, persisted: r.persisted }));
}
/** Clear a slot: remove EVERY env key (alias fan-out, mirroring setPanelSecret)
 *  from its store. Throws on unknown slot — that is a refusal BEFORE anything is
 *  removed, which is the only place a refusal belongs on this path. Everything
 *  the removals cost is DISCLOSED in the returned outcome instead.
 *  This is the revoke path (issue #203) — without it a saved key could only be
 *  overwritten, never removed, short of hand-editing panel-secrets.json. */
export function clearPanelSecret(slotId) {
    const slot = SLOT_BY_ID.get(slotId);
    if (!slot)
        throw new Error(`unknown credential slot "${slotId}"`);
    const remove = slot.store === "agent" ? removeAgentSecret : removeComfyuiSecret;
    let changed = false;
    const lostKeys = new Set();
    let lostCommentLines = 0;
    const uncertainties = [];
    const resurrectionRisks = [];
    let durabilityGap;
    const failures = [];
    const atRisk = new Map();
    let firstError = null;
    for (const key of slot.envKeys) {
        let out;
        try {
            out = remove(key);
        }
        catch (err) {
            // The fan-out is SERIAL, so an earlier alias may already be gone. Letting
            // this throw discarded that completed removal entirely — the console saw
            // only a generic failure, with no `cleared` and no "do not retry"
            // (codex gate). Record it and carry on: the outcome reports both what was
            // removed and what could not be.
            if (firstError === null)
                firstError = err;
            failures.push(`${key} (${err instanceof Error ? err.message : String(err)})`);
            continue;
        }
        changed = out.changed || changed;
        if (out.resurrectionRisk)
            resurrectionRisks.push(out.resurrectionRisk);
        // A key this fan-out itself removes is not collateral — the aliases of one
        // slot are all being cleared on purpose.
        for (const k of out.lostKeys)
            if (!slot.envKeys.includes(k))
                lostKeys.add(k);
        lostCommentLines += out.lostCommentLines;
        if (out.uncertainty)
            uncertainties.push(out.uncertainty);
        if (out.durabilityGap && !durabilityGap)
            durabilityGap = out.durabilityGap;
        // Aggregated, and deduped by filename (#1378). The fan-out is SERIAL, so the FIRST
        // alias whose removal emits is the one that catches the transfers still running; by
        // the second the session is already replaced and its snapshot is empty. Keeping the
        // largest byte count means the report never understates what was in flight.
        for (const d of out.atRiskDownloads) {
            // Keyed on the job id, NOT the displayed filename (codex P2): two credential-shaped
            // names both redact to "(redacted).safetensors", and keying on that reported one
            // download and the larger byte count where there were two and a total.
            const seen = atRisk.get(d.id);
            if (!seen || d.bytes > seen.bytes)
                atRisk.set(d.id, d);
        }
    }
    // Nothing was removed and something failed: nothing happened, so REFUSING is
    // correct and the caller's "it is still there" is true. Anything else is a
    // disclosure about a revoke that partly ran.
    if (!changed && firstError !== null)
        throw firstError;
    return {
        changed,
        atRiskDownloads: [...atRisk.values()],
        ...(failures.length
            ? {
                incomplete: `The revoke could not be completed for ${failures.join(", ")}. ` +
                    `What WAS removed has already been removed — do not simply repeat the revoke; check which aliases are still present first.`,
            }
            : {}),
        lostKeys: [...lostKeys],
        lostCommentLines,
        ...(uncertainties.length ? { uncertainty: uncertainties.join(" ") } : {}),
        ...(durabilityGap ? { durabilityGap } : {}),
        ...(resurrectionRisks.length ? { resurrectionRisk: resurrectionRisks.join(" ") } : {}),
    };
}
/** Whether a slot still resolves to a credential — read AFTER a revoke to prove
 *  the clear actually took effect rather than reporting the removal of one line
 *  while another form of the assignment still supplies the value. */
export function slotStillResolves(slotId) {
    const slot = SLOT_BY_ID.get(slotId);
    if (!slot)
        throw new Error(`unknown credential slot "${slotId}"`);
    return slot.envKeys.some((k) => freshSecretValue(k) !== undefined);
}
/**
 * The revoke's VERIFIED end state:
 *   "gone"           — the store is readable and carries no alias of this slot.
 *   "still-resolves" — an alias still supplies a credential.
 *   "unknown"        — the store could not be read, so neither is established.
 *
 * `slotStillResolves` alone is not enough here: after a revoke deletes this
 * process's copy, an unreadable store makes every alias resolve to undefined —
 * which reads as "gone" while a child that CAN read the file still finds the old
 * credential there. Reporting a revoke on that basis is a false acknowledgement
 * (codex gate, round 5, finding 3).
 */
export function slotRevokeState(slotId) {
    const slot = SLOT_BY_ID.get(slotId);
    if (!slot)
        throw new Error(`unknown credential slot "${slotId}"`);
    const parsed = parseEnvFile();
    if (parsed === null)
        return "unknown"; // the store may still carry it; we cannot tell
    if (slot.envKeys.some((k) => typeof parsed[k] === "string" && parsed[k].trim())) {
        return "still-resolves";
    }
    return slotStillResolves(slotId) ? "still-resolves" : "gone";
}
/** The slot's keys currently supplied by a REAL environment variable. Read
 *  BEFORE a revoke: this store can delete them from THIS process, but not from
 *  the environment the process was started with, so they come back on the next
 *  start. Calling that a completed revoke would be a state we did not reach. */
export function slotShellProvidedKeys(slotId) {
    const slot = SLOT_BY_ID.get(slotId);
    if (!slot)
        throw new Error(`unknown credential slot "${slotId}"`);
    return slot.envKeys.filter((k) => isShellProvided(k));
}
/** Masked per-slot state: set = ANY of the slot's alias env keys resolves.
 *  Checking only the PRIMARY alias reported a slot as unset when only its legacy
 *  alias was configured (e.g. HUGGINGFACE_TOKEN without HF_TOKEN) — a "not
 *  configured" verdict for a credential that is in effect (codex gate, round 4,
 *  answer B). The mask shows the alias that actually resolves. */
export function listPanelSecretsMasked() {
    return CREDENTIAL_SLOTS.map((slot) => {
        // Resolved with the SAME alias precedence a reader uses (freshSecretValue
        // over the whole alias list), not by resolving each alias independently and
        // taking the first: those differ — a shell-set legacy alias outranks a
        // file-set canonical one for a reader, so picking per-alias would display a
        // token consumers are not using (codex gate, round 5, finding 6).
        const val = freshSecretValue(...slot.envKeys);
        return { id: slot.id, label: slot.label, set: !!val, masked: val ? maskSecret(val) : null };
    });
}
//# sourceMappingURL=panel-secrets.js.map