comfyui-mcp 0.52.91 → 0.52.93

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.
@@ -27,13 +27,13 @@ import { createHash, randomUUID } from "node:crypto";
27
27
  // #873 — the operator's tool-surface policy also governs the panel surface.
28
28
  import { resolveToolSurfacePolicy, toolActionPolicyError, toolAllowed, } from "../tools/tool-surface-filter.js";
29
29
  import { logger as toolPolicyLogger } from "../utils/logger.js";
30
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
30
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
31
31
  import { forwardedByReferenceNote, oversizedInlineRefusal, resolveServableViewRef, stageFileIntoServedDir, stagedForDisplayNote, readShowMediaAck, unaccountedShowMediaNote, unverifiedViewRefNote, } from "../services/comfy-view-ref.js";
32
32
  import { extname, isAbsolute, join, resolve, sep } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { comfyuiFetch } from "../comfyui/fetch.js";
35
35
  import { assertPanelNotTargetedUnverifiable } from "../services/panel-pin-guard.js";
36
- import { nodesInstallCommandArgs } from "../services/node-management.js";
36
+ import { findPackOnDisk, nodesInstallCommandArgs, } from "../services/node-management.js";
37
37
  import { sanitizePanelUpdateNodeResult } from "../services/manager-update-error.js";
38
38
  import { formatQueueStatusPartialNote, getManifestPartialLeftover, } from "../services/manifest-partial.js";
39
39
  import { searchPanelNodes } from "../services/manager-node-search.js";
@@ -183,7 +183,7 @@ import { describeUnappliedFilters } from "./civitai-filter-guard.js";
183
183
  import { recordTodo, normalizeTodoItems, TODO_STATUS_INPUTS } from "./todo-state.js";
184
184
  import { applyCapturedWidgetValues } from "../services/live-widget-overlay.js";
185
185
  import { listWorkflowLibraryKeys, userdataFetch } from "../services/userdata-library.js";
186
- import { resolveEffectiveComfyUIBase } from "../services/workspace-env.js";
186
+ import { resolveCustomNodesScanBaseLive, resolveEffectiveComfyUIBase, } from "../services/workspace-env.js";
187
187
  import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
188
188
  import { QueueMonitor } from "../services/queue-monitor.js";
189
189
  import { RunCompletions } from "./run-completion-journal.js";
@@ -3215,11 +3215,256 @@ function panelIncarnation(ctx, tabId) {
3215
3215
  const b = ctx.bridge;
3216
3216
  return typeof b?.tabIncarnation === "function" ? b.tabIncarnation(tabId) : undefined;
3217
3217
  }
