comfyui-mcp 0.48.20 → 0.48.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { createWriteStream, constants as fsConstants } from "node:fs";
3
- import { copyFile, link, mkdir, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
3
+ import { copyFile, link, mkdir, open, readFile, readdir, rename, rm, stat, utimes, writeFile, } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
- import { basename, extname, join, resolve } from "node:path";
5
+ import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { Readable, Transform } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
8
  import { ModelError } from "../utils/errors.js";
@@ -24,6 +24,156 @@ async function safeRm(path) {
24
24
  /* best effort */
25
25
  }
26
26
  }
27
+ /** Remove `path`, returning true iff it is confirmed gone. Unlike `safeRm` this
28
+ * LOGS a removal failure (EPERM/EACCES/…) instead of swallowing it silently — a
29
+ * left-behind rejected artifact (a poisoned partial / cache entry) must at least be
30
+ * visible, since a later cache-hit or resume could otherwise re-hit it (#473 P1). */
31
+ async function rmOrLog(path, logUrl) {
32
+ try {
33
+ await rm(path, { force: true });
34
+ return true;
35
+ }
36
+ catch (err) {
37
+ logger.warn(`Failed to remove a rejected download artifact "${path}" — a retry may re-hit it; ` +
38
+ `delete it manually if the same rejection repeats.`, { url: logUrl, error: err instanceof Error ? err.message : String(err) });
39
+ return false;
40
+ }
41
+ }
42
+ /** Remove `path`; if removal fails, NEUTRALIZE it by truncating to 0 bytes. Returns
43
+ * true iff the path ends up removed OR emptied. A 0-byte partial is treated as fresh
44
+ * (not resumed) and a 0-byte cache/sidecar entry carries no resumable state, so
45
+ * zeroing is as safe as deleting. Logs (error) only when it can do NEITHER (#473 P1). */
46
+ async function rmOrTruncate(path, logUrl) {
47
+ if (await rmOrLog(path, logUrl))
48
+ return true;
49
+ try {
50
+ await writeFile(path, "");
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ logger.error(`Could not remove OR truncate a rejected download artifact "${path}"; a retry may re-serve ` +
55
+ `or resume it. Delete this file manually.`, { url: logUrl, error: err instanceof Error ? err.message : String(err) });
56
+ return false;
57
+ }
58
+ }
59
+ /** Discard a payload that was REJECTED as a non-model (HTML/JSON auth/error) body.
60
+ * Neutralizes BOTH the payload file AND its resume sidecar (remove, else truncate to
61
+ * 0). A resume requires a NONZERO partial AND a valid (nonempty) sidecar, so zeroing
62
+ * or removing EITHER makes a retry decline the resume and restart clean — the
63
+ * guarantee holds as long as at least one of the two can be neutralized, not only
64
+ * when the partial itself can be deleted (#473 P1). A hard failure to neutralize is
65
+ * surfaced via the log inside rmOrTruncate. */
66
+ async function discardRejectedPayload(path, sidecarPath, logUrl) {
67
+ const neutralized = await rmOrTruncate(path, logUrl);
68
+ if (sidecarPath)
69
+ await rmOrTruncate(sidecarPath, logUrl);
70
+ return neutralized;
71
+ }
72
+ /** Drop a tiny "this partial was rejected as a non-model body" MARKER next to a
73
+ * resumable partial. Best-effort. It is the cleanup-INDEPENDENT signal that lets a
74
+ * later attempt refuse to resume even when the rejection was based ONLY on the
75
+ * response Content-Type (gone by retry) and both removal and truncation of the
76
+ * partial+sidecar failed — a case body-magic alone can't re-derive (#473 P1). */
77
+ async function writePoisonMarker(markerPath, logUrl) {
78
+ try {
79
+ await writeFile(markerPath, "rejected-non-model-payload");
80
+ }
81
+ catch (err) {
82
+ logger.warn(`Could not write a poison marker "${markerPath}" for a rejected partial`, {
83
+ url: logUrl,
84
+ error: err instanceof Error ? err.message : String(err),
85
+ });
86
+ }
87
+ }
88
+ /** Path of the Content-Type sidecar beside a finalized CACHE file. A hidden dotfile
89
+ * (so LRU/readdir scans skip it) that records the response Content-Type, letting a
90
+ * later cache-HIT / coalesced caller re-run the #473 model-payload check WITH the
91
+ * original Content-Type instead of only body magic — closing the reuse gap where a
92
+ * Content-Type-only-flaggable body (its bytes don't sniff as text) could otherwise
93
+ * finalize as a model on a subsequent request. */
94
+ function cacheCtSidecar(cacheFilePath) {
95
+ return join(dirname(cacheFilePath), `.${basename(cacheFilePath)}.ct`);
96
+ }
97
+ /** True when a Content-Type is one the #473 gate would reject for a binary-model
98
+ * destination (an HTML page or a JSON document). Kept in lockstep with the
99
+ * Content-Type belt in detectNonModelPayload. */
100
+ function isRejectableContentType(contentType) {
101
+ return (/text\/html|application\/xhtml/i.test(contentType) || /(^|[/+])json\b/i.test(contentType));
102
+ }
103
+ /** Reconcile the Content-Type sidecar beside a freshly-finalized cache file. Writes it
104
+ * ONLY for the reject-worthy shapes (text/html, application/json) — persisting every
105
+ * octet-stream/text-plain type would add a useless sidecar per cache entry, and only
106
+ * an HTML/JSON label can flag a body that body-magic alone would miss on reuse. For a
107
+ * NON-reject-worthy (e.g. octet-stream) fill it REMOVES any pre-existing sidecar: a
108
+ * clean model landing under a cache key that a prior HTML response had tagged
109
+ * `text/html` must not inherit that stale tag and be wrongly rejected on reuse (#473 —
110
+ * a false-positive against a legitimate model). */
111
+ async function writeCacheContentType(cacheFilePath, contentType) {
112
+ const sidecar = cacheCtSidecar(cacheFilePath);
113
+ if (!contentType || !isRejectableContentType(contentType)) {
114
+ // Clear any stale sidecar so it can't attach to these clean bytes. Even if this
115
+ // rm fails, the SIZE stamp below makes a stale sidecar self-invalidate on read.
116
+ await rm(sidecar, { force: true }).catch(() => undefined);
117
+ return;
118
+ }
119
+ try {
120
+ // Stamp a fingerprint of the payload (size + a hash of its sniff head) alongside
121
+ // the Content-Type. On read we honor the type only when that fingerprint still
122
+ // matches the cache file — so a stale sidecar left behind after a clean re-fill
123
+ // (different bytes ⇒ different head/size) is ignored and can NEVER reject a
124
+ // legitimate model, regardless of whether the clearing rm above succeeded, AND
125
+ // even in the astronomically-unlikely case where the replacement model has the
126
+ // exact same byte LENGTH as the prior page (the head hash still differs) (#473 —
127
+ // robust against the swallowed-delete false-positive).
128
+ await writeFile(sidecar, `${contentType}\n${await payloadFingerprint(cacheFilePath)}`);
129
+ }
130
+ catch (err) {
131
+ // Best effort — reuse falls back to body-magic-only validation, which still
132
+ // catches every well-formed HTML/JSON page (only a synthetic body that sniffs
133
+ // binary yet carries a text Content-Type would evade). Surface it rather than
134
+ // swallow so the degraded reuse check is at least visible (#473).
135
+ logger.warn(`Could not persist the Content-Type for a cached download at "${cacheFilePath}"; ` +
136
+ `cache-reuse validation falls back to body magic only.`, { error: err instanceof Error ? err.message : String(err) });
137
+ }
138
+ }
139
+ /** Read the persisted Content-Type for a cache file, or "" when absent/unreadable OR
140
+ * STALE. The sidecar stamps the payload size it was written for; if that size no
141
+ * longer matches the cache file on disk the sidecar describes DIFFERENT (since
142
+ * replaced) bytes and is ignored — so a leftover `text/html` tag can't reject a
143
+ * legitimate model that later re-filled the same cache slot (#473). */
144
+ async function readCacheContentType(cacheFilePath) {
145
+ try {
146
+ const raw = await readFile(cacheCtSidecar(cacheFilePath), "utf-8");
147
+ const nl = raw.indexOf("\n");
148
+ if (nl < 0)
149
+ return ""; // no fingerprint (unexpected) → don't trust it
150
+ const contentType = raw.slice(0, nl).trim();
151
+ const stamped = raw.slice(nl + 1).trim();
152
+ if (!contentType || !stamped)
153
+ return "";
154
+ const current = await payloadFingerprint(cacheFilePath);
155
+ // Only honor the tag when it was stamped for the bytes currently on disk. A
156
+ // mismatch (stale sidecar over re-filled bytes, or an unreadable head) yields "",
157
+ // falling back to body magic — this can only ADD safety, never reject a real model.
158
+ return stamped === current ? contentType : "";
159
+ }
160
+ catch {
161
+ return "";
162
+ }
163
+ }
164
+ /** A cheap content fingerprint of a cache file: its size plus a short hash of its
165
+ * sniff head. Distinguishes a re-filled cache slot from the bytes a Content-Type
166
+ * sidecar was written for — even at an identical byte length — without hashing a
167
+ * multi-GB payload in full. "" when the head can't be read (caller treats a
168
+ * mismatch as "don't trust the sidecar"). */
169
+ async function payloadFingerprint(cacheFilePath) {
170
+ const size = await fileSizeOrUndefined(cacheFilePath);
171
+ const head = await readHead(cacheFilePath);
172
+ if (head === undefined)
173
+ return "";
174
+ const headHash = createHash("sha256").update(head).digest("hex").slice(0, 16);
175
+ return `${size ?? ""}:${headHash}`;
176
+ }
27
177
  /** On-disk size, or undefined when it can't be determined (fs layer stubbed in
28
178
  * tests, or a stat hiccup) — callers must not block a download over a missing
29
179
  * number, only over a number that proves corruption. */
