fix(plugins): keep provider discovery metadata-only

Fix startup and per-turn provider registry hot paths by keeping primary-model startup discovery on metadata-only provider entries and by keeping capability provider fallback loads scoped to manifest-derived owners, including explicit empty scopes when no bundled owner exists.

Evidence:
- Reproduces the reported code paths from #73729, #73835, and #73793: startup prewarm was able to enter provider/model discovery that loaded plugin runtime, and capability lookups could bypass active registry reuse or broaden fallback registry loads.
- Fix threads providerDiscoveryEntriesOnly through models-config planning into plugin discovery.
- Fix reuses active non-memory/non-speech capability providers even with explicit plugins.entries.
- Fix keeps fallback registry loads scoped with onlyPluginIds, including [] for no-owner media capability checks.
- Local targeted tests passed for gateway startup, models config, provider discovery, capability providers, and web provider runtimes.
- Testbox pnpm check:changed passed.
- Testbox pnpm build passed.
- GitHub CI required checks passed on e5e6fe1d52.

Fixes #73729.
Fixes #73835.
Fixes #73793.
Supersedes #73794.
This commit is contained in:
brokemac79
2026-04-29 07:52:32 +01:00
committed by GitHub
parent 13757465ba
commit 20c7a98fb8
12 changed files with 189 additions and 17 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- iMessage: normalize known leading attributedBody corruption markers on sent-message echo text keys so delayed reflected echoes with U+FFFD/U+FFFE/U+FFFF/FEFF prefixes are dropped without collapsing interior text. Fixes #59973; carries forward #59980 and #62191. Thanks @neeravmakwana and @maguilar631697.
- Security/audit: recognize dangerous node command IDs as valid `gateway.nodes.denyCommands` entries, so audit only warns on real typos or unsupported patterns. (#56923) Thanks @chziyue.
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
- Plugins/providers: keep Gateway startup primary-model discovery on metadata-only provider entries and reuse active non-speech capability providers even with explicit plugin entries, avoiding unnecessary provider registry loads during startup and media capability checks. Fixes #73729, #73835, and #73793; carries forward #73853 and #73794. Thanks @sg1416-zg, @brokemac79, and @poolside-ventures.
- Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash.
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.

View File

@@ -127,6 +127,7 @@ describe("models-config", () => {
it("threads startup provider discovery scope into implicit provider discovery", async () => {
let observedProviderIds: readonly string[] | undefined;
let observedEntriesOnly: boolean | undefined;
let observedTimeoutMs: number | undefined;
await resolveProvidersForModelsJsonWithDeps(
@@ -135,14 +136,17 @@ describe("models-config", () => {
agentDir: "/tmp/openclaw-models-config-env-vars-test",
env: {},
providerDiscoveryProviderIds: ["openai"],
providerDiscoveryEntriesOnly: true,
providerDiscoveryTimeoutMs: 5000,
},
{
resolveImplicitProviders: async ({
providerDiscoveryProviderIds,
providerDiscoveryEntriesOnly,
providerDiscoveryTimeoutMs,
}) => {
observedProviderIds = providerDiscoveryProviderIds;
observedEntriesOnly = providerDiscoveryEntriesOnly;
observedTimeoutMs = providerDiscoveryTimeoutMs;
return {};
},
@@ -150,6 +154,7 @@ describe("models-config", () => {
);
expect(observedProviderIds).toEqual(["openai"]);
expect(observedEntriesOnly).toBe(true);
expect(observedTimeoutMs).toBe(5000);
});

View File

@@ -24,6 +24,7 @@ export type ResolveImplicitProvidersForModelsJson = (params: {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
}) => Promise<Record<string, ProviderConfig>>;
export type ModelsJsonPlan =
@@ -47,6 +48,7 @@ export async function resolveProvidersForModelsJsonWithDeps(
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -70,6 +72,7 @@ export async function resolveProvidersForModelsJsonWithDeps(
...(params.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs }
: {}),
...(params.providerDiscoveryEntriesOnly === true ? { providerDiscoveryEntriesOnly: true } : {}),
});
return mergeProviders({
implicit: implicitProviders,
@@ -113,6 +116,7 @@ export async function planOpenClawModelsJsonWithDeps(
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -134,6 +138,9 @@ export async function planOpenClawModelsJsonWithDeps(
...(params.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs }
: {}),
...(params.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
},
deps,
);

View File

@@ -98,4 +98,20 @@ describe("resolveImplicitProviders startup discovery scope", () => {
}),
);
});
it("can keep startup discovery on provider discovery entries only", async () => {
await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryEntriesOnly: true,
});
expect(mocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
discoveryEntriesOnly: true,
}),
);
});
});

