comfyui-mcp 0.49.2 → 0.49.4

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.
Files changed (61) hide show
  1. package/dist/orchestrator/agent-backend.js +8 -0
  2. package/dist/orchestrator/agent-backend.js.map +1 -1
  3. package/dist/orchestrator/codex-backend.js +49 -0
  4. package/dist/orchestrator/codex-backend.js.map +1 -1
  5. package/dist/orchestrator/grok-backend.js +5 -0
  6. package/dist/orchestrator/grok-backend.js.map +1 -1
  7. package/dist/orchestrator/index.js +317 -22
  8. package/dist/orchestrator/index.js.map +1 -1
  9. package/dist/orchestrator/ollama-backend.js +11 -2
  10. package/dist/orchestrator/ollama-backend.js.map +1 -1
  11. package/dist/orchestrator/panel-agent.js +591 -67
  12. package/dist/orchestrator/panel-agent.js.map +1 -1
  13. package/dist/orchestrator/panel-tools.js +518 -65
  14. package/dist/orchestrator/panel-tools.js.map +1 -1
  15. package/dist/orchestrator/run-completion-journal.js +914 -0
  16. package/dist/orchestrator/run-completion-journal.js.map +1 -0
  17. package/dist/orchestrator/session-store.js +11 -3
  18. package/dist/orchestrator/session-store.js.map +1 -1
  19. package/dist/services/asset-reconcile.js +83 -0
  20. package/dist/services/asset-reconcile.js.map +1 -0
  21. package/dist/services/asset-registry.js +9 -2
  22. package/dist/services/asset-registry.js.map +1 -1
  23. package/dist/services/download-jobs.js +178 -14
  24. package/dist/services/download-jobs.js.map +1 -1
  25. package/dist/services/download-progress.js +16 -18
  26. package/dist/services/download-progress.js.map +1 -1
  27. package/dist/services/extra-paths.js +61 -9
  28. package/dist/services/extra-paths.js.map +1 -1
  29. package/dist/services/hello-retarget.js +165 -0
  30. package/dist/services/hello-retarget.js.map +1 -0
  31. package/dist/services/job-history.js +50 -0
  32. package/dist/services/job-history.js.map +1 -1
  33. package/dist/services/job-watcher.js +38 -7
  34. package/dist/services/job-watcher.js.map +1 -1
  35. package/dist/services/manifest.js +92 -9
  36. package/dist/services/manifest.js.map +1 -1
  37. package/dist/services/model-resolver.js +616 -17
  38. package/dist/services/model-resolver.js.map +1 -1
  39. package/dist/services/output-dir.js +63 -15
  40. package/dist/services/output-dir.js.map +1 -1
  41. package/dist/services/panel-pin-guard.js +6 -62
  42. package/dist/services/panel-pin-guard.js.map +1 -1
  43. package/dist/services/ui-bridge.js +111 -14
  44. package/dist/services/ui-bridge.js.map +1 -1
  45. package/dist/services/workspace-env.js +179 -3
  46. package/dist/services/workspace-env.js.map +1 -1
  47. package/dist/tools/assets.js +34 -2
  48. package/dist/tools/assets.js.map +1 -1
  49. package/dist/tools/extra-paths.js +6 -4
  50. package/dist/tools/extra-paths.js.map +1 -1
  51. package/dist/tools/model-extras.js +26 -7
  52. package/dist/tools/model-extras.js.map +1 -1
  53. package/dist/tools/model-management.js +36 -8
  54. package/dist/tools/model-management.js.map +1 -1
  55. package/dist/tools/report-issue.js +12 -2
  56. package/dist/tools/report-issue.js.map +1 -1
  57. package/dist/tools/vocabulary.js +21 -0
  58. package/dist/tools/vocabulary.js.map +1 -1
  59. package/package.json +1 -1
  60. package/scripts/gen-tool-docs.ts +138 -4
  61. package/scripts/tool-doc-examples.ts +794 -0
@@ -1,17 +1,18 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
+ import { platform } from "node:os";
3
4
  import { readdir, stat, mkdir, readFile, lstat, realpath } from "node:fs/promises";
4
5
  import { dirname, join, basename, resolve, relative, sep, isAbsolute, extname } from "node:path";
5
- import { config, isRemoteMode } from "../config.js";
6
+ import { config, getComfyUIBaseUrl, isRemoteMode } from "../config.js";
6
7
  import { getClient, getSystemStats } from "../comfyui/client.js";
7
8
  import { getExtraModelRoots, getLiveExtraModelRoots } from "./extra-paths.js";
8
- import { resolveEffectiveComfyUIBase, liveRootFromArgv } from "./workspace-env.js";
9
+ import { resolveEffectiveComfyUIBase, resolveLiveServerRoot } from "./workspace-env.js";
9
10
  import { installModelViaManager } from "./node-management.js";
10
11
  import { ModelError, ValidationError } from "../utils/errors.js";
11
12
  import { logger } from "../utils/logger.js";
12
13
  import { downloadWithCache, probeRemoteModelPayload } from "./download-cache.js";
13
14
  import { reportDownloadProgress } from "./download-progress.js";
14
- import { resolveModelsDirWithBases, parseModelsDirFromArgv, hasUnresolvableRelativeModelDirFlag, } from "./output-dir.js";
15
+ import { resolveModelsDir, resolveModelsDirWithBases, parseModelsDirFromArgv, hasUnresolvableRelativeModelDirFlag, isLiveAuthoritativeModelsDir, } from "./output-dir.js";
15
16
  import { applyDownloadAuth, redactUrlForLogs, } from "./download-auth.js";