@@ -36,6 +186,331 @@ async function fileSizeOrUndefined(path) {
36
186
  return undefined;
37
187
  }
38
188
  }
189
+ /** Destination extensions whose bytes MUST be a binary model payload — never an
190
+ * HTML page or a JSON document. A download that lands under one of these but whose
191
+ * body is actually an auth-challenge / error page is the #473 false-success class:
192
+ * a login HTML or `{"error":...}` JSON saved as `.safetensors`, reported as a green
193
+ * success, then failing at load time ("header too large"). Extensions NOT in this
194
+ * set (e.g. `.json`, `.yaml`, `.txt`, config sidecars) are left unvalidated so a
195
+ * legitimate text/JSON download is never rejected. */
196
+ // Superset of every BINARY model format the repo recognizes as a downloadable
197
+ // weight file — kept in sync with the recognizers in missing-models.ts (MODEL_EXTS)
198
+ // and workflow-converter.ts (the model-file regex). Intentionally EXCLUDES the
199
+ // textual formats those recognizers also allow (`.yaml`, `.json`): those legitimately
200
+ // contain text, so validating them as binary would reject a valid download.
201
+ const MODEL_BINARY_EXTS = new Set([
202
+ ".safetensors",
203
+ ".safetensor",
204
+ ".sft",
205
+ ".ckpt",
206
+ ".pt",
207
+ ".pt2",
208
+ ".pth",
209
+ ".bin",
210
+ ".gguf",
211
+ ".onnx",
212
+ ".vae",
213
+ ".pkl",
214
+ ".npz",
215
+ ".msgpack",
216
+ ]);
217
+ /** How many leading bytes to sniff for a payload's shape. Enough to see an HTML
218
+ * doctype/tag or a JSON error envelope's opening structure without reading a
219
+ * multi-GB model in full. */
220
+ const PAYLOAD_SNIFF_BYTES = 512;
221
+ /** Read the first `n` bytes of a file WITHOUT loading the whole thing (models are
222
+ * multi-GB) — used to sniff a completed download's shape. Best-effort: returns
223
+ * undefined if the file can't be opened/read (never blocks a download over a read
224
+ * hiccup — the size gate has already run). */
225
+ async function readHead(path, n = PAYLOAD_SNIFF_BYTES) {
226
+ let fh;
227
+ try {
228
+ fh = await open(path, "r");
229
+ const buf = Buffer.alloc(n);
230
+ // Loop: a single read() can return fewer bytes than requested even when more
231
+ // are available; fill up to n (or EOF) so a short first read can't truncate the
232
+ // sniff window and hide an HTML/JSON body after byte ~1.
233
+ let off = 0;
234
+ while (off < n) {
235
+ const { bytesRead } = await fh.read(buf, off, n - off, off);
236
+ if (bytesRead === 0)
237
+ break; // EOF
238
+ off += bytesRead;
239
+ }
240
+ return buf.subarray(0, off);
241
+ }
242
+ catch {
243
+ return undefined;
244
+ }
245
+ finally {
246
+ await fh?.close().catch(() => undefined);
247
+ }
248
+ }
249
+ /** True when a run of bytes reads as a valid UTF-8 TEXT document (what an HTML page
250
+ * or JSON error IS) rather than binary model bytes. This is a real UTF-8 validation,
251
+ * not "any high byte counts as text": each non-ASCII byte must be a well-formed
252
+ * UTF-8 lead followed by valid continuation bytes, and NUL / C0 control bytes
253
+ * (except the text whitespace controls TAB/LF/VT/FF/CR) hard-fail. Random binary that happens to start with `<`/`{`/`[`
254
+ * almost immediately hits an invalid UTF-8 lead byte (0x80–0xC1 and 0xF5–0xFF — ~26%
255
+ * of all byte values) or a control byte, so it is correctly kept binary; a safetensors
256
+ * whose LE header length is 60 (0x3C='<') is followed by NUL padding and fails at once.
257
+ * A multibyte sequence truncated by the end of the sniff window is accepted (we only
258
+ * sampled a prefix), never treated as invalid. */
259
+ function looksLikeText(slice) {
260
+ const n = slice.length;
261
+ if (n === 0)
262
+ return false;
263
+ let i = 0;
264
+ while (i < n) {
265
+ const b = slice[i];
266
+ if (b < 0x80) {
267
+ // ASCII: printable + the standard text whitespace controls TAB(0x09) LF(0x0a)
268
+ // VT(0x0b) FF(0x0c) CR(0x0d). A form-feed / vertical-tab is valid HTML/XML
269
+ // whitespace, so a real login page like `<html>\f<body>…` MUST still read as
270
+ // text (else it slips through as "binary" and finalizes as a model — #473
271
+ // P0-1). Any OTHER C0 control (NUL etc.) ⇒ not text.
272
+ if ((b >= 0x09 && b <= 0x0d) || (b >= 0x20 && b <= 0x7e)) {
273
+ i += 1;
274
+ continue;
275
+ }
276
+ return false;
277
+ }
278
+ // Multibyte lead byte → determine the sequence length; invalid leads reject.
279
+ let len;
280
+ if (b >= 0xc2 && b <= 0xdf)
281
+ len = 2;
282
+ else if (b >= 0xe0 && b <= 0xef)
283
+ len = 3;
284
+ else if (b >= 0xf0 && b <= 0xf4)
285
+ len = 4;
286
+ else
287
+ return false; // 0x80–0xC1 (bare continuation / overlong) or 0xF5–0xFF
288
+ // Strict continuation ranges reject overlong / surrogate / out-of-range forms:
289
+ // E0 → A0..BF (else overlong), ED → 80..9F (else surrogate),
290
+ // F0 → 90..BF (else overlong), F4 → 80..8F (else > U+10FFFF).
291
+ const lo = b === 0xe0 ? 0xa0 : b === 0xf0 ? 0x90 : 0x80;
292
+ const hi = b === 0xed ? 0x9f : b === 0xf4 ? 0x8f : 0xbf;
293
+ // Validate EVERY continuation byte that is PRESENT, even when the sequence is
294
+ // truncated by the sniff window: an already-visible byte that violates its range
295
+ // (e.g. `E0 80` — E0 requires A0..BF) proves the run is NOT valid UTF-8 and must
296
+ // reject, rather than being waved through as "truncated". Only a genuinely
297
+ // cut-off sequence — where we simply ran out of bytes before an INVALID one — is
298
+ // accepted (we only sampled a prefix). This closes a boundary false-positive that
299
+ // would misclassify a raw `.bin` as text/HTML (#473 P0-2 sniff-edge).
300
+ for (let k = 1; k < len; k += 1) {
301
+ if (i + k >= n)
302
+ return true; // ran out of bytes with everything valid so far
303
+ const cont = slice[i + k];
304
+ const clo = k === 1 ? lo : 0x80;
305
+ const chi = k === 1 ? hi : 0xbf;
306
+ if (cont < clo || cont > chi)
307
+ return false; // present-but-invalid continuation
308
+ }
309
+ i += len;
310
+ }
311
+ return true;
312
+ }
313
+ /** Classify a sniffed payload head as an HTML page, a JSON document, or opaque
314
+ * binary. Skips a UTF-8 BOM and leading ASCII whitespace, then requires the run
315
+ * that follows to read as TEXT before declaring html/json — so a binary model that
316
+ * merely starts with byte `<`/`{`/`[` (e.g. a safetensors whose LE header length is
317
+ * 60=0x3C or 91=0x5B, followed by NUL padding) is correctly kept as "binary":
318
+ * - leading `<` + text ⇒ "html"
319
+ * - leading `{`/`[` + text ⇒ "json"
320
+ * - anything else ⇒ "binary" (a real model payload). */
321
+ /** Classify an already-DECODED text string (used for UTF-16 bodies): skips leading
322
+ * whitespace, then a `<` ⇒ html / `{`|`[` ⇒ json when the run that follows is clean
323
+ * text (no NUL / C0 control). A binary payload that merely decoded to a leading
324
+ * `<`/`{` (e.g. a safetensors whose UTF-16 decode yields `<` then U+0000) fails the
325
+ * text check and stays binary. */
326
+ function classifyDecodedText(s) {
327
+ let j = 0;
328
+ // Skip leading text whitespace INCLUDING VT/FF (\v \f) — an auth page can begin with
329
+ // a form-feed before the `<`/`{` (`\f<!DOCTYPE html>`); if we stopped at it the char
330
+ // scrutinized would be the control byte, not the tag, and the page would misclassify
331
+ // as binary (#473 P0-1).
332
+ while (j < s.length) {
333
+ const cp = s.charCodeAt(j);
334
+ if (cp === 0x20 || (cp >= 0x09 && cp <= 0x0d))
335
+ j += 1;
336
+ else
337
+ break;
338
+ }
339
+ if (j >= s.length)
340
+ return "binary";
341
+ const ch = s[j];
342
+ // Require the WHOLE decoded run (not just the first 64 chars) to be clean text
343
+ // before declaring html/json: a binary payload that merely decoded to a leading
344
+ // `<`/`{` but then hits control/replacement chars must stay binary (#473 P0-2).
345
+ const rest = s.slice(j);
346
+ const isText = (() => {
347
+ for (const c of rest) {
348
+ const cp = c.codePointAt(0);
349
+ if (cp >= 0x09 && cp <= 0x0d)
350
+ continue; // TAB/LF/VT/FF/CR — text whitespace
351
+ if (cp < 0x20 || cp === 0xfffd)
352
+ return false; // NUL/C0 control or replacement char
353
+ }
354
+ return true;
355
+ })();
356
+ if (ch === "<")
357
+ return isText ? "html" : "binary";
358
+ if (ch === "{" || ch === "[")
359
+ return isText ? "json" : "binary";
360
+ return "binary";
361
+ }
362
+ function classifyPayload(buf) {
363
+ // A UTF-16 BOM (FF FE = LE, FE FF = BE) marks a text document — an HTML/JSON auth
364
+ // page can be served UTF-16 (e.g. `text/html; charset=utf-16`, bytes FF FE 3C 00).
365
+ // Decode and classify as text so it isn't mistaken for binary. A binary model that
366
+ // coincidentally starts with a BOM byte pair decodes to control chars → stays binary.
367
+ if (buf.length >= 2 &&
368
+ ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff))) {
369
+ try {
370
+ const label = buf[0] === 0xff ? "utf-16le" : "utf-16be";
371
+ // Trim bytes the sniff window cut mid-code-unit BEFORE decoding, so a truncation
372
+ // artifact doesn't decode to U+FFFD and make a valid UTF-16 page look binary:
373
+ // (1) an odd trailing byte (a half code unit), and (2) a trailing UNPAIRED high
374
+ // surrogate (0xD800–0xDBFF) whose low half fell outside the window — e.g. an
375
+ // emoji split across the 512-byte boundary. A mid-document U+FFFD (genuine
376
+ // binary) is still caught by classifyDecodedText (#473 P0-1 sniff-edge).
377
+ let end = buf.length - (buf.length % 2);
378
+ if (end >= 2) {
379
+ const hi = label === "utf-16le" ? buf[end - 1] : buf[end - 2];
380
+ const loB = label === "utf-16le" ? buf[end - 2] : buf[end - 1];
381
+ const lastUnit = (hi << 8) | loB;
382
+ if (lastUnit >= 0xd800 && lastUnit <= 0xdbff)
383
+ end -= 2; // drop lone high surrogate
384
+ }
385
+ return classifyDecodedText(new TextDecoder(label).decode(buf.subarray(0, end)));
386
+ }
387
+ catch {
388
+ return "binary";
389
+ }
390
+ }
391
+ let i = 0;
392
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf)
393
+ i = 3; // UTF-8 BOM
394
+ // Skip leading text whitespace INCLUDING VT(0x0b)/FF(0x0c) — a login page may open
395
+ // with a form-feed before the `<` (`\f<!DOCTYPE html>`). Stopping at that control
396
+ // byte would scrutinize it instead of the tag and misclassify the page as binary
397
+ // (#473 P0-1).
398
+ while (i < buf.length &&
399
+ (buf[i] === 0x20 || (buf[i] >= 0x09 && buf[i] <= 0x0d))) {
400
+ i += 1;
401
+ }
402
+ if (i >= buf.length)
403
+ return "binary";
404
+ const c = buf[i];
405
+ // Require the WHOLE sniffed run — the entire remaining head, not just the first 64
406
+ // bytes — to read as valid text before declaring html/json. A raw binary whose head
407
+ // merely STARTS with `<`/`{`/`[` but turns to non-text bytes further in (e.g. a .bin
408
+ // that begins `<AAA…` then has NUL/high bytes) must be ACCEPTED as a model, not
409
+ // rejected on a one-byte lead over a tiny window (#473 P0-2). A genuine HTML/JSON
410
+ // auth page is text throughout the sniff window, so it still classifies correctly.
411
+ const rest = buf.subarray(i);
412
+ if (c === 0x3c)
413
+ return looksLikeText(rest) ? "html" : "binary"; // '<'
414
+ if (c === 0x7b || c === 0x5b)
415
+ return looksLikeText(rest) ? "json" : "binary"; // '{' or '['
416
+ return "binary";
417
+ }
418
+ /** Given a completed download's sniffed head + Content-Type, decide whether the
419
+ * bytes are an auth/error page masquerading as a model file (#473). Returns the
420
+ * offending kind, or null when the payload is an acceptable model binary (or the
421
+ * destination isn't a model-binary extension, so this validation doesn't apply).
422
+ *
423
+ * Two independent signals, EITHER of which rejects:
424
+ * 1. Body magic — an HTML page's leading `<` (over a whole-window text run) and a
425
+ * JSON error's leading `{`/`[` are present regardless of how the server labels
426
+ * the response, so body magic catches an auth/error page even when mislabeled
427
+ * `application/octet-stream`. (fetch() transparently decompresses gzip/br, so a
428
+ * compressed error page still sniffs as its decoded HTML/JSON here.)
429
+ * 2. Content-Type — a `text/html`/`application/xhtml` or `application/json` label
430
+ * on a binary-model destination is itself proof of an auth/error document, so it
431
+ * rejects EVEN when the body failed to sniff as text (a stray control byte, an
432
+ * exotic charset, or a truncating proxy — #473 P0-1). Real model hosts never
433
+ * label a weight file text/html or application/json, so this can't reject a
434
+ * legitimate binary (whose Content-Type is octet-stream or similar). */
435
+ function detectNonModelPayload(head, contentType, modelExt) {
436
+ if (!modelExt || !MODEL_BINARY_EXTS.has(modelExt.toLowerCase()))
437
+ return null;
438
+ const kind = head ? classifyPayload(head) : "binary";
439
+ if (kind === "html")
440
+ return "html";
441
+ if (kind === "json")
442
+ return "json";
443
+ // Content-Type is AUTHORITATIVE for the two textual auth/error shapes. A server
444
+ // that LABELS a response `text/html`/`application/xhtml` or `application/json` for
445
+ // a binary-model destination is serving a login/auth or API-error document, never
446
+ // a weight file — so reject it EVEN when the body itself failed to sniff as text
447
+ // (a stray control byte, an exotic charset, or a truncating proxy could otherwise
448
+ // let a genuine HTML/JSON page slip through as "binary" — #473 P0-1). Real model
449
+ // hosts serve weights as application/octet-stream (or similar), never as text/html
450
+ // or application/json, so this can't reject a legitimate binary. (The body-magic
451
+ // checks above already caught the common octet-stream-mislabeled auth page.)
452
+ if (/text\/html|application\/xhtml/i.test(contentType))
453
+ return "html";
454
+ if (/(^|[/+])json\b/i.test(contentType))
455
+ return "json";
456
+ return null;
457
+ }
458
+ /** Actionable error for a rejected auth/error payload (#473). Names the concrete
459
+ * failure (an HTML page / JSON error saved as a model) and, for CivitAI, the exact
460
+ * fix (set/refresh the API token) — because a 200-with-auth-challenge is precisely
461
+ * what CivitAI returns for a missing/invalid key or a gated model, and Manager /
462
+ * a bare fetch would otherwise save that page under the `.safetensors` name. */
463
+ function nonModelPayloadError(kind, url, logUrl) {
464
+ let host = "";
465
+ try {
466
+ host = new URL(url).hostname;
467
+ }
468
+ catch {
469
+ /* logUrl is used for reporting; host is only for the CivitAI hint */
470
+ }
471
+ const isCivitai = /(^|\.)civitai\.com$/i.test(host);
472
+ const what = kind === "html"
473
+ ? "an HTML page (an authentication/login or error page)"
474
+ : "a JSON document (an API error or auth challenge)";
475
+ const civitaiHint = isCivitai
476
+ ? " CivitAI returns an auth challenge like this when no valid token is sent — set or refresh " +
477
+ "CIVITAI_API_TOKEN (panel Settings › “Set CivitAI token…”, or the env var; create one at " +
478
+ "civitai.com/user/account) and retry. Note: on a REMOTE ComfyUI target the token is NOT " +
479
+ "forwarded to ComfyUI-Manager, so a locally-set token does not fix a remote Manager download."
480
+ : "";
481
+ return new ModelError(`Download rejected: the server returned ${what}, not a model file, but the destination is a ` +
482
+ `binary model file. Refusing to save it as a model (it would land as a corrupt file under a ` +
483
+ `false success and fail at load time, e.g. "header too large").${civitaiHint}`, { url: logUrl });
484
+ }
485
+ /** Validate that a COMPLETED file at `targetPath` is a real model payload — not an
486
+ * HTML/JSON auth/error page — when the destination is a binary-model extension
487
+ * (#473). FAILS CLOSED: an offending payload OR an unreadable-for-sniffing file (we
488
+ * can't confirm it) throws, after `onReject` cleans up, so a corrupt file is never
489
+ * finalized under a success. A no-op for non-model destinations. `contentType` is a
490
+ * secondary signal (empty on cache-hit/cloud paths, where only the bytes are known). */
491
+ async function assertModelPayloadOrThrow(opts) {
492
+ const { targetPath, modelExt, contentType, url, logUrl, failClosed = true } = opts;
493
+ if (!modelExt || !MODEL_BINARY_EXTS.has(modelExt.toLowerCase()))
494
+ return;
495
+ const head = await readHead(targetPath);
496
+ // An unreadable OR unexpectedly-empty head (a non-empty file that read 0 bytes
497
+ // under an fs race) leaves the payload UNVERIFIED. On the fail-closed paths refuse
498
+ // to finalize it rather than trust it (#473/#467); on the best-effort cloud path,
499
+ // skip. (A genuinely 0-byte download is already rejected upstream by assertComplete
500
+ // / the cache-hit 0-byte guard.)
501
+ if (head === undefined || head.length === 0) {
502
+ if (!failClosed)
503
+ return;
504
+ await opts.onReject?.();
505
+ throw new ModelError(`Download could not be verified: the completed file could not be read to confirm it is a ` +
506
+ `model payload (and not an HTML/JSON auth/error page). Not finalizing it as a model; retry.`, { url: logUrl });
507
+ }
508
+ const kind = detectNonModelPayload(head, contentType, modelExt);
509
+ if (!kind)
510
+ return;
511
+ await opts.onReject?.();
512
+ throw nonModelPayloadError(kind, url, logUrl);
513
+ }
39
514
  /** POSITIVELY true only when `path` is confirmed discarded — it no longer exists
40
515
  * (stat throws ENOENT) or exists but is empty (size 0). A non-ENOENT stat error
41
516
  * (a transient fs hiccup) or a still-present non-empty file returns false, so a
@@ -228,7 +703,13 @@ async function streamUrlToFile(url, targetPath, headers, logUrl = redactUrlForLo
228
703
  /** Sink for THIS physical download's resume decision, threaded from the job so
229
704
  * the outcome is stored on that job — never in a shared keyed map that could
230
705
  * misattribute it to another job (#467). Absent for internal/direct callers. */
