From 272313877dc867f83008810302b3b6eba4bb1900 Mon Sep 17 00:00:00 2001 From: 547895019 <547895019@qq.com> Date: Sat, 25 Apr 2026 04:23:13 +0800 Subject: [PATCH] fix(comfy): read config from plugins.entries instead of models.providers (openclaw#63058) Verified: - pnpm test -- extensions/comfy/image-generation-provider.test.ts extensions/comfy/music-generation-provider.test.ts extensions/comfy/video-generation-provider.test.ts - rg -n "models\\.providers\\.comfy" docs extensions/comfy src -g '*.{ts,md,json}' - pnpm check -- --help - gh pr checks 63058 --repo openclaw/openclaw --watch --fail-fast Co-authored-by: 547895019 <7350824+547895019@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/help/testing-live.md | 2 +- docs/providers/comfy.md | 104 +++++++------- docs/tools/music-generation.md | 2 +- .../comfy/image-generation-provider.test.ts | 131 ++++++++++++++++++ .../comfy/music-generation-provider.test.ts | 18 +-- extensions/comfy/test-helpers.ts | 10 ++ extensions/comfy/workflow-runtime.ts | 107 ++++++++++++-- 8 files changed, 307 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd9070be3b..daaeece9dec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,6 +160,7 @@ Docs: https://docs.openclaw.ai - Memory/dreaming: decouple the managed dreaming cron from heartbeat by running it as an isolated lightweight agent turn, so dreaming runs even when heartbeat is disabled for the default agent and is no longer skipped by `heartbeat.activeHours`. `openclaw doctor --fix` migrates stale main-session dreaming jobs in persisted cron configs to the new shape. Fixes #69811, #67397, #68972. (#70737) Thanks @jalehman. - Agents/CLI: keep `--agent` plus `--session-id` lookup scoped to the requested agent store, so explicit agent resumes cannot select another agent's session. (#70985) Thanks @frankekn. - Gateway/MCP loopback: apply owner-only tool policy and run before-tool-call hooks on `127.0.0.1/mcp` `tools/list` and `tools/call`, so non-owner bearer callers can no longer see or invoke owner-only tools such as `cron`, `gateway`, and `nodes`, matching the existing HTTP `/tools/invoke` and embedded-agent paths. (#71159) Thanks @mmaps. +- Plugins/Comfy: read workflow and cloud auth configuration from `plugins.entries.comfy.config` while preserving legacy Comfy config fallback, so image, video, and music workflows pass config validation. Fixes #61915. (#63058) Thanks @547895019. ## 2026.4.22 diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index 4a08263d30d..a24120cb949 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -389,7 +389,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local - Enable: `OPENCLAW_LIVE_TEST=1 COMFY_LIVE_TEST=1 pnpm test:live -- extensions/comfy/comfy.live.test.ts` - Scope: - Exercises the bundled comfy image, video, and `music_generate` paths - - Skips each capability unless `models.providers.comfy.` is configured + - Skips each capability unless `plugins.entries.comfy.config.` is configured - Useful after changing comfy workflow submission, polling, downloads, or plugin registration ## Image generation live diff --git a/docs/providers/comfy.md b/docs/providers/comfy.md index b5d47fe7efc..de2907ad4af 100644 --- a/docs/providers/comfy.md +++ b/docs/providers/comfy.md @@ -46,15 +46,17 @@ Choose between running ComfyUI on your own machine or using Comfy Cloud. ```json5 { - models: { - providers: { + plugins: { + entries: { comfy: { - mode: "local", - baseUrl: "http://127.0.0.1:8188", - image: { - workflowPath: "./workflows/flux-api.json", - promptNodeId: "6", - outputNodeId: "9", + config: { + mode: "local", + baseUrl: "http://127.0.0.1:8188", + image: { + workflowPath: "./workflows/flux-api.json", + promptNodeId: "6", + outputNodeId: "9", + }, }, }, }, @@ -104,7 +106,7 @@ Choose between running ComfyUI on your own machine or using Comfy Cloud. export COMFY_CLOUD_API_KEY="your-key" # Or inline in config - openclaw config set models.providers.comfy.apiKey "your-key" + openclaw config set plugins.entries.comfy.config.apiKey "your-key" ``` @@ -115,14 +117,16 @@ Choose between running ComfyUI on your own machine or using Comfy Cloud. ```json5 { - models: { - providers: { + plugins: { + entries: { comfy: { - mode: "cloud", - image: { - workflowPath: "./workflows/flux-api.json", - promptNodeId: "6", - outputNodeId: "9", + config: { + mode: "cloud", + image: { + workflowPath: "./workflows/flux-api.json", + promptNodeId: "6", + outputNodeId: "9", + }, }, }, }, @@ -163,25 +167,27 @@ Comfy supports shared top-level connection settings plus per-capability workflow ```json5 { - models: { - providers: { + plugins: { + entries: { comfy: { - mode: "local", - baseUrl: "http://127.0.0.1:8188", - image: { - workflowPath: "./workflows/flux-api.json", - promptNodeId: "6", - outputNodeId: "9", - }, - video: { - workflowPath: "./workflows/video-api.json", - promptNodeId: "12", - outputNodeId: "21", - }, - music: { - workflowPath: "./workflows/music-api.json", - promptNodeId: "3", - outputNodeId: "18", + config: { + mode: "local", + baseUrl: "http://127.0.0.1:8188", + image: { + workflowPath: "./workflows/flux-api.json", + promptNodeId: "6", + outputNodeId: "9", + }, + video: { + workflowPath: "./workflows/video-api.json", + promptNodeId: "12", + outputNodeId: "21", + }, + music: { + workflowPath: "./workflows/music-api.json", + promptNodeId: "3", + outputNodeId: "18", + }, }, }, }, @@ -242,15 +248,17 @@ The `image` and `video` sections also support: ```json5 { - models: { - providers: { + plugins: { + entries: { comfy: { - image: { - workflowPath: "./workflows/edit-api.json", - promptNodeId: "6", - inputImageNodeId: "7", - inputImageInputName: "image", - outputNodeId: "9", + config: { + image: { + workflowPath: "./workflows/edit-api.json", + promptNodeId: "6", + inputImageNodeId: "7", + inputImageInputName: "image", + outputNodeId: "9", + }, }, }, }, @@ -299,12 +307,14 @@ The `image` and `video` sections also support: ```json5 { - models: { - providers: { + plugins: { + entries: { comfy: { - workflowPath: "./workflows/flux-api.json", - promptNodeId: "6", - outputNodeId: "9", + config: { + workflowPath: "./workflows/flux-api.json", + promptNodeId: "6", + outputNodeId: "9", + }, }, }, }, diff --git a/docs/tools/music-generation.md b/docs/tools/music-generation.md index 5636a06e6aa..54f860cda15 100644 --- a/docs/tools/music-generation.md +++ b/docs/tools/music-generation.md @@ -64,7 +64,7 @@ Generate an energetic chiptune loop about launching a rocket at sunrise. The bundled `comfy` plugin plugs into the shared `music_generate` tool through the music-generation provider registry. -1. Configure `models.providers.comfy.music` with a workflow JSON and +1. Configure `plugins.entries.comfy.config.music` with a workflow JSON and prompt/output nodes. 2. If you use Comfy Cloud, set `COMFY_API_KEY` or `COMFY_CLOUD_API_KEY`. 3. Ask the agent for music or call the tool directly. diff --git a/extensions/comfy/image-generation-provider.test.ts b/extensions/comfy/image-generation-provider.test.ts index f1fc4b9a8d3..bec51fdd3a2 100644 --- a/extensions/comfy/image-generation-provider.test.ts +++ b/extensions/comfy/image-generation-provider.test.ts @@ -5,6 +5,7 @@ import { } from "./image-generation-provider.js"; import { buildComfyConfig, + buildLegacyComfyConfig, mockComfyCloudJobResponses, mockComfyProviderApiKey, parseComfyJsonBody, @@ -25,6 +26,7 @@ describe("comfy image-generation provider", () => { afterEach(() => { _setComfyFetchGuardForTesting(null); + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -42,6 +44,57 @@ describe("comfy image-generation provider", () => { ).toBe(true); }); + it("falls back to legacy models.providers comfy config when plugin config is absent", () => { + const provider = buildComfyImageGenerationProvider(); + expect( + provider.isConfigured?.({ + cfg: buildLegacyComfyConfig({ + workflow: { + "6": { inputs: { text: "" } }, + }, + promptNodeId: "6", + }), + }), + ).toBe(true); + }); + + it("treats cloud comfy workflows as configured with a plugin config API key", () => { + const provider = buildComfyImageGenerationProvider(); + expect( + provider.isConfigured?.({ + cfg: buildComfyConfig({ + mode: "cloud", + apiKey: "comfy-test-key", + image: { + workflow: { + "6": { inputs: { text: "" } }, + }, + promptNodeId: "6", + }, + }), + }), + ).toBe(true); + }); + + it("treats cloud comfy workflows as configured with a plugin config env SecretRef", () => { + vi.stubEnv("COMFY_TEST_API_KEY", "comfy-secret-ref-key"); + const provider = buildComfyImageGenerationProvider(); + expect( + provider.isConfigured?.({ + cfg: buildComfyConfig({ + mode: "cloud", + apiKey: { source: "env", provider: "default", id: "COMFY_TEST_API_KEY" }, + image: { + workflow: { + "6": { inputs: { text: "" } }, + }, + promptNodeId: "6", + }, + }), + }), + ).toBe(true); + }); + it("submits a local workflow, waits for history, and downloads images", async () => { _setComfyFetchGuardForTesting(fetchWithSsrFGuardMock); fetchWithSsrFGuardMock @@ -301,4 +354,82 @@ describe("comfy image-generation provider", () => { outputNodeIds: ["9"], }); }); + + it("uses plugin config env SecretRef auth for cloud workflows", async () => { + vi.stubEnv("COMFY_TEST_API_KEY", "comfy-secret-ref-key"); + _setComfyFetchGuardForTesting(fetchWithSsrFGuardMock); + mockComfyCloudJobResponses(fetchWithSsrFGuardMock, { + body: Buffer.from("cloud-data"), + contentType: "image/png", + filename: "cloud.png", + outputKind: "images", + promptId: "cloud-secret-ref-1", + redirectLocation: "https://cdn.example.com/cloud.png", + }); + + const provider = buildComfyImageGenerationProvider(); + await provider.generateImage({ + provider: "comfy", + model: "workflow", + prompt: "cloud workflow prompt", + cfg: buildComfyConfig({ + mode: "cloud", + apiKey: { source: "env", provider: "default", id: "COMFY_TEST_API_KEY" }, + workflow: { + "6": { inputs: { text: "" } }, + "9": { inputs: {} }, + }, + promptNodeId: "6", + outputNodeId: "9", + }), + }); + + const submitRequest = fetchWithSsrFGuardMock.mock.calls[0]?.[0]; + const submitHeaders = new Headers(submitRequest?.init?.headers); + expect(submitHeaders.get("x-api-key")).toBe("comfy-secret-ref-key"); + expect(parseJsonBody(1)).toMatchObject({ + extra_data: { + api_key_comfy_org: "comfy-secret-ref-key", + }, + }); + }); + + it("uses provider auth fallback for cloud workflows without plugin config API keys", async () => { + vi.stubEnv("COMFY_API_KEY", "stale-env-key"); + mockComfyProviderApiKey("profile-key"); + _setComfyFetchGuardForTesting(fetchWithSsrFGuardMock); + mockComfyCloudJobResponses(fetchWithSsrFGuardMock, { + body: Buffer.from("cloud-data"), + contentType: "image/png", + filename: "cloud.png", + outputKind: "images", + promptId: "cloud-profile-1", + redirectLocation: "https://cdn.example.com/cloud.png", + }); + + const provider = buildComfyImageGenerationProvider(); + await provider.generateImage({ + provider: "comfy", + model: "workflow", + prompt: "cloud workflow prompt", + cfg: buildComfyConfig({ + mode: "cloud", + workflow: { + "6": { inputs: { text: "" } }, + "9": { inputs: {} }, + }, + promptNodeId: "6", + outputNodeId: "9", + }), + }); + + const submitRequest = fetchWithSsrFGuardMock.mock.calls[0]?.[0]; + const submitHeaders = new Headers(submitRequest?.init?.headers); + expect(submitHeaders.get("x-api-key")).toBe("profile-key"); + expect(parseJsonBody(1)).toMatchObject({ + extra_data: { + api_key_comfy_org: "profile-key", + }, + }); + }); }); diff --git a/extensions/comfy/music-generation-provider.test.ts b/extensions/comfy/music-generation-provider.test.ts index 04d2e575d01..b4da7b5aab2 100644 --- a/extensions/comfy/music-generation-provider.test.ts +++ b/extensions/comfy/music-generation-provider.test.ts @@ -58,16 +58,18 @@ describe("comfy music-generation provider", () => { model: "workflow", prompt: "gentle ambient synth loop", cfg: { - models: { - providers: { + plugins: { + entries: { comfy: { - music: { - workflow: { - "6": { inputs: { text: "" } }, - "9": { inputs: {} }, + config: { + music: { + workflow: { + "6": { inputs: { text: "" } }, + "9": { inputs: {} }, + }, + promptNodeId: "6", + outputNodeId: "9", }, - promptNodeId: "6", - outputNodeId: "9", }, }, }, diff --git a/extensions/comfy/test-helpers.ts b/extensions/comfy/test-helpers.ts index 284ec47c0ef..8d3f8885e53 100644 --- a/extensions/comfy/test-helpers.ts +++ b/extensions/comfy/test-helpers.ts @@ -20,6 +20,16 @@ type ComfyCloudJobResponseOptions = { }; export function buildComfyConfig(config: Record): OpenClawConfig { + return { + plugins: { + entries: { + comfy: { config }, + }, + }, + } as unknown as OpenClawConfig; +} + +export function buildLegacyComfyConfig(config: Record): OpenClawConfig { return { models: { providers: { diff --git a/extensions/comfy/workflow-runtime.ts b/extensions/comfy/workflow-runtime.ts index 7434d03a946..da4bd0fa00f 100644 --- a/extensions/comfy/workflow-runtime.ts +++ b/extensions/comfy/workflow-runtime.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { canResolveEnvSecretRefInReadOnlyPath } from "openclaw/plugin-sdk/extension-shared"; import { isProviderApiKeyConfigured, type AuthProfileStore, @@ -10,6 +11,10 @@ import { normalizeBaseUrl, resolveProviderHttpRequestConfig, } from "openclaw/plugin-sdk/provider-http"; +import { + normalizeSecretInputString, + resolveSecretInputString, +} from "openclaw/plugin-sdk/secret-input-runtime"; import { buildHostnameAllowlistPolicyFromSuffixAllowlist, fetchWithSsrFGuard, @@ -66,6 +71,18 @@ type ComfyStatusResponse = { type ComfyNetworkPolicy = { apiPolicy?: SsrFPolicy; }; +type ComfyApiKeyResolution = + | { + status: "available"; + apiKey: string; + source: string; + } + | { + status: "missing"; + } + | { + status: "configured_unavailable"; + }; export type ComfySourceImage = { buffer: Buffer; @@ -104,8 +121,12 @@ function readConfigInteger(config: ComfyProviderConfig, key: string): number | u } export function getComfyConfig(cfg?: OpenClawConfig): ComfyProviderConfig { - const raw = cfg?.models?.providers?.comfy; - return isRecord(raw) ? raw : {}; + const pluginConfig = cfg?.plugins?.entries?.comfy?.config; + if (isRecord(pluginConfig)) { + return pluginConfig; + } + const legacyConfig = cfg?.models?.providers?.comfy; + return isRecord(legacyConfig) ? legacyConfig : {}; } function stripNestedCapabilityConfig(config: ComfyProviderConfig): ComfyProviderConfig { @@ -132,10 +153,56 @@ export function resolveComfyMode(config: ComfyProviderConfig): ComfyMode { return normalizeOptionalString(config.mode) === "cloud" ? "cloud" : "local"; } +function resolveComfyApiKey( + config: ComfyProviderConfig, + cfg?: OpenClawConfig, +): ComfyApiKeyResolution { + const resolved = resolveSecretInputString({ + value: config.apiKey, + path: "plugins.entries.comfy.config.apiKey", + defaults: cfg?.secrets?.defaults, + mode: "inspect", + }); + if (resolved.status === "available") { + const apiKey = normalizeSecretInputString(resolved.value); + return apiKey + ? { + status: "available", + apiKey, + source: "plugins.entries.comfy.config.apiKey", + } + : { status: "missing" }; + } + if (resolved.status === "configured_unavailable") { + if (resolved.ref.source !== "env") { + return { status: "configured_unavailable" }; + } + const envVarName = resolved.ref.id.trim(); + if ( + !canResolveEnvSecretRefInReadOnlyPath({ + cfg, + provider: resolved.ref.provider, + id: envVarName, + }) + ) { + return { status: "configured_unavailable" }; + } + const apiKey = normalizeSecretInputString(process.env[envVarName]); + return apiKey + ? { + status: "available", + apiKey, + source: `plugins.entries.comfy.config.apiKey (${envVarName})`, + } + : { status: "configured_unavailable" }; + } + return { status: "missing" }; +} + function getRequiredConfigString(config: ComfyProviderConfig, key: string): string { const value = normalizeOptionalString(config[key]); if (!value) { - throw new Error(`models.providers.comfy.${key} is required`); + throw new Error(`plugins.entries.comfy.config.${key} is required`); } return value; } @@ -158,7 +225,9 @@ async function loadComfyWorkflow(config: ComfyProviderConfig): Promise.workflow or workflowPath is required"); + throw new Error( + "plugins.entries.comfy.config..workflow or workflowPath is required", + ); } const resolvedPath = resolveUserPath(source.workflowPath); @@ -536,6 +605,13 @@ export function isComfyCapabilityConfigured(params: { if (resolveComfyMode(capabilityConfig) === "local") { return true; } + const configuredApiKey = resolveComfyApiKey(capabilityConfig, params.cfg); + if (configuredApiKey.status === "available") { + return true; + } + if (configuredApiKey.status === "configured_unavailable") { + return false; + } return isProviderApiKeyConfigured({ provider: "comfy", agentDir: params.agentDir, @@ -577,14 +653,23 @@ export async function runComfyWorkflow(params: { value: params.prompt, }); + const pluginApiKey = resolveComfyApiKey(capabilityConfig, params.cfg); const resolvedAuth = mode === "cloud" - ? await resolveApiKeyForProvider({ - provider: "comfy", - cfg: params.cfg, - agentDir: params.agentDir, - store: params.authStore, - }) + ? pluginApiKey.status === "available" + ? { + apiKey: pluginApiKey.apiKey, + source: pluginApiKey.source, + mode: "api-key" as const, + } + : pluginApiKey.status === "configured_unavailable" + ? null + : await resolveApiKeyForProvider({ + provider: "comfy", + cfg: params.cfg, + agentDir: params.agentDir, + store: params.authStore, + }) : null; if (mode === "cloud" && !resolvedAuth?.apiKey) { throw new Error("Comfy Cloud API key missing"); @@ -621,7 +706,7 @@ export async function runComfyWorkflow(params: { if (params.inputImage) { if (!inputImageNodeId) { throw new Error( - "Comfy edit requests require models.providers.comfy..inputImageNodeId to be configured", + "Comfy edit requests require plugins.entries.comfy.config..inputImageNodeId to be configured", ); } const uploadedName = await uploadInputImage({