3218
- async function settleDroppedEnqueue(ctx, res, dispatch) {
3219
- if (res.isError)
3218
+ function registryInstallId(args) {
3219
+ // The normalized command is the authority here. In particular, a repository
3220
+ // URL that arrived in `id` has already been rerouted to `repository` and must
3221
+ // not enter this local registry-only fallback.
3222
+ if (args.repository !== undefined || typeof args.id !== "string")
3223
+ return null;
3224
+ const id = args.id.trim();
3225
+ if (!id || /[/\\\x00-\x1F\x7F]/.test(id) || /^[a-z][a-z0-9+.-]*:\/\//i.test(id)) {
3226
+ return null;
3227
+ }
3228
+ return id;
3229
+ }
3230
+ /** A path comparison that does not let case or slash spelling retarget evidence on Windows. */
3231
+ function sameFilesystemPath(a, b) {
3232
+ try {
3233
+ const normalize = (value) => resolve(value).replace(/[\\/]+$/, "");
3234
+ const left = normalize(a);
3235
+ const right = normalize(b);
3236
+ return process.platform === "win32"
3237
+ ? left.toLowerCase() === right.toLowerCase()
3238
+ : left === right;
3239
+ }
3240
+ catch {
3241
+ return false;
3242
+ }
3243
+ }
3244
+ /**
3245
+ * Strict post-install evidence for the registry-only fallback.
3246
+ *
3247
+ * `findPackOnDisk` is intentionally broader because other node-management
3248
+ * operations must recognize renamed and disabled packs. This fallback needs a
3249
+ * narrower answer: the exact, newly-created, enabled directory, with Manager's
3250
+ * tracking marker and readable Python/package metadata. A symlink, marker-only
3251
+ * husk, arbitrary same-name folder, or any read error is inconclusive.
3252
+ */
3253
+ function inspectNewRegistryPack(id, scanBase) {
3254
+ const customNodes = join(scanBase, "custom_nodes");
3255
+ try {
3256
+ const entries = readdirSync(customNodes, { withFileTypes: true });
3257
+ const wanted = id.toLowerCase();
3258
+ const entry = entries.find((candidate) => candidate.name.toLowerCase() === wanted);
3259
+ if (!entry || entry.isSymbolicLink() || !entry.isDirectory()) {
3260
+ return { state: "not-found", scanned: customNodes };
3261
+ }
3262
+ const dir = join(customNodes, entry.name);
3263
+ const lstat = lstatSync(dir);
3264
+ if (!lstat.isDirectory() || lstat.isSymbolicLink()) {
3265
+ return { state: "not-found", scanned: customNodes };
3266
+ }
3267
+ // Reject junctions and other directory indirections too. The panel's local
3268
+ // proof must describe the directory Manager just populated, not another tree.
3269
+ if (!sameFilesystemPath(realpathSync(dir), dir)) {
3270
+ return { state: "not-found", scanned: customNodes };
3271
+ }
3272
+ const files = readdirSync(dir, { withFileTypes: true });
3273
+ const tracking = files.find((file) => file.name === ".tracking" && file.isFile());
3274
+ const code = files.find((file) => file.isFile() &&
3275
+ (file.name.toLowerCase() === "__init__.py" ||
3276
+ file.name.toLowerCase() === "pyproject.toml" ||
3277
+ file.name.toLowerCase().endsWith(".py")));
3278
+ if (!tracking || !code)
3279
+ return { state: "not-found", scanned: customNodes };
3280
+ // Reading both the Manager marker and the loadable file makes an access
3281
+ // failure a refusal, never a truthy `looksLikeAPack` fallback.
3282
+ readFileSync(join(dir, tracking.name));
3283
+ readFileSync(join(dir, code.name));
3284
+ return { state: "found", dir };
3285
+ }
3286
+ catch (err) {
3287
+ return {
3288
+ state: "unreadable",
3289
+ reason: err instanceof Error ? err.message : String(err),
3290
+ };
3291
+ }
3292
+ }
3293
+ async function captureRegistryInstallSnapshot(args, panelBaseBeforeDispatch) {
3294
+ const id = registryInstallId(args);
3295
+ if (!id || !panelBaseBeforeDispatch)
3296
+ return null;
3297
+ try {
3298
+ const scanBase = await resolveCustomNodesScanBaseLive({ requireLive: true });
3299
+ if (!scanBase)
3300
+ return null;
3301
+ return { id, scanBase, before: findPackOnDisk(id, scanBase) };
3302
+ }
3303
+ catch {
3304
+ return null;
3305
+ }
3306
+ }
3307
+ function queueStatusRecord(reply) {
3308
+ if (!reply)
3309
+ return null;
3310
+ const nested = reply.status;
3311
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
3312
+ // Manager responses can put queue counters under `status` while leaving
3313
+ // failure metadata (for example recent_failures) on the outer envelope.
3314
+ // Flatten both records so the settlement gate cannot discard that evidence.
3315
+ return { ...reply, ...nested };
3316
+ }
3317
+ return reply;
3318
+ }
3319
+ function explicitQueueFailure(value) {
3320
+ if (value === true)
3321
+ return true;
3322
+ if (typeof value === "number")
3323
+ return Number.isFinite(value) && value > 0;
3324
+ if (typeof value === "string")
3325
+ return value.trim() !== "";
3326
+ if (Array.isArray(value))
3327
+ return value.length > 0;
3328
+ return value !== null && typeof value === "object";
3329
+ }
3330
+ function queueReportsFailure(status) {
3331
+ // These are the protocol's explicit failure payloads. Empty lists/strings and
3332
+ // false flags are not failures; every non-empty/error-shaped value is.
3333
+ for (const key of [
3334
+ "failed",
3335
+ "error",
3336
+ "failure",
3337
+ "failures",
3338
+ "recent_failures",
3339
+ "errors",
3340
+ ]) {
3341
+ if (explicitQueueFailure(status[key]))
3342
+ return true;
3343
+ }
3344
+ for (const key of [
3345
+ "failed_count",
3346
+ "failure_count",
3347
+ "error_count",
3348
+ "fail_count",
3349
+ ]) {
3350
+ if (typeof status[key] === "number" && status[key] > 0)
3351
+ return true;
3352
+ }
3353
+ if (typeof status.failure_reporting === "string") {
3354
+ const reporting = status.failure_reporting.trim().toLowerCase();
3355
+ if (reporting === "failed" || reporting === "failure" || reporting === "error") {
3356
+ return true;
3357
+ }
3358
+ }
3359
+ const state = typeof status.status === "string" ? status.status.toLowerCase() : "";
3360
+ return state === "failed" || state === "failure" || state === "error";
3361
+ }
3362
+ function queueSettledForDiskCorroboration(status, outerReply) {
3363
+ if (!status || queueReportsFailure(status) || (outerReply && queueReportsFailure(outerReply))) {
3364
+ return false;
3365
+ }
3366
+ if (status.is_processing !== false)
3367
+ return false;
3368
+ for (const key of ["pending_count", "in_progress_count"]) {
3369
+ if (typeof status[key] === "number" && status[key] !== 0)
3370
+ return false;
3371
+ }
3372
+ // A legacy zero-total idle snapshot is the known dropped-enqueue signature;
3373
+ // settleDroppedEnqueue turns that into a warning and blocks this path. A v4
3374
+ // idle snapshot has no total_count, so the strict disk delta remains the
3375
+ // available post-install evidence.
3376
+ if (status.total_count === 0)
3377
+ return false;
3378
+ if (typeof status.total_count === "number" &&
3379
+ (typeof status.done_count !== "number" || status.done_count <= 0)) {
3380
+ return false;
3381
+ }
3382
+ return typeof status.pending_count === "number" ||
3383
+ (typeof status.total_count === "number" && status.total_count > 0) ||
3384
+ (typeof status.done_count === "number" && status.done_count > 0);
3385
+ }
3386
+ /**
3387
+ * #2180 — Panel 0.15.71 can report a registry install as unverified when a
3388
+ * registry zip lands in custom_nodes without entering the installed list.
3389
+ *
3390
+ * The fallback is deliberately narrower than the normal node-management disk
3391
+ * scan: it can only corroborate a registry id whose target was absent in a
3392
+ * pre-dispatch snapshot, after the queued response has passed queue settlement,
3393
+ * and only when a strict new-pack read succeeds on the same live target.
3394
+ */
3395
+ async function corroborateUntrackedRegistryInstall(ctx, res, snapshot, dispatch, panelBaseBeforeDispatch, mayCorroborateDisk) {
3396
+ if (!mayCorroborateDisk || res.isError || !snapshot || snapshot.before.state !== "not-found") {
3220
3397
  return res;
3221
- if (!claimsQueued(parseToolResultJson(res)))
3398
+ }
3399
+ const parsed = parseToolResultJson(res);
3400
+ if (!parsed ||
3401
+ parsed.installed !== false ||
3402
+ parsed.verified !== false ||
3403
+ !claimsQueued(parsed) ||
3404
+ parsed.failed === true ||
3405
+ parsed.error === true) {
3406
+ return res;
3407
+ }
3408
+ // Require the panel response to echo the same registry identity. A fresh
3409
+ // directory with only a caller-supplied same-name id is not proof that the
3410
+ // Manager installed that id.
3411
+ if (typeof parsed.id !== "string" ||
3412
+ parsed.id.trim().toLowerCase() !== snapshot.id.toLowerCase()) {
3413
+ return res;
3414
+ }
3415
+ // The follow-up must still be attributable to the panel and server that were
3416
+ // captured before dispatch. This mirrors settleDroppedEnqueue's takeover guard
3417
+ // and keeps local disk evidence inside the requireLive/security scope.
3418
+ if (ctx.tabId !== dispatch.tab || dispatch.incarnation === undefined)
3419
+ return res;
3420
+ if (panelIncarnation(ctx, ctx.tabId) !== dispatch.incarnation)
3421
+ return res;
3422
+ if (!panelBaseBeforeDispatch || !sameHttpBase(getComfyUIBaseUrl(), panelBaseBeforeDispatch)) {
3423
+ return res;
3424
+ }
3425
+ let liveScanBase;
3426
+ try {
3427
+ liveScanBase = await resolveCustomNodesScanBaseLive({ requireLive: true });
3428
+ }
3429
+ catch {
3222
3430
  return res;
3431
+ }
3432
+ if (!liveScanBase || !sameFilesystemPath(liveScanBase, snapshot.scanBase))
3433
+ return res;
3434
+ const disk = inspectNewRegistryPack(snapshot.id, snapshot.scanBase);
3435
+ if (disk.state !== "found")
3436
+ return res;
3437
+ const upgraded = {
3438
+ ...parsed,
3439
+ installed: true,
3440
+ verified: true,
3441
+ restart_required: true,
3442
+ verification_evidence: "new-on-disk-registry-pack",
3443
+ note: [
3444
+ typeof parsed.note === "string" ? parsed.note : undefined,
3445
+ `The pre-install snapshot found no matching pack, and after queue settlement a readable enabled registry pack with Manager tracking metadata appeared at ${disk.dir}.`,
3446
+ "The disk evidence does not establish the requested version or channel; those response fields were preserved unchanged. Restart ComfyUI before relying on the new nodes.",
3447
+ ]
3448
+ .filter(Boolean)
3449
+ .join(" "),
3450
+ };
3451
+ return {
3452
+ ...res,
3453
+ content: [
3454
+ { type: "text", text: JSON.stringify(upgraded, null, 2) },
3455
+ ...res.content.slice(1),
3456
+ ],
3457
+ ...(res.structuredContent
3458
+ ? { structuredContent: { ...res.structuredContent, ...upgraded } }
3459
+ : {}),
3460
+ };
3461
+ }
3462
+ async function settleDroppedEnqueue(ctx, res, dispatch) {
3463
+ if (res.isError)
3464
+ return { result: res, mayCorroborateDisk: false };
3465
+ if (!claimsQueued(parseToolResultJson(res))) {
3466
+ return { result: res, mayCorroborateDisk: false };
3467
+ }
3223
3468
  // The queue is only evidence about the panel the install was DISPATCHED to
3224
3469
  // (codex P1 — and the same guard #1468 needed, which I did not carry across).
3225
3470
  // `ctx.call` runs ensureReachable first, which silently rebinds an unpinned
@@ -3233,7 +3478,7 @@ async function settleDroppedEnqueue(ctx, res, dispatch) {
3233
3478
  // both are captured: the key AND the incarnation currently holding it.
3234
3479
  const queue = await ctx.call({ cmd: "nodes_queue_status" }, 15000);
3235
3480
  if (ctx.tabId !== dispatch.tab)
3236
- return res;
3481
+ return { result: res, mayCorroborateDisk: false };
3237
3482
  // Both captured BEFORE the install was dispatched, not here — a takeover that
3238
3483
  // happens DURING the install is already baked in by the time this function
3239
3484
  // runs, so comparing two post-install readings would always agree and the guard
@@ -3242,11 +3487,20 @@ async function settleDroppedEnqueue(ctx, res, dispatch) {
3242
3487
  // rule out a same-key takeover, so it does not get to make a claim about which
3243
3488
  // panel answered.
3244
3489
  if (dispatch.incarnation === undefined)
3245
- return res;
3246
- if (panelIncarnation(ctx, ctx.tabId) !== dispatch.incarnation)
3247
- return res;
3248
- if (queue.isError || !queueNeverSawATask(parseToolResultJson(queue)))
3249
- return res;
3490
+ return { result: res, mayCorroborateDisk: false };
3491
+ if (panelIncarnation(ctx, ctx.tabId) !== dispatch.incarnation) {
3492
+ return { result: res, mayCorroborateDisk: false };
3493
+ }
3494
+ if (queue.isError)
3495
+ return { result: res, mayCorroborateDisk: false };
3496
+ const queueReply = parseToolResultJson(queue);
3497
+ const status = queueStatusRecord(queueReply);
3498
+ if (!queueNeverSawATask(queueReply)) {
3499
+ return {
3500
+ result: res,
3501
+ mayCorroborateDisk: queueSettledForDiskCorroboration(status, queueReply),
3502
+ };
3503
+ }
3250
3504
  // NO PROVENANCE CLAIM AT ALL — deliberately, after five review rounds.
3251
3505
  //
3252
3506
  // Earlier drafts explained WHY the task was probably dropped ("the Manager
@@ -3260,19 +3514,22 @@ async function settleDroppedEnqueue(ctx, res, dispatch) {
3260
3514
  // saying, needs no provenance, and cannot be wrong. The cause belongs to
3261
3515
  // whoever can see the install — so the message asks for the ONE check that
3262
3516
  // settles it and stops there. Less useful in the common case; never false.
3263
- return appendNote(res, `WARNING — THE QUEUE DOES NOT HAVE THIS TASK. The Manager accepted the install above, but a ` +
3264
- `read taken immediately afterwards, on that same panel, reports an IDLE queue holding no ` +
3265
- `tasks at all (total_count, done, in_progress all 0). "queued" is its acknowledgement, not ` +
3266
- `a receipt.\n\n` +
3267
- `THIS IS NOT PROOF EITHER WAY. Those counters are also cleared by a queue RESET, which ` +
3268
- `other operations in this server issue, so an install that really ran can read exactly ` +
3269
- `like this if a reset landed in between.\n\n` +
3270
- `SO CHECK: call panel_list_nodes and see whether the pack is actually there. Do not restart ` +
3271
- `on the assumption it installed, and do not reinstall on the assumption it did not.\n\n` +
3272
- `IF IT IS ABSENT: install it through the headless install_custom_node, which verifies the ` +
3273
- `pack really landed instead of trusting the queue, and can clone a repository URL directly ` +
3274
- `when the Manager will not take it. This tool cannot clone for you it drives whatever ` +
3275
- `ComfyUI the panel is bound to, which need not be this machine.`);
3517
+ return {
3518
+ result: appendNote(res, `WARNING THE QUEUE DOES NOT HAVE THIS TASK. The Manager accepted the install above, but a ` +
3519
+ `read taken immediately afterwards, on that same panel, reports an IDLE queue holding no ` +
3520
+ `tasks at all (total_count, done, in_progress all 0). "queued" is its acknowledgement, not ` +
3521
+ `a receipt.\n\n` +
3522
+ `THIS IS NOT PROOF EITHER WAY. Those counters are also cleared by a queue RESET, which ` +
3523
+ `other operations in this server issue, so an install that really ran can read exactly ` +
3524
+ `like this if a reset landed in between.\n\n` +
3525
+ `SO CHECK: call panel_list_nodes and see whether the pack is actually there. Do not restart ` +
3526
+ `on the assumption it installed, and do not reinstall on the assumption it did not.\n\n` +
3527
+ `IF IT IS ABSENT: install it through the headless install_custom_node, which verifies the ` +
3528
+ `pack really landed instead of trusting the queue, and can clone a repository URL directly ` +
3529
+ `when the Manager will not take it. This tool cannot clone for you — it drives whatever ` +
3530
+ `ComfyUI the panel is bound to, which need not be this machine.`),
3531
+ mayCorroborateDisk: false,
3532
+ };
3276
3533
  }
3277
3534
  /**
3278
3535
  * #1699 — a drained Manager queue is not apply_manifest completion when
@@ -15660,18 +15917,24 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
15660
15917
  // #1129 — the panel identity is captured BEFORE dispatch, because a
15661
15918
  // takeover during the install is exactly what the follow-up read must not
15662
15919
  // be attributed to.
15920
+ // #2180 — the local disk corroboration below uses the same server-authorized
15921
+ // binding, captured before dispatch, so a reconnect cannot retarget it.
15922
+ const panelBaseBeforeDispatch = captureRebootHealthBase(ctx);
15663
15923
  const dispatch = {
15664
15924
  tab: ctx.tabId,
15665
15925
  incarnation: panelIncarnation(ctx, ctx.tabId),
15666
15926
  };
15927
+ // #2180 — this snapshot must happen BEFORE Manager dispatch. A post-only
15928
+ // scan cannot distinguish a newly landed registry zip from an old pack,
15929
+ // a concurrent writer, or a directory that merely shares the id.
15930
+ const registrySnapshot = await captureRegistryInstallSnapshot(cmdArgs, panelBaseBeforeDispatch);
15667
15931
  const res = await ctx.call({ cmd: "nodes_install", ...cmdArgs }, 30000);
15668
- // #1129 — settle BEFORE any supplemental note is appended. The note is glued on
15669
- // after the JSON body, which makes the payload unparseable as JSON, so a probe
15670
- // running afterwards reads `null`, concludes the panel never claimed a queue,
15671
- // and silently does nothing. Git notes describe v4 bare-name resolution, but
15672
- // v4 Git requests now fail before this point and legacy 3.x receives files:[url]
15673
- // directly; appending that v4 note to a legacy success would be false.
15674
- const settled = await settleDroppedEnqueue(ctx, res, dispatch);
15932
+ // #1129 / #2180 — settle EVERY queued/pending response before any disk
15933
+ // corroboration. An early rewrite would bypass the queue read and could
15934
+ // turn a still-running or failed request into installed:true.
15935
+ const settlement = await settleDroppedEnqueue(ctx, res, dispatch);
15936
+ const corroborated = await corroborateUntrackedRegistryInstall(ctx, settlement.result, registrySnapshot, dispatch, panelBaseBeforeDispatch, settlement.mayCorroborateDisk);
15937
+ const settled = corroborated;
15675
15938
  if (note && !cmdArgs.repository) {
15676
15939
  const text = settled.content.find((c) => c.type === "text");
15677
15940
  if (text && text.type === "text") {