231
- onResume) {
706
+ onResume,
707
+ /** Extension of the FINAL destination (e.g. ".safetensors"), NOT of the .partial
708
+ * we stream into. When it's a binary-model extension the completed payload is
709
+ * validated as a real model — an HTML/JSON auth/error body is rejected rather
710
+ * than finalized as a corrupt model under a false success (#473). Empty ⇒ no
711
+ * payload validation (unknown/non-model destination). */
712
+ modelExt = "") {
232
713
  if (supportsCloudDownload(url)) {
233
714
  // Cloud downloaders (S3/Azure) don't range-resume — they overwrite the target.
234
715
  // If a partial exists it's being discarded; surface that (#467) instead of a
@@ -259,7 +740,26 @@ onResume) {
259
740
  await safeRm(targetPath);
260
741
  throw new ModelError("Download produced a 0-byte file — the cloud source (S3/Azure) sent no data. Removed it; retry.", { url: logUrl });
261
742
  }
262
- return;
743
+ // A cloud (S3/Azure) object can also be an error document — e.g. an XML
744
+ // AccessDenied body saved under a `.safetensors` name. No HTTP Content-Type is
745
+ // available here, so sniff the body alone (an XML/HTML error starts with `<`).
746
+ await assertModelPayloadOrThrow({
747
+ targetPath,
748
+ modelExt,
749
+ contentType: "",
750
+ url,
751
+ logUrl,
752
+ onReject: async () => {
753
+ await discardRejectedPayload(targetPath, undefined, logUrl);
754
+ },
755
+ // FAIL CLOSED, same as the HTTP path: a cloud (S3/Azure) object can be an XML/
756
+ // HTML AccessDenied body saved under a `.safetensors` name, and if we CAN'T
757
+ // sniff the completed file (a transient open/read failure) we must NOT finalize
758
+ // it — cannot verify ⇒ do not finalize (#473 P0-3). Previously this was a
759
+ // best-effort skip, which let an unsniffable auth/error body get renamed in.
760
+ failClosed: true,
761
+ });
762
+ return ""; // cloud (S3/Azure) has no HTTP Content-Type to persist
263
763
  }
