diff --git a/CHANGELOG.md b/CHANGELOG.md index 750717a7665..4320256b4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 9218692699d..f5fdefb3a64 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -24,6 +24,7 @@ export type ResolveImplicitProvidersForModelsJson = (params: { pluginMetadataSnapshot?: Pick; providerDiscoveryProviderIds?: readonly string[]; providerDiscoveryTimeoutMs?: number; + providerDiscoveryEntriesOnly?: boolean; }) => Promise>; export type ModelsJsonPlan = @@ -47,6 +48,7 @@ export async function resolveProvidersForModelsJsonWithDeps( pluginMetadataSnapshot?: Pick; 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; 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, ); diff --git a/src/agents/models-config.providers.implicit.discovery-scope.test.ts b/src/agents/models-config.providers.implicit.discovery-scope.test.ts index 47c16ce0cde..b987675ec0c 100644 --- a/src/agents/models-config.providers.implicit.discovery-scope.test.ts +++ b/src/agents/models-config.providers.implicit.discovery-scope.test.ts @@ -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, + }), + ); + }); }); diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index 9ea7c5ee1a6..23050d34473 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -47,6 +47,7 @@ type ImplicitProviderParams = { pluginMetadataSnapshot?: Pick; providerDiscoveryProviderIds?: readonly string[]; providerDiscoveryTimeoutMs?: number; + providerDiscoveryEntriesOnly?: boolean; }; type ImplicitProviderContext = ImplicitProviderParams & { @@ -482,6 +483,7 @@ export async function resolveImplicitProviders( ...(params.pluginMetadataSnapshot ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } : {}), + ...(params.providerDiscoveryEntriesOnly === true ? { discoveryEntriesOnly: true } : {}), }); for (const order of PLUGIN_DISCOVERY_ORDERS) { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 141e4b48a40..ad347594c1d 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -49,6 +49,7 @@ async function buildModelsJsonFingerprint(params: { pluginMetadataSnapshot?: Pick; providerDiscoveryProviderIds?: readonly string[]; providerDiscoveryTimeoutMs?: number; + providerDiscoveryEntriesOnly?: boolean; }): Promise { 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,7 @@ 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 +224,9 @@ export async function ensureOpenClawModelsJson( ...(options.providerDiscoveryTimeoutMs !== undefined ? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs } : {}), + ...(options.providerDiscoveryEntriesOnly === true + ? { providerDiscoveryEntriesOnly: true } + : {}), }); if (plan.action === "skip") { @@ -251,6 +258,9 @@ export async function ensureOpenClawModelsJson( ...(options.providerDiscoveryTimeoutMs !== undefined ? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs } : {}), + ...(options.providerDiscoveryEntriesOnly === true + ? { providerDiscoveryEntriesOnly: true } + : {}), }); const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint); if (refreshedCacheKey !== cacheKey) { diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index eb59edf8af1..31dea54f722 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -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 }, diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 3b533438b1e..130ed15d054 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -106,6 +106,7 @@ async function waitForAcpRuntimeBackendReady(params: { async function prewarmConfiguredPrimaryModel(params: { cfg: OpenClawConfig; + workspaceDir?: string; log: { warn: (msg: string) => void }; }): Promise { 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, }, diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index 21b7dc2fa02..f33464e5d11 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -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(); diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index dca838250e5..1ea7cd5b7b3 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -103,6 +103,7 @@ function expectBundledCompatLoadPath(params: { config: params.enablementCompat, onlyPluginIds: ["openai"], activate: false, + onlyPluginIds: ["openai"], }); } @@ -403,6 +404,7 @@ describe("resolvePluginCapabilityProviders", () => { }), onlyPluginIds: ["microsoft"], activate: false, + onlyPluginIds: ["microsoft"], }); }); @@ -630,9 +632,52 @@ describe("resolvePluginCapabilityProviders", () => { config: compatConfig, onlyPluginIds: ["google"], activate: false, + onlyPluginIds: ["openai"], }); }); + 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("loads only the bundled owner plugin for a targeted provider lookup", () => { const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig; const allowlistCompat = { @@ -696,6 +741,7 @@ describe("resolvePluginCapabilityProviders", () => { config: enablementCompat, onlyPluginIds: ["google"], activate: false, + onlyPluginIds: ["google"], }); }); }); diff --git a/src/plugins/capability-provider-runtime.ts b/src/plugins/capability-provider-runtime.ts index 67b412fcebb..8d30641b35b 100644 --- a/src/plugins/capability-provider-runtime.ts +++ b/src/plugins/capability-provider-runtime.ts @@ -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 0 && params.key !== "memoryEmbeddingProviders" && - params.key !== "speechProviders" && - !hasExplicitPluginConfig(params.cfg?.plugins) + params.key !== "speechProviders" ) { return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey[]; } diff --git a/src/plugins/provider-discovery.runtime.test.ts b/src/plugins/provider-discovery.runtime.test.ts index e1ec0ad6f51..3a1114801ed 100644 --- a/src/plugins/provider-discovery.runtime.test.ts +++ b/src/plugins/provider-discovery.runtime.test.ts @@ -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(); + }); });