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),
205 lines • 9.82 kB
JavaScript
// #1300 — a bare status code cost a reporter four attempts.
//
// `download_model action:"download"` reported `Download failed: 404 ` and nothing
// else. The host had explained itself in the response body, and we discarded it;
// the CivitAI token hint existed but was gated on 401/403, and CivitAI answered
// the unauthenticated request with 404 (curl saw 401 for the same URL). So a
// missing-token failure and a wrong-URL failure arrived as the same sentence,
// and the one message that would have solved it on attempt one stayed silent.
//
// Both halves live here so the branches are testable without a network.
import { scrubSecretShapedText } from "../comfyui/json-guard.js";
/** Longest error body we will quote back. */
const MAX_BODY_CHARS = 400;
/** Bytes we are willing to READ before giving up on the explanation. The output
* cap alone is not a bound: `res.text()` drains the whole response first, so a
* huge or never-ending error body could hang the download or exhaust memory
* while we were busy improving its error message (codex). */
const MAX_BODY_BYTES = 8 * 1024;
/** And a clock, because a body can stall without ending. */
const BODY_READ_TIMEOUT_MS = 3_000;
/** Read at most MAX_BODY_BYTES from the stream, then STOP — and cancel, so the
* socket is not left draining a body nobody is going to read. */
async function readCapped(source) {
const reader = source.body?.getReader?.();
if (!reader) {
if (typeof source.text !== "function")
return "";
// No stream to cap; bound the wait instead, and let the body be collected.
return await Promise.race([
source.text(),
new Promise((resolve) => setTimeout(() => resolve(""), BODY_READ_TIMEOUT_MS)),
]);
}
const chunks = [];
let total = 0;
const deadline = Date.now() + BODY_READ_TIMEOUT_MS;
try {
for (;;) {
if (Date.now() > deadline)
break;
const { done, value } = await reader.read();
if (done)
break;
if (value?.length) {
chunks.push(value);
total += value.length;
if (total >= MAX_BODY_BYTES)
break;
}
}
}
finally {
try {
await reader.cancel?.();
}
catch {
/* the response is already an error; cancelling is best effort */
}
}
if (!chunks.length)
return "";
const joined = new Uint8Array(total);
let at = 0;
for (const c of chunks) {
joined.set(c.subarray(0, Math.min(c.length, total - at)), at);
at += c.length;
if (at >= total)
break;
}
return new TextDecoder().decode(joined.subarray(0, Math.min(at, MAX_BODY_BYTES)));
}
/**
* The server's own explanation, bounded and safe to show.
*
* SCRUBBED because an auth challenge is exactly the kind of body that echoes a
* credential back, and this string lands in a tool result and the logs. Two
* passes, because they catch different things:
*
* 1. the shared scrubber — every credential this process is configured with,
* plus any opaque run long enough to be one;
* 2. the REQUEST URL's own query values (codex). A signed or pre-authenticated
* download URL carries its credential in the query string, and a host that
* reflects the request back would otherwise republish it. Those values are
* ours and we know them exactly, so they are redacted by identity rather
* than by shape.
*
* SCRUB THEN BOUND, in that order: truncating first could cut a secret in half
* and leave 400 characters of it in the message.
*
* NEVER THROWS: a diagnostic that fails must not replace the status we already
* have with an error about the diagnostic.
*/
export async function readErrorBody(source, requestUrl) {
try {
const raw = await readCapped(source);
if (typeof raw !== "string" || !raw.trim())
return "";
let out = scrubSecretShapedText(raw) ?? raw;
out = redactUrlQueryValues(out, requestUrl);
const scrubbed = out.replace(/\s+/g, " ").trim();
if (!scrubbed)
return "";
return scrubbed.length > MAX_BODY_CHARS ? `${scrubbed.slice(0, MAX_BODY_CHARS)}…` : scrubbed;
}
catch {
return "";
}
}
/** Redact any query VALUE from our own request URL that the body echoed back, in
* raw and percent-encoded form. Short values are skipped: `type=UNet` is not a
* secret, and redacting it would destroy the diagnosis this exists to give. */
function redactUrlQueryValues(text, requestUrl) {
if (!requestUrl)
return text;
let out = text;
try {
const url = new URL(requestUrl);
for (const value of url.searchParams.values()) {
if (!value || value.length < 12)
continue;
for (const form of new Set([value, encodeURIComponent(value)])) {
out = out.split(form).join("«redacted»");
}
}
}
catch {
/* an unparseable url contributes no known values */
}
return out;
}
/**
* What to DO about it, keyed on the facts that decide the answer: the host,
* whether a token is configured, and — where it is genuinely informative — the
* status.
*
* WHY NOT GATE THE NO-TOKEN CASE ON THE STATUS. The hint this replaces fired only
* on 401/403, and the reporter got a 404 — so it never appeared. CivitAI requires
* a token for EVERY model download, which makes "no token is configured" worth
* saying on any failing status from that host. Note what that sentence is: a
* statement about OUR configuration, which we know for certain, not a claim about
* what the server meant by its status, which we do not.
*
* WHEN A TOKEN IS SET the status matters, and ignoring it was its own wrong
* answer (codex): a 401/403 with a token configured is precisely the signal for
* an INVALID, expired or insufficient one, and telling that caller "probably not
* an auth failure" would send them off exactly as the original bug did, one
* layer along. Only a 404 gets the URL-form remedy — the case the reporter
* actually established.
*
* WHEN A PER-REQUEST `auth` OVERRIDE WAS USED (#1635) the same logic applies one
* level up: the request already carried a credential — the override REPLACES the
* configured-token handling (downloadModel attaches the config token only when no
* `auth` was passed) — so "set CIVITAI_API_TOKEN" is a wrong remedy that sends the
* caller to configure a credential they deliberately bypassed. A 401/403 then
* indicts the OVERRIDE credential, and any other status is probably not an auth
* failure either.
*/
export function downloadFailureHint(opts) {
let host = "";
try {
// A trailing dot is a legitimate spelling of the same FQDN (codex); strip it
// for the comparison only.
host = new URL(opts.url).hostname.replace(/\.$/, "");
}
catch {
return ""; // an unparseable url earns no host-specific advice
}
if (!/(^|\.)civitai\.com$/i.test(host))
return "";
if (!opts.hasCivitaiToken && !opts.callerAuth) {
return (" — NOTE: no CIVITAI_API_TOKEN is configured, and CivitAI requires a token for ALL model " +
"downloads, so that is the most likely cause whatever status came back (an unauthenticated " +
"download answers 401 or 404 depending on the URL form). Set it in panel Settings › " +
"“Set CivitAI token…”, or the env var; create one at civitai.com/user/account. Do NOT retry " +
"other model ids first — they will fail the same way until a token is set.");
}
if (opts.status === 401 || opts.status === 403) {
if (opts.callerAuth) {
return (" — a per-request `auth` override was supplied AND used for this request, so this status " +
"indicts THAT credential, not a missing CIVITAI_API_TOKEN (the override replaced the " +
"configured-token handling for this download): it is INVALID, expired, or lacks access " +
"to this model (early-access and gated models need entitlement, not just any token). " +
"Re-check the credential you passed, and whether this model requires purchase or " +
"early-access on its page.");
}
return (" — a CIVITAI_API_TOKEN is configured, and this status is what CivitAI returns for a token " +
"that is INVALID, expired, or lacks access to this model (early-access and gated models " +
"need entitlement, not just any token). Re-create the token at civitai.com/user/account and " +
"set it again, and check whether this model requires purchase or early-access on its page.");
}
const isMetadataQuery = /[?&](type|format|fp|size)=/i.test(opts.url);
const fileIdTip = opts.status === 404 && isMetadataQuery
? " This URL selects a file by METADATA (type/format/fp). CivitAI's own API returns that " +
"form, but it can 404 even with a valid token when a version publishes several files — " +
"address the file directly instead: " +
"https://civitai.com/api/download/models/<versionId>?fileId=<fileId>, taking fileId from " +
"that version's `files[]` in the CivitAI API."
: "";
if (opts.callerAuth) {
return (" — a per-request `auth` override was supplied AND used for this request, so this is " +
`probably not a missing-token failure.${fileIdTip}`);
}
return ` — a CIVITAI_API_TOKEN IS configured, so this is probably not an auth failure.${fileIdTip}`;
}
//# sourceMappingURL=download-failure-hint.js.map