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>
This commit is contained in:
547895019
2026-04-25 04:23:13 +08:00
committed by GitHub
parent f896c3935e
commit 272313877d
8 changed files with 307 additions and 68 deletions

View File

@@ -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

View File

@@ -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.<capability>` is configured
- Skips each capability unless `plugins.entries.comfy.config.<capability>` is configured
- Useful after changing comfy workflow submission, polling, downloads, or plugin registration
## Image generation live

View File

@@ -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"
```
</Step>
<Step title="Prepare your workflow JSON">
@@ -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",
},
},
},
},

View File

@@ -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.

View File

@@ -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",
},
});
});
});

View File

@@ -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",
},
},
},

View File

@@ -20,6 +20,16 @@ type ComfyCloudJobResponseOptions = {
};
export function buildComfyConfig(config: Record<string, unknown>): OpenClawConfig {
return {
plugins: {
entries: {
comfy: { config },
},
},
} as unknown as OpenClawConfig;
}
export function buildLegacyComfyConfig(config: Record<string, unknown>): OpenClawConfig {
return {
models: {
providers: {

View File

@@ -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<ComfyWork
return source.workflow;
}
if (!source.workflowPath) {
throw new Error("models.providers.comfy.<capability>.workflow or workflowPath is required");
throw new Error(
"plugins.entries.comfy.config.<capability>.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.<capability>.inputImageNodeId to be configured",
"Comfy edit requests require plugins.entries.comfy.config.<capability>.inputImageNodeId to be configured",
);
}
const uploadedName = await uploadInputImage({