View File

@@ -47,6 +47,7 @@ type ImplicitProviderParams = {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
};
type ImplicitProviderContext = ImplicitProviderParams & {
@@ -483,6 +484,7 @@ export async function resolveImplicitProviders(
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
...(params.providerDiscoveryEntriesOnly === true ? { discoveryEntriesOnly: true } : {}),
});
for (const order of PLUGIN_DISCOVERY_ORDERS) {

View File

@@ -49,6 +49,7 @@ async function buildModelsJsonFingerprint(params: {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
}): Promise<string> {
const authProfilesMtimeMs = await readFileMtimeMs(
path.join(params.agentDir, "auth-profiles.json"),
@@ -68,6 +69,7 @@ async function buildModelsJsonFingerprint(params: {
pluginMetadataSnapshotIndexFingerprint,
providerDiscoveryProviderIds: params.providerDiscoveryProviderIds,
providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs,
providerDiscoveryEntriesOnly: params.providerDiscoveryEntriesOnly === true,
});
}
@@ -162,6 +164,7 @@ export async function ensureOpenClawModelsJson(
workspaceDir?: string;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
} = {},
): Promise<{ agentDir: string; wrote: boolean }> {
const resolved = resolveModelsConfigInput(config);
@@ -191,6 +194,9 @@ export async function ensureOpenClawModelsJson(
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint);
const cached = MODELS_JSON_STATE.readyCache.get(cacheKey);
@@ -220,6 +226,9 @@ export async function ensureOpenClawModelsJson(
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
if (plan.action === "skip") {
@@ -251,6 +260,9 @@ export async function ensureOpenClawModelsJson(
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint);
if (refreshedCacheKey !== cacheKey) {

View File

@@ -153,6 +153,11 @@ vi.mock("../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir: hoisted.resolveOpenClawAgentDir,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"),
resolveDefaultAgentId: vi.fn(() => "default"),
}));
vi.mock("../agents/defaults.js", () => ({
DEFAULT_MODEL: "gpt-5.4",
DEFAULT_PROVIDER: "openai",
@@ -357,6 +362,11 @@ describe("startGatewayPostAttachRuntime", () => {
await vi.waitFor(
() => {
expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1);
expect(prewarmPrimaryModel).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/openclaw-workspace",
}),
);
expect(startChannels).toHaveBeenCalledTimes(1);
},
{ timeout: 2_000 },

View File

@@ -106,6 +106,7 @@ async function waitForAcpRuntimeBackendReady(params: {
async function prewarmConfiguredPrimaryModel(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
log: { warn: (msg: string) => void };
}): Promise<void> {
const { resolveAgentModelPrimaryValue } = await import("../config/model-input.js");
@@ -125,11 +126,13 @@ async function prewarmConfiguredPrimaryModel(params: {
}
const [
{ resolveOpenClawAgentDir },
{ resolveAgentWorkspaceDir, resolveDefaultAgentId },
{ DEFAULT_MODEL, DEFAULT_PROVIDER },
{ isCliProvider, resolveConfiguredModelRef },
{ resolveEmbeddedAgentRuntime },
] = await Promise.all([
import("../agents/agent-paths.js"),
import("../agents/agent-scope.js"),
import("../agents/defaults.js"),
import("../agents/model-selection.js"),
import("../agents/pi-embedded-runner/runtime.js"),
@@ -149,10 +152,14 @@ async function prewarmConfiguredPrimaryModel(params: {
// Keep startup prewarm metadata-only; resolving models can import provider runtimes and block readiness.
const { ensureOpenClawModelsJson } = await import("../agents/models-config.js");
const agentDir = resolveOpenClawAgentDir();
const workspaceDir =
params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
try {
await ensureOpenClawModelsJson(params.cfg, agentDir, {
workspaceDir,
providerDiscoveryProviderIds: [provider],
providerDiscoveryTimeoutMs: STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS,
providerDiscoveryEntriesOnly: true,
});
} catch (err) {
params.log.warn(`startup model warmup failed for ${provider}/${model}: ${String(err)}`);
@@ -162,6 +169,7 @@ async function prewarmConfiguredPrimaryModel(params: {
async function prewarmConfiguredPrimaryModelWithTimeout(
params: {
cfg: OpenClawConfig;
workspaceDir?: string;
log: { warn: (msg: string) => void };
timeoutMs?: number;
},
@@ -190,6 +198,7 @@ async function prewarmConfiguredPrimaryModelWithTimeout(
function schedulePrimaryModelPrewarm(
params: {
cfg: OpenClawConfig;
workspaceDir?: string;
log: { warn: (msg: string) => void };
startupTrace?: GatewayStartupTrace;
},
@@ -202,6 +211,7 @@ function schedulePrimaryModelPrewarm(
prewarmConfiguredPrimaryModelWithTimeout(
{
cfg: params.cfg,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
log: params.log,
},
prewarm,
@@ -342,6 +352,7 @@ export async function startGatewaySidecars(params: {
schedulePrimaryModelPrewarm(
{
cfg: params.cfg,
workspaceDir: params.defaultWorkspaceDir,
log: params.log,
startupTrace: params.startupTrace,
},

View File

@@ -15,6 +15,11 @@ vi.mock("../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/agent",
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: () => "/tmp/workspace",
resolveDefaultAgentId: () => "default",
}));
vi.mock("../agents/models-config.js", () => ({
ensureOpenClawModelsJson: (config: unknown, agentDir: unknown, options?: unknown) =>
ensureOpenClawModelsJsonMock(config, agentDir, options),
@@ -68,8 +73,10 @@ describe("gateway startup primary model warmup", () => {
cfg,
"/tmp/agent",
expect.objectContaining({
workspaceDir: "/tmp/workspace",
providerDiscoveryProviderIds: ["openai-codex"],
providerDiscoveryTimeoutMs: 5000,
providerDiscoveryEntriesOnly: true,
}),
);
expect(piModelModuleLoadedMock).not.toHaveBeenCalled();
@@ -163,8 +170,10 @@ describe("gateway startup primary model warmup", () => {
cfg,
"/tmp/agent",
expect.objectContaining({
workspaceDir: "/tmp/workspace",
providerDiscoveryProviderIds: ["openai-codex"],
providerDiscoveryTimeoutMs: 5000,
providerDiscoveryEntriesOnly: true,
}),
);
expect(piModelModuleLoadedMock).not.toHaveBeenCalled();

View File

@@ -122,13 +122,17 @@ function createCompatChainConfig() {
return { cfg, allowlistCompat, enablementCompat };
}
function setBundledCapabilityFixture(contractKey: string) {
function setBundledCapabilityFixture(
contractKey: string,
pluginId = "openai",
providerId = pluginId,
) {
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
id: pluginId,
origin: "bundled",
contracts: { [contractKey]: ["openai"] },
contracts: { [contractKey]: [providerId] },
},
{
id: "custom-plugin",
@@ -230,7 +234,7 @@ describe("resolvePluginCapabilityProviders", () => {
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
});
it("uses active non-speech capability providers even when cfg is passed", () => {
it("uses active non-speech capability providers even when cfg has explicit plugin entries", () => {
const active = createEmptyPluginRegistry();
active.mediaUnderstandingProviders.push({
pluginId: "deepgram",
@@ -246,6 +250,7 @@ describe("resolvePluginCapabilityProviders", () => {
const providers = resolvePluginCapabilityProviders({
key: "mediaUnderstandingProviders",
cfg: {
plugins: { entries: { deepgram: { enabled: true } } },
tools: {
media: {
models: [{ provider: "deepgram" }],
@@ -603,16 +608,7 @@ describe("resolvePluginCapabilityProviders", () => {
nativeDocumentInputs: ["pdf"],
},
} as never);
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "google",
origin: "bundled",
contracts: { mediaUnderstandingProviders: ["google"] },
},
] as never,
diagnostics: [],
});
setBundledCapabilityFixture("mediaUnderstandingProviders", "google", "google");
mocks.withBundledPluginEnablementCompat.mockReturnValue(compatConfig);
mocks.withBundledPluginVitestCompat.mockReturnValue(compatConfig);
mocks.resolveRuntimePluginRegistry.mockImplementation((params?: unknown) =>
@@ -633,6 +629,100 @@ describe("resolvePluginCapabilityProviders", () => {
});
});
it.each([
"imageGenerationProviders",
"videoGenerationProviders",
"musicGenerationProviders",
] as const)("uses an explicit empty plugin scope for %s when no bundled owner exists", (key) => {
const providers = resolvePluginCapabilityProviders({
key,
cfg: {} as OpenClawConfig,
});
expectNoResolvedCapabilityProviders(providers as Array<{ id: string }>);
expect(mocks.loadPluginManifestRegistry).toHaveBeenCalledWith({
config: {},
env: process.env,
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith();
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
config: expect.anything(),
onlyPluginIds: [],
activate: false,
});
});
it("scopes media capability snapshot loads to manifest-derived bundled owners", () => {
const cfg = { plugins: { allow: ["openai", "minimax"] } } as OpenClawConfig;
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
contracts: {
imageGenerationProviders: ["openai"],
videoGenerationProviders: ["openai"],
},
},
{
id: "minimax",
origin: "bundled",
contracts: {
imageGenerationProviders: ["minimax"],
videoGenerationProviders: ["minimax"],
musicGenerationProviders: ["minimax"],
},
},
] as never,
diagnostics: [],
});
resolvePluginCapabilityProviders({ key: "imageGenerationProviders", cfg });
resolvePluginCapabilityProviders({ key: "videoGenerationProviders", cfg });
resolvePluginCapabilityProviders({ key: "musicGenerationProviders", cfg });
const snapshotLoadOptions = mocks.resolveRuntimePluginRegistry.mock.calls
.map(([options]) => options)
.filter((options): options is { activate: boolean; onlyPluginIds?: string[] } =>
Boolean(options && typeof options === "object" && "activate" in options),
);
expect(snapshotLoadOptions.map((options) => options.onlyPluginIds)).toEqual([
["minimax", "openai"],
["minimax", "openai"],
["minimax"],
]);
});
it("does not unscoped-load media generation capabilities without bundled owners", () => {
const cfg = { plugins: { allow: ["openai"] } } as OpenClawConfig;
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
contracts: {
imageGenerationProviders: ["openai"],
},
},
] as never,
diagnostics: [],
});
expectNoResolvedCapabilityProviders(
resolvePluginCapabilityProviders({ key: "imageGenerationProviders", cfg }),
);
expectNoResolvedCapabilityProviders(
resolvePluginCapabilityProviders({ key: "musicGenerationProviders", cfg }),
);
const snapshotLoadOptions = mocks.resolveRuntimePluginRegistry.mock.calls
.map(([options]) => options)
.filter((options): options is { activate: boolean; onlyPluginIds?: string[] } =>
Boolean(options && typeof options === "object" && "activate" in options),
);
expect(snapshotLoadOptions.map((options) => options.onlyPluginIds)).toEqual([["openai"], []]);
});
it("loads only the bundled owner plugin for a targeted provider lookup", () => {
const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig;
const allowlistCompat = {

View File

@@ -4,7 +4,6 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { hasExplicitPluginConfig } from "./config-policy.js";
import { resolveRuntimePluginRegistry } from "./loader.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import type { PluginRegistry } from "./registry-types.js";
@@ -242,8 +241,7 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
if (
activeProviders.length > 0 &&
params.key !== "memoryEmbeddingProviders" &&
params.key !== "speechProviders" &&
!hasExplicitPluginConfig(params.cfg?.plugins)
params.key !== "speechProviders"
) {
return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey<K>[];
}

View File

@@ -226,4 +226,15 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => {
]);
expect(mocks.resolvePluginProviders).not.toHaveBeenCalled();
});
it("does not fall back to full plugin loading when discovery entries are requested only", () => {
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
plugins: [createManifestPluginWithoutDiscovery({ id: "deepseek" })],
diagnostics: [],
});
expect(resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true })).toEqual([]);
expect(resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true })).toEqual([]);
expect(mocks.resolvePluginProviders).not.toHaveBeenCalled();
});
});