16
17
  export const MODEL_SUBDIRS = [
17
18
  "checkpoints",
@@ -503,6 +504,548 @@ export function resolveModelSubfolder(targetSubfolder) {
503
504
  }
504
505
  return targetDir;
505
506
  }
507
+ // ---------------------------------------------------------------------------
508
+ // Live-server visibility (#369): does the CONNECTED ComfyUI actually read here?
509
+ // ---------------------------------------------------------------------------
510
+ /**
511
+ * The file extensions ComfyUI core registers for its model folders
512
+ * (`folder_paths.supported_pt_extensions`). Deliberately EXCLUDES `.gguf` and
513
+ * other custom-node-registered types: this set is used to decide whether a
514
+ * candidate directory's contents SHOULD appear in the live server's listing, and
515
+ * over-including would turn "core doesn't list this type" into a false refusal.
516
+ */
517
+ const CORE_MODEL_EXTENSIONS = new Set([
518
+ ".ckpt",
519
+ ".pt",
520
+ ".pt2",
521
+ ".bin",
522
+ ".pth",
523
+ ".safetensors",
524
+ ".sft",
525
+ ]);
526
+ /** The models/ CATEGORY a target subfolder belongs to — its first path segment
527
+ * (`"loras/sdxl"` → `"loras"`), which is the folder name ComfyUI's `/models/<cat>`
528
+ * endpoint is keyed by. */
529
+ function categoryOf(targetSubfolder) {
530
+ return (targetSubfolder ?? "").trim().split(/[\\/]+/).filter(Boolean)[0] ?? "";
531
+ }
532
+ /** The part of a target subfolder BELOW its category (`"loras/sdxl/x"` → `"sdxl/x"`,
533
+ * `"loras"` → `""`). ComfyUI's `/models/<category>` listing is relative to the
534
+ * category, so this is what a landed file's entry must be prefixed with. */
535
+ function subfolderRemainder(targetSubfolder) {
536
+ const parts = (targetSubfolder ?? "").trim().split(/[\\/]+/).filter(Boolean);
537
+ return parts.slice(1).join("/");
538
+ }
539
+ /**
540
+ * What the LIVE server reports for a model category via its own `/models/<cat>`
541
+ * endpoint — the SAME source of truth `list_local_models` reads, which is exactly
542
+ * why #369 was so confusing: the reader asked the server and the writer asked
543
+ * COMFYUI_PATH, and the two disagreed silently.
544
+ *
545
+ * Returns `undefined` (INCONCLUSIVE — never an empty array) when the server is
546
+ * unreachable, the endpoint is absent/errors, or the body is not a string array.
547
+ * Callers must treat inconclusive as "no evidence", never as "no files".
548
+ */
549
+ export async function liveCategoryListing(category) {
550
+ if (!category)
551
+ return undefined;
552
+ try {
553
+ const client = getClient();
554
+ const res = await client.fetchApi(`/models/${encodeURIComponent(category)}`);
555
+ if (!res.ok)
556
+ return undefined;
557
+ const json = (await res.json());
558
+ if (!Array.isArray(json))
559
+ return undefined;
560
+ return json.filter((n) => typeof n === "string");
561
+ }
562
+ catch {
563
+ return undefined;
564
+ }
565
+ }
566
+ /**
567
+ * Model files under a category directory, as CATEGORY-RELATIVE paths — the exact
568
+ * form ComfyUI's `/models/<category>` listing uses (`"sdxl/x.safetensors"`). Bare
569
+ * basenames would make `loras/a/shared.safetensors` look accounted for by a live
570
+ * `loras/b/shared.safetensors` from a different install (codex gate, round 9).
571
+ */
572
+ async function coreModelFilesUnder(categoryDir) {
573
+ let entries;
574
+ try {
575
+ entries = await readdir(categoryDir, { recursive: true });
576
+ }
577
+ catch {
578
+ return [];
579
+ }
580
+ // Not just tidiness: this feeds a REFUSAL, so an unreadable/odd listing must
581
+ // become "no evidence", never a crash inside the download path.
582
+ if (!Array.isArray(entries))
583
+ return [];
584
+ return entries
585
+ .filter((e) => typeof e === "string" && CORE_MODEL_EXTENSIONS.has(extname(e).toLowerCase()))
586
+ .map((e) => normRel(e));
587
+ }
588
+ /** How many populated categories of the candidate models root we cross-check.
589
+ * Bounded so a large models tree costs a handful of cheap HTTP calls, not one
590
+ * per folder. */
591
+ const DISAGREEMENT_PROBE_CATEGORIES = 6;
592
+ /** Immediate subdirectories of the candidate models root, in a stable order. */
593
+ async function categoriesUnder(modelsRoot) {
594
+ try {
595
+ const entries = await readdir(modelsRoot, { withFileTypes: true });
596
+ if (!Array.isArray(entries))
597
+ return [];
598
+ const names = [];
599
+ for (const e of entries) {
600
+ const entry = e;
601
+ if (!entry || typeof entry.isDirectory !== "function")
602
+ continue;
603
+ if (typeof entry.name !== "string")
604
+ continue;
605
+ if (entry.isDirectory())
606
+ names.push(entry.name);
607
+ }
608
+ return names.sort();
609
+ }
610
+ catch {
611
+ return [];
612
+ }
613
+ }
614
+ /**
615
+ * REFUSE a destination the connected ComfyUI demonstrably does not read from.
616
+ *
617
+ * Runs ONLY when the models root came from LOCAL CONFIGURATION that the running
618
+ * server never vouched for (`configured-base` / `base-anchored`) — a live-resolved
619
+ * root is already authoritative and needs no second opinion. The test is a pure
620
+ * disagreement check against the server's own `/models/<category>` listing: if a
621
+ * category directory in the candidate root holds core-extension model files and the
622
+ * live server lists NONE of them, that root is not part of its search path. That is
623
+ * the #369 signature exactly (3 files on disk, 24 unrelated files in the live
624
+ * listing), and catching it BEFORE the transfer saves the user the multi-GB download
625
+ * that would otherwise be reported as a success and then be invisible.
626
+ *
627
+ * The evidence is gathered ROOT-WIDE, not just from the target category: a stale
628
+ * install commonly has an EMPTY `diffusion_models/` while its `checkpoints/` is full
629
+ * of files the live server has never heard of (codex gate, round 1 — the target
630
+ * category alone made an empty-but-wrong destination look evidence-free).
631
+ *
632
+ * Agreement means CONTAINMENT, not overlap (codex gate, round 5): a directory the
633
+ * server scans has ALL of its model files in that server's listing. A single shared
634
+ * filename proves nothing — two unrelated installs routinely hold the same popular
635
+ * checkpoint — so overlap alone must never suppress the refusal.
636
+ *
637
+ * POSITIVE EVIDENCE ONLY. There is deliberately no "the live server lists models
638
+ * this tree cannot account for" rule: being unable to EXPLAIN the server's models is
639
+ * absence of evidence, not proof that this destination belongs to another install
640
+ * (maintainer ruling). Unaccountable is a proceed-unverified state — see the note at
641
+ * the end of the body.
642
+ *
643
+ * Fails OPEN on anything inconclusive — server unreachable, endpoint missing, an
644
+ * empty candidate directory, or full containment. It must never block a legitimate
645
+ * download into a fresh or shared models tree.
646
+ */
647
+ async function assertDestinationVisibleToLiveServer(modelsRoot, targetSubfolder, source, snapshot) {
648
+ // A live-resolved root that EXISTS on this filesystem is authoritative and needs
649
+ // no second opinion. A live-resolved root that does NOT exist locally is a
650
+ // different animal: a loopback ComfyUI inside Docker / behind an SSH forward
651
+ // reports its CONTAINER-side `--models-directory`, and writing that path here
652
+ // silently creates a host directory the server never reads (codex gate, round 3).
653
+ // The check below is cheap and fails open, so run it for that case too.
654
+ if (isLiveAuthoritativeModelsDir(source) && existsSync(modelsRoot))
655
+ return;
656
+ if (!snapshot.reachable || isRemoteMode())
657
+ return;
658
+ // The target category first (it is the one that matters), then the rest of the
659
+ // root — so a populated sibling can still expose a wrong install when the target
660
+ // folder happens to be empty.
661
+ const target = categoryOf(targetSubfolder);
662
+ const present = await categoriesUnder(modelsRoot);
663
+ const primary = [...(target ? [target] : []), ...present.filter((c) => c !== target)];
664
+ let probed = 0;
665
+ let worst;
666
+ for (const category of primary) {
667
+ if (probed >= DISAGREEMENT_PROBE_CATEGORIES)
668
+ break;
669
+ const onDisk = await coreModelFilesUnder(join(modelsRoot, category));
670
+ if (onDisk.length === 0)
671
+ continue; // nothing here to contradict anything
672
+ const listing = await liveCategoryListing(category);
673
+ if (listing === undefined)
674
+ continue; // the server can't speak for it — no evidence
675
+ probed += 1;
676
+ // CONTAINMENT, not overlap. If this directory really is one the server reads, it
677
+ // SCANS it — so EVERY core-extension file in it must appear in the listing. A
678
+ // merely-overlapping name proves nothing: two unrelated installs routinely share
679
+ // `sd_xl_base_1.0.safetensors`, and treating that as agreement let a download
680
+ // proceed into a stale tree (codex gate, round 5). Compared as CATEGORY-RELATIVE
681
+ // paths, so `a/shared.safetensors` is not accounted for by a live
682
+ // `b/shared.safetensors` (codex gate, round 9).
683
+ const liveNames = new Set(listing.map((n) => normRel(n)));
684
+ const missing = onDisk.filter((n) => !liveNames.has(n));
685
+ if (missing.length === 0)
686
+ continue; // this folder is fully accounted for — agrees
687
+ worst = { category, missing, onDisk: onDisk.length, live: listing.length };
688
+ break;
689
+ }
690
+ if (worst) {
691
+ const sample = worst.missing.slice(0, 3).join(", ");
692
+ throw new ModelError(`Refusing to download into "${modelsRoot}": the connected ComfyUI ` +
693
+ `(${getComfyUIBaseUrl()}) does not read from it. Its "${worst.category}" folder holds ` +
694
+ `${worst.onDisk} model file(s), ${worst.missing.length} of which the running server does ` +
695
+ `NOT list (e.g. ${sample}) — if it scanned this directory it would see all of them, so ` +
696
+ "this is a DIFFERENT install than the one serving you. This destination came from local " +
697
+ "configuration (COMFYUI_PATH / the saved default workspace), not from the running server, " +
698
+ "which could not tell us its own install root. Point COMFYUI_PATH at the ComfyUI that is " +
699
+ "actually running, or launch it with an absolute --base-directory, then retry.");
700
+ }
701
+ // NOTE — there is deliberately NO 'the server lists models this tree does not contain'
702
+ // refusal. That inference is itself absence of evidence: not being able to ACCOUNT
703
+ // for the server's models is not proof the destination belongs to a different
704
+ // install. The roots could be registered by a config we cannot read, one deleted
705
+ // after the server loaded it, or a path shape nobody has enumerated yet — and each
706
+ // time that rule was tightened it produced another FALSE REFUSAL of a legitimate
707
+ // setup (an external drive, a moved YAML). Per the maintainer ruling, unaccountable
708
+ // is a PROCEED-UNVERIFIED state, not a refusal: the download goes through and the
709
+ // post-write check reports honestly. This guard refuses only on POSITIVE evidence —
710
+ // files sitting in this tree that the running server demonstrably does not read.
711
+ }
712
+ /** Normalize a listing entry / relative path for comparison (ComfyUI reports
713
+ * OS-native separators). */
714
+ function normRel(s) {
715
+ return s.replace(/\\/g, "/").replace(/^\.?\//, "");
716
+ }
717
+ /** The category-relative entry a file at `<models>/<targetSubfolder>/<filename>`
718
+ * would appear as in ComfyUI's `/models/<category>` listing. */
719
+ function listingEntryFor(targetSubfolder, filename) {
720
+ const rest = subfolderRemainder(targetSubfolder);
721
+ return normRel(rest ? `${rest}/${filename}` : filename);
722
+ }
723
+ /**
724
+ * Was this exact entry ALREADY in the live server's listing before we wrote?
725
+ *
726
+ * Captured BEFORE the transfer so the post-write check can tell "the server now
727
+ * sees the file BECAUSE we wrote it" from "the server already had a file of that
728
+ * name somewhere else". Without that distinction a download into a stale tree that
729
+ * happens to share a filename with the live install verifies as `visible` and is
730
+ * reported as a success (codex gate, round 3). `undefined` = the server could not
731
+ * answer, so nothing is known either way.
732
+ */
733
+ export async function liveListingHasEntry(targetSubfolder, filename) {
734
+ const listing = await liveCategoryListing(categoryOf(targetSubfolder));
735
+ if (listing === undefined)
736
+ return undefined;
737
+ const wanted = listingEntryFor(targetSubfolder, filename);
738
+ return listing.some((n) => normRel(n) === wanted);
739
+ }
740
+ /**
741
+ * Does the live server list a file with this BASENAME anywhere in the category?
742
+ *
743
+ * Deliberately looser than `liveListingHasEntry`: the manifest's existing-file
744
+ * lookup itself matches a basename anywhere in the served category (a `checkpoints`
745
+ * target satisfied by `checkpoints/sdxl/big.safetensors`), so confirming it with an
746
+ * exact-path check would FAIL legitimate nested installs. This is used only as an
747
+ * ADDITIONAL requirement on the skip path — it can rule a skip out, never in on its
748
+ * own. `undefined` = the server could not answer.
749
+ */
750
+ export async function liveListingHasBasename(category, filename) {
751
+ const listing = await liveCategoryListing(categoryOf(category));
752
+ if (listing === undefined)
753
+ return undefined;
754
+ const wanted = basename(filename);
755
+ return listing.some((n) => basename(normRel(n)) === wanted);
756
+ }
757
+ export async function isUnderLiveModelRoots(absPath,
758
+ /** The models CATEGORY the path belongs to. Live extra roots are category-scoped
759
+ * (`{ category, dir }`), so a `checkpoints` root must never vouch for a LoRA
760
+ * (codex gate, round 6). Omitted = consider every root. */
761
+ category) {
762
+ /** Canonicalize so a junctioned/symlinked root is compared by where it REALLY
763
+ * lives. `C:\ComfyUI\models` being a junction to `D:\Models` is a legitimate,
764
+ * supported layout; a lexical-only compare would call a correctly-placed file
765
+ * "outside the live roots" and fabricate a failure (codex gate, round 6). */
766
+ const canon = async (p) => {
767
+ const lex = resolve(p);
768
+ try {
769
+ return { real: resolve(await realpath(p)), lex, ok: true };
770
+ }
771
+ catch {
772
+ return { real: lex, lex, ok: false }; // not created yet / unreadable
773
+ }
774
+ };
775
+ const target = await canon(absPath);
776
+ const under = (root) => {
777
+ const hit = (r, t) => t === r || t.startsWith(r + sep);
778
+ // Either canonical form matching is enough — only ONE side may be resolvable.
779
+ return (hit(root.real, target.real) ||
780
+ hit(root.lex, target.lex) ||
781
+ hit(root.real, target.lex) ||
782
+ hit(root.lex, target.real));
783
+ };
784
+ let dest;
785
+ try {
786
+ dest = await resolveModelsDirWithBases();
787
+ }
788
+ catch {
789
+ return { inRoots: undefined };
790
+ }
791
+ if (!isLiveAuthoritativeModelsDir(dest.source))
792
+ return { inRoots: undefined };
793
+ const liveRoot = resolve(dest.modelsDir);
794
+ // A negative answer is only honest when everything it rests on could actually be
795
+ // canonicalized; otherwise say UNKNOWN rather than accuse a correct placement.
796
+ let fullyCanonical = target.ok;
797
+ const primary = await canon(dest.modelsDir);
798
+ fullyCanonical = fullyCanonical && primary.ok;
799
+ if (under(primary))
800
+ return { inRoots: true, liveRoot };
801
+ let extra;
802
+ try {
803
+ extra = await getLiveExtraModelRoots(dest.snapshot);
804
+ }
805
+ catch {
806
+ return { inRoots: undefined, liveRoot };
807
+ }
808
+ const wantCat = (category ?? "").trim().toLowerCase();
809
+ for (const r of extra.roots) {
810
+ if (wantCat && String(r.category ?? "").trim().toLowerCase() !== wantCat)
811
+ continue;
812
+ const rc = await canon(r.dir);
813
+ fullyCanonical = fullyCanonical && rc.ok;
814
+ if (under(rc))
815
+ return { inRoots: true, liveRoot };
816
+ }
817
+ // The live roots are known and this path is in none of them.
818
+ if (!extra.authoritative)
819
+ return { inRoots: undefined, liveRoot };
820
+ return { inRoots: fullyCanonical ? false : undefined, liveRoot };
821
+ }
822
+ /**
823
+ * The models root the connected server reads RIGHT NOW — and ONLY when that answer
824
+ * is LIVE-AUTHORITATIVE. A transient `/system_stats` outage makes the resolver fall
825
+ * back to COMFYUI_PATH, and reporting THAT as "what the server reads now" would
826
+ * downgrade a correctly verified download with a false "a DIFFERENT install"
827
+ * warning (codex gate, round 12). Unknown is the honest answer there. Never throws.
828
+ */
829
+ /** Do two absolute models roots name the same directory? Windows paths are
830
+ * case-insensitive and mix separators, so comparing them raw would report a
831
+ * server "change" that never happened. */
832
+ export function sameModelsRoot(a, b) {
833
+ const norm = (s) => {
834
+ const slashed = s.replace(/\\/g, "/").replace(/\/+$/, "");
835
+ return platform() === "win32" ? slashed.toLowerCase() : slashed;
836
+ };
837
+ return norm(a) === norm(b);
838
+ }
839
+ export async function currentLiveModelsRoot() {
840
+ try {
841
+ const dest = await resolveModelsDirWithBases();
842
+ if (!isLiveAuthoritativeModelsDir(dest.source))
843
+ return undefined;
844
+ return resolve(dest.modelsDir);
845
+ }
846
+ catch {
847
+ return undefined;
848
+ }
849
+ }
850
+ /**
851
+ * Why a destination could not be confirmed, and how the USER can upgrade themselves
852
+ * into a verified answer.
853
+ *
854
+ * Saying "unconfirmed" without saying WHY leaves the user with nothing to act on.
855
+ * There are exactly two reasons the live root stays unpinnable: the running server
856
+ * reported a relative `main.py` with no working directory (so its own report is not
857
+ * enough), and the OS process table could not be read to identify the process on our
858
+ * port. The second is a TOOLING gap on POSIX — `lsof` is simply not installed on
859
+ * many minimal images — and installing it converts this host into a verifiable one.
860
+ */
861
+ function unverifiableDestinationRemedy() {
862
+ const probe = platform() === "win32"
863
+ ? "the process listening on the ComfyUI port could not be identified"
864
+ : "the process listening on the ComfyUI port could not be identified (this needs `lsof`, " +
865
+ "which is missing on many minimal images — installing it makes future downloads verifiable)";
866
+ return ("This happens when the running ComfyUI reports a RELATIVE main.py with no working " +
867
+ `directory AND ${probe}. To get a definite answer: point COMFYUI_PATH at the ComfyUI ` +
868
+ "that is actually running, launch it with an absolute --base-directory, or make its " +
869
+ "process observable. Meanwhile, confirm with list_local_models.");
870
+ }
871
+ /** How many times we re-ask the live server before concluding a landed file is
872
+ * invisible to it. ComfyUI caches its folder listings and invalidates on the
873
+ * directory mtime, so the first re-read normally already sees the new file; the
874
+ * retries only cover a filesystem whose mtime lands a beat late. */
875
+ const LIVE_VISIBILITY_ATTEMPTS = 3;
876
+ const LIVE_VISIBILITY_RETRY_MS = 1000;
877
+ /**
878
+ * Confirm, AFTER a download lands, that the bytes are (a) really on disk and
879
+ * (b) somewhere the LIVE server reads from — then report the path we actually
880
+ * confirmed. Reporting the INTENDED path is what made #369 a fabricated success:
881
+ * `download_status` said "done … landed at <stale install>" while the running
882
+ * ComfyUI could not see the file at all.
883
+ *
884
+ * Never throws: a completed transfer must not be turned into a failure by a
885
+ * verification hiccup. An inconclusive answer is reported as `unknown` with the
886
+ * reason, which callers surface instead of an unqualified success.
887
+ */
888
+ export async function verifyLandedModel(targetPath, targetSubfolder, opts) {
889
+ let verifiedPath;
890
+ try {
891
+ const info = await stat(targetPath);
892
+ if (!info.isFile()) {
893
+ return {
894
+ liveVisible: "unknown",
895
+ note: `${targetPath} exists but is not a file.`,
896
+ };
897
+ }
898
+ try {
899
+ verifiedPath = resolve(await realpath(targetPath));
900
+ }
901
+ catch {
902
+ verifiedPath = resolve(targetPath);
903
+ }
904
+ }
905
+ catch {
906
+ return {
907
+ liveVisible: "unknown",
908
+ note: `The file could not be confirmed on disk at ${targetPath}.`,
909
+ };
910
+ }
911
+ if (isRemoteMode()) {
912
+ return {
913
+ verifiedPath,
914
+ liveVisible: "unknown",
915
+ note: "The connected ComfyUI is remote, so local on-disk placement cannot be checked against it.",
916
+ };
917
+ }
918
+ const category = categoryOf(targetSubfolder);
919
+ // Match the FULL path the server would report relative to the category, not the
920
+ // bare filename: `/models/loras` lists nested entries as "a/foo.safetensors", so a
921
+ // basename comparison would call a download into `loras/a` "visible" because the
922
+ // LIVE tree happens to hold an unrelated `loras/b/foo.safetensors` — a direct
923
+ // false success (codex gate, round 2). Separators are normalized because ComfyUI
924
+ // reports OS-native ones.
925
+ const wanted = listingEntryFor(targetSubfolder, basename(targetPath));
926
+ const attempts = opts?.attempts ?? LIVE_VISIBILITY_ATTEMPTS;
927
+ const retryMs = opts?.retryMs ?? LIVE_VISIBILITY_RETRY_MS;
928
+ // Is the directory we wrote into one the RUNNING server told us about? Only then
929
+ // does "the server lists this name" prove that the bytes we just wrote are the
930
+ // ones it will load. On a locally-configured root a pre-existing entry of the
931
+ // same name in the LIVE tree would otherwise verify our write into a STALE tree
932
+ // as a success (codex gate, round 3) — so a name that was already listed BEFORE
933
+ // the download proves nothing there.
934
+ // Is the file inside a tree the RUNNING server actually reads? `true` makes the
935
+ // listing decisive; `false` means it demonstrably is not (a ComfyUI restart onto
936
+ // a DIFFERENT install between the write and this check produces exactly that —
937
+ // codex gate, round 4); `undefined` means only local configuration could answer,
938
+ // so a pre-existing listing entry proves nothing about OUR bytes.
939
+ const landed = verifiedPath ?? resolve(targetPath);
940
+ const membership = await isUnderLiveModelRoots(landed, category);
941
+ const destinationIsLiveAuthoritative = membership.inRoots === true;
942
+ if (membership.inRoots === false) {
943
+ const liveRoot = membership.liveRoot;
944
+ return {
945
+ verifiedPath,
946
+ verifiedAgainstRoot: liveRoot,
947
+ liveVisible: "not-visible",
948
+ note: `The file IS on disk at ${landed}, but that is OUTSIDE every directory the ` +
949
+ `connected ComfyUI (${getComfyUIBaseUrl()}) reads models from` +
950
+ (liveRoot ? ` (its models directory is ${liveRoot})` : "") +
951
+ ". This happens when the running server is replaced by a different install " +
952
+ "while a download is in flight. Move the file into the running server's models " +
953
+ "tree, or re-download now that the correct server is connected.",
954
+ };
955
+ }
956
+ // A listing hit only PROVES something about the bytes we just wrote when the
957
+ // directory we wrote to is one the RUNNING server told us it reads. On any other
958
+ // destination the listing describes a tree we cannot tie to this write:
959
+ // - the name may have been there all along (round 3);
960
+ // - the pre-write listing may have been unavailable, so "it appeared" is not
961
+ // established (round 14);
962
+ // - the server answering AFTER the write may not even be the one that answered
963
+ // BEFORE it, and its own same-named model then masquerades as ours (round 17).
964
+ // Each of those was a separate route to a fabricated success, and every fix that
965
+ // stopped short of this rule left another. So: a non-authoritative destination is
966
+ // reported HONESTLY as unconfirmed. It costs a noisier message on hosts where the
967
+ // live root cannot be pinned; it never refuses, never fails, and never lies.
968
+ const ambiguous = !destinationIsLiveAuthoritative;
969
+ let sawListing = false;
970
+ for (let i = 0; i < attempts; i++) {
971
+ const listing = await liveCategoryListing(category);
972
+ if (listing !== undefined) {
973
+ sawListing = true;
974
+ if (listing.some((n) => normRel(n) === wanted)) {
975
+ // RE-CHECK membership AFTER the listing. The root check and the listing are
976
+ // two separate observations; a ComfyUI restart onto a DIFFERENT install
977
+ // between them would otherwise let the NEW server's own same-named model
978
+ // confirm OUR file, which that server cannot read (codex gate, round 11).
979
+ const still = await isUnderLiveModelRoots(landed, category);
980
+ if (still.inRoots === false) {
981
+ return {
982
+ verifiedPath,
983
+ // Stamp the root THIS check ran against — never a third, later
984
+ // observation, which could name yet another server (codex gate, r12).
985
+ verifiedAgainstRoot: still.liveRoot,
986
+ liveVisible: "not-visible",
987
+ note: `The file IS on disk at ${landed}, but the connected ComfyUI ` +
988
+ `(${getComfyUIBaseUrl()}) changed while this was being checked and no longer ` +
989
+ "reads from that location — the entry it lists is its OWN file of the same name. " +
990
+ "Re-download now that the correct server is connected.",
991
+ };
992
+ }
993
+ if (!ambiguous) {
994
+ return {
995
+ verifiedPath,
996
+ // The root the POST-listing membership check just validated against —
997
+ // taking a fresh observation here could stamp a server that replaced it
998
+ // between the two awaits (codex gate, round 12).
999
+ verifiedAgainstRoot: still.liveRoot ?? membership.liveRoot,
1000
+ liveVisible: "visible",
1001
+ };
1002
+ }
1003
+ return {
1004
+ verifiedPath,
1005
+ liveVisible: "unknown",
1006
+ note: `The connected ComfyUI (${getComfyUIBaseUrl()}) lists "${wanted}" under "${category}", ` +
1007
+ "but this destination came from local configuration rather than from the running " +
1008
+ "server, so that listing cannot be tied to the file just written to " +
1009
+ `${verifiedPath} — it may be the server's own copy elsewhere` +
1010
+ (opts?.listedBefore === true
1011
+ ? " (it already listed that name before this download)"
1012
+ : opts?.listedBefore === undefined
1013
+ ? " (whether it listed that name before this download could not be checked)"
1014
+ : "") +
1015
+ ". " +
1016
+ unverifiableDestinationRemedy(),
1017
+ };
1018
+ }
1019
+ }
1020
+ if (i < attempts - 1 && retryMs > 0) {
1021
+ await new Promise((r) => setTimeout(r, retryMs));
1022
+ }
1023
+ }
1024
+ if (!sawListing) {
1025
+ return {
1026
+ verifiedPath,
1027
+ liveVisible: "unknown",
1028
+ note: `The connected ComfyUI (${getComfyUIBaseUrl()}) did not answer for the ` +
1029
+ `"${category}" model folder, so it could not be confirmed that it reads from this location.`,
1030
+ };
1031
+ }
1032
+ let liveModelsDir;
1033
+ try {
1034
+ liveModelsDir = await resolveModelsDir();
1035
+ }
1036
+ catch {
1037
+ liveModelsDir = undefined;
1038
+ }
1039
+ return {
1040
+ verifiedPath,
1041
+ liveVisible: "not-visible",
1042
+ note: `The file IS on disk at ${verifiedPath}, but the connected ComfyUI ` +
1043
+ `(${getComfyUIBaseUrl()}) does NOT list "${wanted}" under "${category}" — it will not be ` +
1044
+ `usable in a workflow from there.` +
1045
+ (liveModelsDir ? ` The models directory that server reads is ${liveModelsDir}.` : "") +
1046
+ " Move the file into the running server's models tree (or point COMFYUI_PATH at that install and re-download).",
1047
+ };
1048
+ }
506
1049
  /**
507
1050
  * Like resolveModelSubfolder, but roots the destination at the CONNECTED
508
1051
  * server's real models directory (its `--base-directory`/models, read from
@@ -514,6 +1057,19 @@ export function resolveModelSubfolder(targetSubfolder) {
514
1057
  * same containment guard as the sync variant.
515
1058
  */
516
1059
  export async function resolveModelSubfolderPreferServer(targetSubfolder) {
1060
+ return (await resolveModelSubfolderWithLiveRoot(targetSubfolder)).targetDir;
1061
+ }
1062
+ /**
1063
+ * As `resolveModelSubfolderPreferServer`, but also returns the LIVE models root that
1064
+ * THIS resolution used — when it was live-authoritative.
1065
+ *
1066
+ * The writer must bind its destination to the server the destination was computed
1067
+ * for. Taking a separate observation afterwards leaves a window in which the server
1068
+ * is replaced BETWEEN the resolution and the capture, so both later observations
1069
+ * agree with each other while the target still points into the previous install
1070
+ * (codex gate, round 18). Returning it from the same call closes that window.
1071
+ */
1072
+ export async function resolveModelSubfolderWithLiveRoot(targetSubfolder) {
517
1073
  const raw = (targetSubfolder ?? "").trim();
518
1074
  if (!raw) {
519
1075
  throw new ModelError("target_subfolder is required (e.g. 'loras', 'checkpoints').");
@@ -525,12 +1081,18 @@ export async function resolveModelSubfolderPreferServer(targetSubfolder) {
525
1081
  // code-root veto needs, AND the snapshot the escape-authorizer uses — so they can
526
1082
  // never disagree (a second stats call could straddle a server restart and combine
527
1083
  // model roots from server B with code bases from server A; #633 codex).
528
- const { modelsDir, baseDirs, snapshot } = await resolveModelsDirWithBases();
1084
+ const { modelsDir, baseDirs, snapshot, source } = await resolveModelsDirWithBases();
529
1085
  const modelsRoot = resolve(modelsDir);
530
1086
  const targetDir = resolve(modelsRoot, raw);
531
1087
  if (targetDir !== modelsRoot && !targetDir.startsWith(modelsRoot + sep)) {
532
1088
  throw new ModelError(`Refusing to write outside the models directory: ${raw}`);
533
1089
  }
1090
+ // The root did NOT come from the running server (it could not tell us where it
1091
+ // lives, so we fell back to local config). Before writing, check the one thing
1092
+ // that can still expose a stale-install destination: the server's own listing for
1093
+ // this category. #369 shipped 4.88 GB into a directory the live ComfyUI had never
1094
+ // heard of; refusing here costs nothing and saves the transfer.
1095
+ await assertDestinationVisibleToLiveServer(modelsRoot, raw, source, snapshot);
534
1096
  // Path-string containment (above) is not enough: an EXISTING symlink somewhere
535
1097
  // between modelsRoot and targetDir could redirect the real write OUTSIDE the
536
1098
  // models dir. Because THIS resolver is the single canonical write-target for
@@ -539,7 +1101,10 @@ export async function resolveModelSubfolderPreferServer(targetSubfolder) {
539
1101
  // so it can never diverge from a caller's separate pre-validation (e.g.
540
1102
  // apply_manifest resolving the root a second time; #490 codex review).
541
1103
  await assertNoEscapingSymlinkAncestor(modelsRoot, targetDir, raw, baseDirs, snapshot);
542
- return targetDir;
1104
+ return {
1105
+ targetDir,
1106
+ liveRootAtResolve: isLiveAuthoritativeModelsDir(source) ? modelsRoot : undefined,
1107
+ };
543
1108
  }
544
1109
  /**
545
1110
  * Enforce that a local model download can ONLY land where the running ComfyUI
@@ -762,7 +1327,7 @@ liveSnapshot = { reachable: false }) {
762
1327
  * "..". Throws ModelError on an invalid subfolder or filename.
763
1328
  */
764
1329
  export async function resolveDownloadTarget(url, targetSubfolder, filename) {
765
- const targetDir = await resolveModelSubfolderPreferServer(targetSubfolder);
1330
+ const { targetDir, liveRootAtResolve } = await resolveModelSubfolderWithLiveRoot(targetSubfolder);
766
1331
  const rawFilename = filename ?? (basename(new URL(url).pathname) || "model.safetensors");
767
1332
  const resolvedFilename = basename(rawFilename);
768
1333
  if (resolvedFilename !== rawFilename ||
@@ -775,7 +1340,7 @@ export async function resolveDownloadTarget(url, targetSubfolder, filename) {
775
1340
  if (!resolve(targetPath).startsWith(resolve(targetDir) + sep)) {
776
1341
  throw new ModelError("Refusing to write outside the target model directory.", { filename: rawFilename });
777
1342
  }
778
- return { targetDir, filename: resolvedFilename, targetPath };
1343
+ return { targetDir, filename: resolvedFilename, targetPath, liveRootAtResolve };
779
1344
  }
780
1345
  /**
781
1346
  * Resolve a relative-to-models path against a known root, keeping the result
@@ -1123,14 +1688,18 @@ export async function shouldDispatchDownloadToManager() {
1123
1688
  // opaque/relative argv we can't resolve).
1124
1689
  if (parseModelsDirFromArgv(argv, cwd))
1125
1690
  return false;
1126
- // Derive the server's own install root from argv (its main.py). Only treat it
1127
- // as a LOCAL stream target when that path ACTUALLY EXISTS on this filesystem:
1128
- // a loopback ComfyUI inside Docker / behind an SSH port-forward reports a
1129
- // container-side main.py path that is NOT the host's, so writing there would
1130
- // create a bogus host directory instead of reaching the server. When the root
1131
- // isn't locally present, hand the fetch to the connected Manager (server-side
1132
- // write), which lands in the real install regardless.
1133
- const liveRoot = liveRootFromArgv(argv, cwd);
1691
+ // Derive the server's own install root through the ONE canonical resolver — the
1692
+ // SAME notion resolveModelsDirWithBases writes into, so this predicate can never
1693
+ // disagree with the destination (#369). It covers the relative-`main.py`-no-cwd
1694
+ // shape (Desktop / Windows portable) that the old argv-only check could not
1695
+ // resolve, which needlessly bounced those installs through the Manager (and then
1696
+ // failed outright when Manager isn't installed). Only treat the root as a LOCAL
1697
+ // stream target when it ACTUALLY EXISTS on this filesystem: a loopback ComfyUI
1698
+ // inside Docker / behind an SSH port-forward reports a container-side path that
1699
+ // is NOT the host's, so writing there would create a bogus host directory
1700
+ // instead of reaching the server. When it isn't locally present, hand the fetch
1701
+ // to the connected Manager (server-side write), which lands correctly regardless.
1702
+ const liveRoot = resolveLiveServerRoot(argv, cwd, { remote: false }).root;
1134
1703
  if (liveRoot && existsSync(liveRoot))
1135
1704
  return false;
1136
1705
  return true;
@@ -1204,10 +1773,40 @@ onLanded) {
1204
1773
  // not blindly at COMFYUI_PATH/models — otherwise a Desktop install downloads
1205
1774
  // into a stale checkout the running server never reads (#346/#369). Resolution
1206
1775
  // + filename validation go through the SHARED resolveDownloadTarget so the
1207
- // background job registry keys jobs by the exact same targetPath (no drift).
1208
- const { targetDir, targetPath, filename: resolvedFilename } = await resolveDownloadTarget(url, targetSubfolder, filename);
1776
+ // background job registry keys jobs by the exact same targetPath.
1777
+ //
1778
+ // DELIBERATE: this RE-RESOLVES rather than reusing the target startDownloadJob
1779
+ // keyed on. If the live server was replaced in between (a restart onto the same
1780
+ // port serving a DIFFERENT install), the freshly resolved root is the one the
1781
+ // server now reads, and the stale key is the one that would put the file where
1782
+ // nobody looks — so the CURRENT destination wins over a stable key. `onLanded`
1783
+ // reports the real path back to the job, and verifyLandedModel confirms it, so
1784
+ // nothing is ever reported at the stale path. Accepted residual (disclosed):
1785
+ // across such a restart the job's serialization key can name the previous
1786
+ // destination, so a concurrent same-file request may run a duplicate transfer.
1787
+ // That is bounded by the existing per-writer O_EXCL temp + atomic rename (#467) —
1788
+ // a duplicate download, never a corrupt file — and is strictly preferable to
1789
+ // writing into an install the server no longer serves.
1790
+ const { targetDir, targetPath, filename: resolvedFilename,
1791
+ // BIND the destination to the server it was resolved for. Re-resolving is right,
1792
+ // but the answer must not go stale between resolving it and writing: a ComfyUI
1793
+ // replaced during the mkdir would otherwise take the bytes into the root the
1794
+ // PREVIOUS server read. This root comes from the SAME resolution that produced
1795
+ // targetPath — capturing it separately afterwards would leave a window in which
1796
+ // the swap happens BEFORE the capture, so both later observations agree while the
1797
+ // target still points into the old install (codex gate, round 18). Both roots
1798
+ // must be live-authoritative to compare; when either is unknown there is nothing
1799
+ // to bind, and the (already never-confirmed) non-authoritative path applies.
1800
+ liveRootAtResolve: rootAtResolve, } = await resolveDownloadTarget(url, targetSubfolder, filename);
1209
1801
  // Ensure target directory exists
1210
1802
  await mkdir(targetDir, { recursive: true });
1803
+ const rootBeforeWrite = await currentLiveModelsRoot();
1804
+ if (rootAtResolve && rootBeforeWrite && !sameModelsRoot(rootAtResolve, rootBeforeWrite)) {
1805
+ throw new ModelError(`Refusing to start this download: the connected ComfyUI changed while the destination was ` +
1806
+ `being prepared. It read "${rootAtResolve}" when the destination was resolved and reads ` +
1807
+ `"${rootBeforeWrite}" now, so "${targetPath}" is no longer where the running server ` +
1808
+ "looks. Re-issue the download now that the current server is connected.");
1809
+ }
1211
1810
  const request = applyDownloadAuth(url, auth);
1212
1811
  const headers = { ...request.headers };
1213
1812
  // HF token: attach ONLY when the url's PARSED hostname is huggingface.co (or a subdomain),