diff --git a/browser_tests/assets/missing/missing_models_gated.json b/browser_tests/assets/missing/missing_models_gated.json
new file mode 100644
index 00000000000..650e9380c45
--- /dev/null
+++ b/browser_tests/assets/missing/missing_models_gated.json
@@ -0,0 +1,54 @@
+{
+ "last_node_id": 1,
+ "last_link_id": 1,
+ "nodes": [
+ {
+ "id": 1,
+ "type": "CheckpointLoaderSimple",
+ "pos": [256, 256],
+ "size": [315, 98],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "MODEL",
+ "type": "MODEL",
+ "links": null
+ },
+ {
+ "name": "CLIP",
+ "type": "CLIP",
+ "links": null
+ },
+ {
+ "name": "VAE",
+ "type": "VAE",
+ "links": null
+ }
+ ],
+ "properties": {
+ "Node name for S&R": "CheckpointLoaderSimple",
+ "models": [
+ {
+ "name": "gated_model.safetensors",
+ "url": "https://huggingface.co/comfy-e2e/gated-test/resolve/main/gated_model.safetensors",
+ "directory": "checkpoints"
+ }
+ ]
+ },
+ "widgets_values": ["gated_model.safetensors"]
+ }
+ ],
+ "links": [],
+ "groups": [],
+ "config": {},
+ "extra": {
+ "ds": {
+ "offset": [0, 0],
+ "scale": 1
+ }
+ },
+ "version": 0.4
+}
diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts
index 541f4ffd978..66911126596 100644
--- a/browser_tests/fixtures/selectors.ts
+++ b/browser_tests/fixtures/selectors.ts
@@ -65,6 +65,8 @@ export const TestIds = {
missingModelReferenceCount: 'missing-model-reference-count',
missingModelUnsupportedSection:
'missing-model-import-not-supported-section',
+ missingModelGatedAccess: 'missing-model-gated-access',
+ missingModelGatedHint: 'missing-model-gated-hint',
missingModelDownload: 'missing-model-download',
missingModelActions: 'missing-model-actions',
missingModelDownloadAll: 'missing-model-download-all',
diff --git a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts
index c41fd6145ec..0f912ce297e 100644
--- a/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts
+++ b/browser_tests/tests/propertiesPanel/errorsTabMissingModels.spec.ts
@@ -13,6 +13,7 @@ import {
} from '@e2e/fixtures/helpers/ErrorsTabHelper'
const FAKE_MODEL_NAME = 'fake_model.safetensors'
+const GATED_MODEL_REPO_URL = 'https://huggingface.co/comfy-e2e/gated-test'
function getModelLabel(group: Locator, modelName: string = FAKE_MODEL_NAME) {
return group.getByRole('button', { name: modelName, exact: true })
@@ -160,5 +161,71 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
).toBeHidden()
})
+
+ test.describe('Gated Hugging Face model', { tag: '@ui' }, () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.page
+ .context()
+ .route('https://huggingface.co/**', async (route) => {
+ if (route.request().method() === 'HEAD') {
+ return route.fulfill({
+ status: 403,
+ headers: {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Expose-Headers': 'X-Error-Code',
+ 'X-Error-Code': 'GatedRepo'
+ }
+ })
+ }
+
+ return route.fulfill({
+ status: 200,
+ contentType: 'text/html',
+ body: '
stub repo page'
+ })
+ })
+ await loadWorkflowAndOpenErrorsTab(
+ comfyPage,
+ 'missing/missing_models_gated'
+ )
+ })
+
+ test('Should classify a 403 model as gated', async ({ comfyPage }) => {
+ await expect(
+ comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedAccess)
+ ).toBeVisible()
+ await expect(
+ comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedHint)
+ ).toBeVisible()
+ })
+
+ test('Should keep Download available for a gated model', async ({
+ comfyPage
+ }) => {
+ await expect(
+ comfyPage.page.getByTestId(TestIds.dialogs.missingModelGatedAccess)
+ ).toBeVisible()
+
+ const downloadButton = comfyPage.page.getByTestId(
+ TestIds.dialogs.missingModelDownload
+ )
+
+ await expect(downloadButton).toBeVisible()
+ await expect(downloadButton).toBeEnabled()
+ await expect(downloadButton).toHaveText('Download')
+ })
+
+ test('Should open the gated repository with the browser fallback', async ({
+ comfyPage
+ }) => {
+ const pagePromise = comfyPage.page.context().waitForEvent('page')
+ await comfyPage.page
+ .getByTestId(TestIds.dialogs.missingModelGatedAccess)
+ .click()
+ const accessPage = await pagePromise
+
+ await expect(accessPage).toHaveURL(GATED_MODEL_REPO_URL)
+ })
+ })
})
})
diff --git a/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts
index a7c51e63870..51254a1f552 100644
--- a/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts
+++ b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts
@@ -69,7 +69,12 @@ export interface ComfyDesktop2TelemetryBridge {
}
export interface ComfyDesktop2Bridge {
+ /** Reports whether the backend server is cloud/remote, not the user's location. */
isRemote(): boolean
+ /** Opens a model provider access page in the hosted frontend's browser session.
+ * Resolves `true` when the host has taken ownership of the request.
+ * On `false` or rejection the frontend falls back to opening a new tab. */
+ openModelAccessPage?: (url: string) => Promise
downloadModel?: (
url: string,
filename: string,
diff --git a/packages/comfyui-desktop-bridge-types/package.json b/packages/comfyui-desktop-bridge-types/package.json
index c3cdbe2a5db..d61214b04f6 100644
--- a/packages/comfyui-desktop-bridge-types/package.json
+++ b/packages/comfyui-desktop-bridge-types/package.json
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
- "version": "0.1.3",
+ "version": "0.1.4",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index e826f593dad..3d0918a7671 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -4005,7 +4005,11 @@
"importNotSupported": "Import Not Supported",
"copyModelName": "Copy model name",
"copyUrl": "Copy URL",
+ "gatedModelsHint": "Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.",
+ "gatedModelTooltip": "This model is gated and requires you to be logged in to Hugging Face and to accept its license agreement.",
+ "gatedModelDownloadTooltip": "Download may require signing in to Hugging Face first.",
"locateNode": "Locate node on canvas",
+ "openHuggingFaceRepo": "Open Hugging Face repo",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"unknownCategory": "Unknown",
diff --git a/src/platform/missingModel/components/MissingModelCard.test.ts b/src/platform/missingModel/components/MissingModelCard.test.ts
index e5eccda9834..65ab57db26e 100644
--- a/src/platform/missingModel/components/MissingModelCard.test.ts
+++ b/src/platform/missingModel/components/MissingModelCard.test.ts
@@ -3,6 +3,7 @@ import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -10,6 +11,20 @@ import type {
MissingModelGroup,
MissingModelViewModel
} from '@/platform/missingModel/types'
+import type * as MissingModelDownload from '@/platform/missingModel/missingModelDownload'
+import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
+
+const mockDownloadModel = vi.hoisted(() => vi.fn())
+
+vi.mock('@/platform/missingModel/missingModelDownload', async () => {
+ const actual = await vi.importActual(
+ '@/platform/missingModel/missingModelDownload'
+ )
+ return {
+ ...actual,
+ downloadModel: mockDownloadModel
+ }
+})
vi.mock('./MissingModelRow.vue', () => ({
default: {
@@ -131,6 +146,7 @@ function getRowsIn(testId: string) {
describe('MissingModelCard', () => {
beforeEach(() => {
+ vi.clearAllMocks()
mockIsCloud.value = true
})
@@ -241,6 +257,21 @@ describe('MissingModelCard', () => {
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
})
+
+ it('does not show gated model guidance in cloud', async () => {
+ const group = makeGroup({ withDownloadUrls: true })
+ const url =
+ 'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
+ mountCard({ missingModelGroups: [group] })
+
+ useMissingModelStore().gatedRepoUrls[url] =
+ 'https://huggingface.co/comfy/test'
+ await nextTick()
+
+ expect(
+ screen.queryByTestId('missing-model-gated-hint')
+ ).not.toBeInTheDocument()
+ })
})
describe('Event Handling', () => {
@@ -256,6 +287,7 @@ describe('MissingModelCard', () => {
describe('MissingModelCard (OSS)', () => {
beforeEach(() => {
+ vi.clearAllMocks()
mockIsCloud.value = false
})
@@ -293,6 +325,69 @@ describe('MissingModelCard (OSS)', () => {
).toBeVisible()
})
+ it('shows gated model guidance in OSS', async () => {
+ const group = makeGroup({ withDownloadUrls: true })
+ const url =
+ 'https://huggingface.co/comfy/test/resolve/main/model.safetensors'
+ mountCard({ missingModelGroups: [group] })
+
+ useMissingModelStore().gatedRepoUrls[url] =
+ 'https://huggingface.co/comfy/test'
+ await nextTick()
+
+ expect(screen.getByRole('note')).toHaveTextContent(
+ 'Some models are gated. To download them, sign in to Hugging Face and accept the model license agreement.'
+ )
+ })
+
+ it('does not show gated guidance for a model that is not downloadable', async () => {
+ const group = makeGroup({
+ modelNames: ['model.bin'],
+ withDownloadUrls: true
+ })
+ const url = 'https://huggingface.co/comfy/test/resolve/main/model.bin'
+ mountCard({ missingModelGroups: [group] })
+
+ useMissingModelStore().gatedRepoUrls[url] =
+ 'https://huggingface.co/comfy/test'
+ await nextTick()
+
+ expect(
+ screen.queryByTestId('missing-model-gated-hint')
+ ).not.toBeInTheDocument()
+ })
+
+ it('routes Download all through the shared missing-model download handler', async () => {
+ mountCard({
+ missingModelGroups: [
+ makeGroup({
+ withDownloadUrls: true,
+ modelNames: ['first.safetensors', 'second.safetensors']
+ })
+ ]
+ })
+
+ await userEvent.click(screen.getByTestId('missing-model-download-all'))
+
+ expect(mockDownloadModel).toHaveBeenCalledTimes(2)
+ expect(mockDownloadModel).toHaveBeenCalledWith(
+ {
+ name: 'first.safetensors',
+ url: 'https://huggingface.co/comfy/test/resolve/main/first.safetensors',
+ directory: 'checkpoints'
+ },
+ {}
+ )
+ expect(mockDownloadModel).toHaveBeenCalledWith(
+ {
+ name: 'second.safetensors',
+ url: 'https://huggingface.co/comfy/test/resolve/main/second.safetensors',
+ directory: 'checkpoints'
+ },
+ {}
+ )
+ })
+
it('hides Download all when no model is downloadable', () => {
mountCard()
diff --git a/src/platform/missingModel/components/MissingModelCard.vue b/src/platform/missingModel/components/MissingModelCard.vue
index 6e7ec5a1ffd..80c5bee2d1a 100644
--- a/src/platform/missingModel/components/MissingModelCard.vue
+++ b/src/platform/missingModel/components/MissingModelCard.vue
@@ -1,5 +1,20 @@
+
+
+
+ {{ t('rightSidePanel.missingModels.gatedModelsHint') }}
+
+
+
missingModelGroups
@@ -128,6 +144,12 @@ const downloadableModels = computed(() => {
return getDownloadableModels(missingModelGroups)
})
+const showGatedModelsHint = computed(() =>
+ downloadableModels.value.some(
+ (model) => !!missingModelStore.gatedRepoUrls[model.url]
+ )
+)
+
const downloadAllLabel = computed(() => {
const base = t('rightSidePanel.missingModels.downloadAll')
const total = downloadableModels.value.reduce(
@@ -139,7 +161,7 @@ const downloadAllLabel = computed(() => {
function downloadAllModels() {
for (const model of downloadableModels.value) {
- downloadModel(model, missingModelStore.folderPaths)
+ downloadMissingModel(model)
}
}
diff --git a/src/platform/missingModel/components/MissingModelRow.test.ts b/src/platform/missingModel/components/MissingModelRow.test.ts
index 441ec813a38..35c8f19b18f 100644
--- a/src/platform/missingModel/components/MissingModelRow.test.ts
+++ b/src/platform/missingModel/components/MissingModelRow.test.ts
@@ -1,7 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -19,6 +19,8 @@ const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockShowUploadDialog = vi.hoisted(() => vi.fn())
const mockCopyToClipboard = vi.hoisted(() => vi.fn())
const mockDownloadModel = vi.hoisted(() => vi.fn())
+const mockFetchModelMetadata = vi.hoisted(() => vi.fn())
+const mockOpenGatedRepoPage = vi.hoisted(() => vi.fn())
const mockRootGraph = vi.hoisted<{
value: Record | null
}>(() => ({ value: null }))
@@ -104,10 +106,8 @@ vi.mock('@/platform/missingModel/missingModelDownload', async () => {
return {
...actual,
downloadModel: mockDownloadModel,
- fetchModelMetadata: vi.fn().mockResolvedValue({
- fileSize: null,
- gatedRepoUrl: null
- })
+ fetchModelMetadata: mockFetchModelMetadata,
+ openGatedRepoPage: mockOpenGatedRepoPage
}
})
@@ -177,12 +177,21 @@ function renderRow(
describe('MissingModelRow', () => {
beforeEach(() => {
vi.clearAllMocks()
+ delete window.__comfyDesktop2
mockIsCloud.value = true
mockRootGraph.value = null
mockApiListeners.clear()
mockGetNodeByExecutionId.mockReset()
mockUploadContext.resolver = undefined
mockUploadCallbacks.onUploadSuccess = undefined
+ mockFetchModelMetadata.mockResolvedValue({
+ fileSize: null,
+ gatedRepoUrl: null
+ })
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
})
it('opens the model import dialog from the cloud row', async () => {
@@ -401,6 +410,73 @@ describe('MissingModelRow', () => {
)
})
+ it('shows a gated HuggingFace access action without replacing download', async () => {
+ mockIsCloud.value = false
+ const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
+ model.representative.url =
+ 'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors'
+ mockFetchModelMetadata.mockResolvedValueOnce({
+ fileSize: null,
+ gatedRepoUrl: 'https://huggingface.co/bfl/FLUX.1'
+ })
+
+ renderRow(model, vi.fn(), false)
+ const store = useMissingModelStore()
+
+ await waitFor(() => {
+ expect(store.gatedRepoUrls[model.representative.url!]).toBe(
+ 'https://huggingface.co/bfl/FLUX.1'
+ )
+ })
+
+ const gatedModelTooltip =
+ 'This model is gated and requires you to be logged in to Hugging Face and to accept its license agreement.'
+ const gatedModelDownloadTooltip =
+ 'Download may require signing in to Hugging Face first.'
+ expect(screen.getByTestId('missing-model-gated-access')).toHaveAttribute(
+ 'title',
+ gatedModelTooltip
+ )
+ expect(screen.getByTestId('missing-model-download')).toHaveAttribute(
+ 'title',
+ gatedModelDownloadTooltip
+ )
+ expect(
+ screen.getByTestId('missing-model-download')
+ ).toHaveAccessibleDescription(gatedModelDownloadTooltip)
+ })
+
+ it('opens gated repo action separately from the download action', async () => {
+ mockIsCloud.value = false
+ const user = userEvent.setup()
+ const model = makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }])
+ model.representative.url =
+ 'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors'
+
+ renderRow(model, vi.fn(), false)
+ const store = useMissingModelStore()
+ store.setGatedRepoUrl(
+ model.representative.url,
+ 'https://huggingface.co/bfl/FLUX.1'
+ )
+ await nextTick()
+
+ await user.click(screen.getByTestId('missing-model-gated-access'))
+ expect(mockOpenGatedRepoPage).toHaveBeenCalledWith(
+ 'https://huggingface.co/bfl/FLUX.1'
+ )
+
+ await user.click(screen.getByTestId('missing-model-download'))
+ expect(mockDownloadModel).toHaveBeenCalledWith(
+ {
+ name: 'model.safetensors',
+ url: 'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors',
+ directory: 'checkpoints'
+ },
+ {}
+ )
+ })
+
it('shows unknown category metadata for models without a directory', () => {
renderRow(
makeModel([{ nodeId: '1', widgetName: 'ckpt_name' }]),
diff --git a/src/platform/missingModel/components/MissingModelRow.vue b/src/platform/missingModel/components/MissingModelRow.vue
index f325d7dd2a7..3042d40d7d1 100644
--- a/src/platform/missingModel/components/MissingModelRow.vue
+++ b/src/platform/missingModel/components/MissingModelRow.vue
@@ -126,6 +126,18 @@
+
+
+ {{ gatedModelDownloadTooltip }}
+