264
764
  // Resumable downloads: when a partial file exists at targetPath we ask the
265
765
  // server for the remaining bytes via Range. If the server returns 206 with
@@ -703,14 +1203,45 @@ onResume) {
703
1203
  `the bad file; retry.`, { url: logUrl });
704
1204
  }
705
1205
  };
1206
+ // Reject a completed body that is an HTML/JSON auth/error page rather than a
1207
+ // model file (#473). Runs AFTER assertComplete (size is right) but BEFORE the
1208
+ // .partial is renamed into place / the sidecar is dropped — a plausibly-sized
1209
+ // login page or `{"error":...}` would otherwise pass the size gate and finalize
1210
+ // as a corrupt model under a green success. Removes the bad partial + sidecar so
1211
+ // a retry starts clean, and surfaces an actionable (CivitAI-aware) error.
1212
+ // The response Content-Type — used now for the stream-time check AND returned to
1213
+ // the cache layer so it can persist it beside the cache file. A later cache-HIT /
1214
+ // coalesced caller re-validates with contentType "" (the header is long gone), so
1215
+ // without persisting it a body that only a Content-Type could flag (e.g. an
1216
+ // HTML/JSON page whose bytes don't sniff as text) could slip through on reuse (#473).
1217
+ const responseContentType = res.headers.get("content-type") || "";
1218
+ const assertModelPayload = () => assertModelPayloadOrThrow({
1219
+ targetPath,
1220
+ modelExt,
1221
+ contentType: responseContentType,
1222
+ url,
1223
+ logUrl,
1224
+ // Remove the rejected partial + its resume sidecar, and if removal fails,
1225
+ // NEUTRALIZE the partial (truncate to 0) so a retry can't resume onto the
1226
+ // poisoned HTML/JSON prefix (#473 P1). safeRm alone would swallow an EPERM and
1227
+ // leave a resumable poisoned partial behind. When resumable, ALSO drop a poison
1228
+ // marker so a content-type-only rejection (whose body may sniff as binary on
1229
+ // retry) still can't be resumed even if neutralization failed.
1230
+ onReject: async () => {
1231
+ await discardRejectedPayload(targetPath, resumable ? validatorSidecar : undefined, logUrl);
1232
+ if (resumable)
1233
+ await writePoisonMarker(`${targetPath}.rejected`, logUrl);
1234
+ },
1235
+ });
706
1236
  // No progress wanted (internal/cache caller, or not under the panel) → straight pipe.
707
1237
  if (!progress) {
708
1238
  await pipeline(nodeStream, fileStream);
709
1239
  await assertComplete();
1240
+ await assertModelPayload();
710
1241
  // Complete: the validator sidecar is only needed to guard a resume, so drop it.
711
1242
  if (resumable)
712
1243
  await safeRm(validatorSidecar);
713
- return;
1244
+ return responseContentType;
714
1245
  }
715
1246
  // Tally bytes as they flow and report throughput to the panel tray.
716
1247
  const total = expectedTotal;
@@ -737,17 +1268,19 @@ onResume) {
737
1268
  try {
738
1269
  await pipeline(nodeStream, counter, fileStream);
739
1270
  await assertComplete();
1271
+ await assertModelPayload();
740
1272
  if (resumable)
741
1273
  await safeRm(validatorSidecar);
742
1274
  bytesPerSec = 0;
743
1275
  emit("done", true);
1276
+ return responseContentType;
744
1277
  }
745
1278
  catch (err) {
746
1279
  emit("error", true);
747
1280
  throw err;
748
1281
  }
749
1282
  }
750
- async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progress, onResume) {
1283
+ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progress, onResume, modelExt = "") {
751
1284
  // Representation-aware identity (#467): a same-URL download with different HTTP
752
1285
  // auth headers OR different cloud (S3/Azure) credentials gets its OWN cache file,
753
1286
  // partial and in-flight slot — never coalesced onto another caller's stream.
@@ -771,6 +1304,8 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
771
1304
  // materializing an empty file that reports success.
772
1305
  if (info.size === 0) {
773
1306
  await downloadCacheFs.rm(target, { force: true }).catch(() => undefined);
1307
+ // Drop its stale Content-Type sidecar too — it now describes nothing.
1308
+ await downloadCacheFs.rm(cacheCtSidecar(target), { force: true }).catch(() => undefined);
774
1309
  }
775
1310
  else {
776
1311
  await touch(target);
@@ -788,6 +1323,7 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
788
1323
  // restarting from zero. (See streamUrlToFile for the Range + flags
789
1324
  // handshake.) Cleanup on terminal failure stays unchanged.
790
1325
  const partial = join(cacheDir(), `.${basename(target)}.partial`);
1326
+ const rejectedMarker = `${partial}.rejected`;
791
1327
  let resumeFromBytes = 0;
792
1328
  try {
793
1329
  const existing = await downloadCacheFs.stat(partial);
@@ -802,11 +1338,63 @@ async function downloadIntoCache(url, headers, logUrl, storageAuth = {}, progres
802
1338
  catch {
803
1339
  // No partial — fresh download.
804
1340
  }
1341
+ // #473 P1 — poison MARKER guard (cleanup- AND content-type-independent). A prior
1342
+ // attempt that REJECTED this download (even solely on the response Content-Type,
1343
+ // which is gone now) drops a `.rejected` marker next to the partial. If it's
1344
+ // present, the leftover partial is poison regardless of what its bytes sniff as —
1345
+ // never resume onto it. Discard everything and restart from 0.
1346
+ let markerPresent = false;
805
1347
  try {
806
- await streamUrlToFile(url, partial, headers, logUrl, storageAuth, resumeFromBytes, progress, true, // resumable: cache partials use the .partial + If-Range resume handshake
807
- onResume);
1348
+ markerPresent = (await downloadCacheFs.stat(rejectedMarker)).isFile();
1349
+ }
1350
+ catch {
1351
+ /* no marker */
1352
+ }
1353
+ if (markerPresent) {
1354
+ if (resumeFromBytes > 0) {
1355
+ logger.warn(`Discarding a previously-rejected (${resumeFromBytes}-byte) partial before resume: a ` +
1356
+ `poison marker from an earlier non-model rejection is present — restarting from 0 (#473).`, { url: logUrl, bytes: resumeFromBytes });
1357
+ }
1358
+ const neutralized = await discardRejectedPayload(partial, `${partial}.etag`, logUrl ?? redactUrlForLogs(url));
1359
+ // Keep the marker if the partial could NOT be neutralized (rm AND truncate both
1360
+ // failed), so a still-poisoned leftover stays flagged for the next attempt.
1361
+ // resumeFromBytes = 0 forces this attempt to re-download fresh ("w" truncates the
1362
+ // leftover), so the poison is cleared here regardless.
1363
+ if (neutralized)
1364
+ await safeRm(rejectedMarker);
1365
+ resumeFromBytes = 0;
1366
+ }
1367
+ // #473 P1 — cleanup-INDEPENDENT poison guard (body-magic). A prior attempt may have REJECTED
1368
+ // this download as an HTML/JSON auth/error body and then been UNABLE to remove or
1369
+ // truncate the leftover .partial (a denied rm AND a denied truncate). Re-inspect
1370
+ // the partial's HEAD here, before deciding to resume: if it is itself a non-model
1371
+ // (HTML/JSON) body for a binary-model destination, it is poison — NEVER resume
1372
+ // onto it. Reset to a fresh download (resumeFromBytes = 0 ⇒ no Range ⇒ the "w"
1373
+ // open truncates the poisoned bytes) and best-effort discard the sidecar, so the
1374
+ // invariant "a rejected leftover can't be treated as resumable" holds even when
1375
+ // both cleanup mechanisms failed. A legitimate in-progress partial sniffs as
1376
+ // binary (null) and resumes normally.
1377
+ if (resumeFromBytes > 0 && modelExt) {
1378
+ const partialHead = await readHead(partial);
1379
+ if (detectNonModelPayload(partialHead, "", modelExt)) {
1380
+ logger.warn(`Discarding a previously-rejected non-model (${resumeFromBytes}-byte) partial before ` +
1381
+ `resume: its head is an HTML/JSON auth/error body, not a model — restarting from 0 so ` +
1382
+ `the poisoned bytes can't be resumed onto (#473).`, { url: logUrl, bytes: resumeFromBytes });
1383
+ await discardRejectedPayload(partial, `${partial}.etag`, logUrl ?? redactUrlForLogs(url));
1384
+ resumeFromBytes = 0;
1385
+ }
1386
+ }
1387
+ try {
1388
+ const contentType = await streamUrlToFile(url, partial, headers, logUrl, storageAuth, resumeFromBytes, progress, true, // resumable: cache partials use the .partial + If-Range resume handshake
1389
+ onResume, modelExt);
808
1390
  await downloadCacheFs.rename(partial, target);
809
1391
  await touch(target);
1392
+ // Persist the response Content-Type beside the cache file so a later cache-HIT /
1393
+ // coalesced caller re-validates with it (#473 reuse gap).
1394
+ await writeCacheContentType(target, contentType);
1395
+ // Clean any stale poison marker now that a CLEAN payload finalized under this
1396
+ // key — the partial is gone (renamed) and the bytes passed validation.
1397
+ await safeRm(rejectedMarker);
810
1398
  return target;
811
1399
  }
812
1400
  catch (err) {
@@ -1000,18 +1588,57 @@ async function evictLruIfNeeded() {
1000
1588
  files.sort((a, b) => a.time - b.time);
1001
1589
  for (const file of files) {
1002
1590
  await downloadCacheFs.rm(file.path, { force: true });
1591
+ // Evict the Content-Type sidecar with its cache file so it can't outlive it.
1592
+ await downloadCacheFs.rm(cacheCtSidecar(file.path), { force: true }).catch(() => undefined);
1003
1593
  total -= file.size;
1004
1594
  if (total <= limit)
1005
1595
  break;
1006
1596
  }
1007
1597
  }
1008
- export async function downloadUrlToFile(url, targetPath, headers, logUrl, storageAuth = {}, progress) {
1009
- await streamUrlToFile(url, targetPath, headers, logUrl, storageAuth, 0, progress);
1598
+ export async function downloadUrlToFile(url, targetPath, headers, logUrl, storageAuth = {}, progress, modelExt = extname(targetPath)) {
1599
+ await streamUrlToFile(url, targetPath, headers, logUrl, storageAuth, 0, progress, false, undefined, modelExt);
1010
1600
  }
1011
1601
  export async function downloadWithCache(options) {
1012
1602
  const logUrl = options.logUrl ?? redactUrlForLogs(options.url);
1603
+ // The FINAL destination extension drives model-payload validation (#473). Derived
1604
+ // from the real targetPath here — NOT from the .partial / random temp we actually
1605
+ // stream into (those carry `.partial`/`.tmp`) — and threaded down so the completed
1606
+ // body is checked against what the destination IS (a `.safetensors` etc.).
1607
+ const modelExt = extname(options.targetPath);
1013
1608
  try {
1014
- const cachePath = await downloadIntoCache(options.url, options.headers, logUrl, options.storageAuth, options.progress, options.onResume);
1609
+ const cachePath = await downloadIntoCache(options.url, options.headers, logUrl, options.storageAuth, options.progress, options.onResume, modelExt);
1610
+ // Validate the CACHE FILE itself before materializing it to this destination —
1611
+ // this is the authoritative per-caller gate (#473). It covers four cases the
1612
+ // stream-time check can't: (1) a CACHE HIT that skipped streaming entirely,
1613
+ // (2) a COALESCED caller whose .safetensors destination consumed a stream that
1614
+ // ran under a different (e.g. non-model) caller's modelExt, (3) a legacy cache
1615
+ // entry poisoned before this fix, and (4) a body that ONLY the Content-Type could
1616
+ // flag (its bytes don't sniff as text). The live Content-Type header is gone by
1617
+ // now, so we recover it from the `.ct` sidecar persisted at download time —
1618
+ // otherwise a Content-Type-only-flaggable body could finalize as a model on reuse.
1619
+ const cachedContentType = await readCacheContentType(cachePath);
1620
+ await assertModelPayloadOrThrow({
1621
+ targetPath: cachePath,
1622
+ modelExt,
1623
+ contentType: cachedContentType,
1624
+ url: options.url,
1625
+ logUrl,
1626
+ // Drop the poisoned cache entry (+ its Content-Type sidecar) so a retry
1627
+ // re-downloads clean rather than re-serving the same bad bytes on every call.
1628
+ // Truncate-to-0 fallback if rm fails (a 0-byte cache entry is treated as a
1629
+ // miss), and log if even that fails, so a poisoned cache entry can't silently
1630
+ // re-poison cache hits (#473 P1).
1631
+ onReject: async () => {
1632
+ const neutralized = await discardRejectedPayload(cachePath, undefined, logUrl);
1633
+ // Only drop the Content-Type sidecar once the poisoned cache file is actually
1634
+ // gone/emptied. If the cache file SURVIVED (rm AND truncate both failed), KEEP
1635
+ // the `.ct` so the NEXT caller can still reject it by its persisted
1636
+ // Content-Type — deleting the evidence would let a Content-Type-only poison be
1637
+ // re-served as a model on a later cache hit (#473 P1).
1638
+ if (neutralized)
1639
+ await safeRm(cacheCtSidecar(cachePath));
1640
+ },
1641
+ });
1015
1642
  const materializedBy = await materializeCacheFile(cachePath, options.targetPath);
1016
1643
  await evictLruIfNeeded();
1017
1644
  return {
@@ -1038,7 +1665,7 @@ export async function downloadWithCache(options) {
1038
1665
  // leaves the destination untouched.
1039
1666
  const tmp = await reserveExclusiveTemp(options.targetPath, "dl");
1040
1667
  try {
1041
- await downloadUrlToFile(options.url, tmp, options.headers, logUrl, options.storageAuth, options.progress);
1668
+ await downloadUrlToFile(options.url, tmp, options.headers, logUrl, options.storageAuth, options.progress, modelExt);
1042
1669
  await renameTempOverDestination(tmp, options.targetPath);
1043
1670
  }
1044
1671
  catch (e) {