diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 30bbf939aac..290f6f62f87 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -146,6 +146,7 @@ describe("runConfigureWizard", () => { ]); mocks.applySearchKey.mockReset(); mocks.applySearchProviderSelection.mockReset(); + mocks.applySearchProviderSelection.mockImplementation((cfg: OpenClawConfig) => cfg); }); it("persists gateway.mode=local when only the run mode is selected", async () => { @@ -377,56 +378,66 @@ describe("runConfigureWizard", () => { }); it("uses provider-specific credential copy for Gemini web search", async () => { - mocks.readConfigFileSnapshot.mockResolvedValue({ - exists: false, - valid: true, - config: {}, - issues: [], - }); - mocks.resolveGatewayPort.mockReturnValue(18789); - mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); - mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); - mocks.summarizeExistingConfig.mockReturnValue(""); - mocks.createClackPrompter.mockReturnValue({}); - mocks.resolveSearchProviderOptions.mockReturnValue([ - { - id: "gemini", - label: "Gemini (Google Search)", - hint: "Requires Google Gemini API key · Google Search grounding", - credentialLabel: "Google Gemini API key", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - credentialPath: "plugins.entries.google.config.webSearch.apiKey", - }, - ]); + const originalGeminiApiKey = process.env.GEMINI_API_KEY; + delete process.env.GEMINI_API_KEY; + try { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.resolveGatewayPort.mockReturnValue(18789); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.resolveSearchProviderOptions.mockReturnValue([ + { + id: "gemini", + label: "Gemini (Google Search)", + hint: "Requires Google Gemini API key · Google Search grounding", + credentialLabel: "Google Gemini API key", + envVars: ["GEMINI_API_KEY"], + placeholder: "AIza...", + signupUrl: "https://aistudio.google.com/apikey", + credentialPath: "plugins.entries.google.config.webSearch.apiKey", + }, + ]); - const selectQueue = ["local", "gemini"]; - const confirmQueue = [true, false]; - mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); - mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); - mocks.clackText.mockResolvedValue(""); - mocks.clackIntro.mockResolvedValue(undefined); - mocks.clackOutro.mockResolvedValue(undefined); + const selectQueue = ["local", "gemini"]; + const confirmQueue = [true, false]; + mocks.clackSelect.mockImplementation(async () => selectQueue.shift()); + mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift()); + mocks.clackText.mockResolvedValue(""); + mocks.clackIntro.mockResolvedValue(undefined); + mocks.clackOutro.mockResolvedValue(undefined); - await runConfigureWizard( - { command: "configure", sections: ["web"] }, - { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }, - ); + await runConfigureWizard( + { command: "configure", sections: ["web"] }, + { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + ); - expect(mocks.clackText).toHaveBeenCalledWith( - expect.objectContaining({ - message: "Google Gemini API key", - }), - ); - expect(mocks.note).toHaveBeenCalledWith( - expect.stringContaining("Store your Google Gemini API key here or set GEMINI_API_KEY"), - "Web search", - ); + expect(mocks.clackText).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Google Gemini API key"), + }), + ); + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Store your Google Gemini API key here or set GEMINI_API_KEY"), + "Web search", + ); + } finally { + if (originalGeminiApiKey === undefined) { + delete process.env.GEMINI_API_KEY; + } else { + process.env.GEMINI_API_KEY = originalGeminiApiKey; + } + } }); it("does not crash when web search providers are unavailable under plugin policy", async () => { diff --git a/src/plugins/provider-wizard.test.ts b/src/plugins/provider-wizard.test.ts index c4d4fa55811..d641f43b37a 100644 --- a/src/plugins/provider-wizard.test.ts +++ b/src/plugins/provider-wizard.test.ts @@ -23,6 +23,7 @@ function makeProvider(overrides: Partial & Pick { beforeEach(() => { vi.clearAllMocks(); + vi.useRealTimers(); }); it("uses explicit setup choice ids and bound method ids", () => { @@ -202,6 +203,249 @@ describe("provider wizard boundaries", () => { ]); }); + it("reuses provider resolution across wizard consumers for the same config and env", () => { + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], + wizard: { + setup: { + choiceLabel: "SGLang setup", + groupId: "sglang", + groupLabel: "SGLang", + }, + modelPicker: { + label: "SGLang server", + methodId: "server", + }, + }, + }); + const config = {}; + const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; + resolvePluginProviders.mockReturnValue([provider]); + + expect( + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }), + ).toHaveLength(1); + expect( + resolveProviderModelPickerEntries({ + config, + workspaceDir: "/tmp/workspace", + env, + }), + ).toHaveLength(1); + + expect(resolvePluginProviders).toHaveBeenCalledTimes(1); + expect(resolvePluginProviders).toHaveBeenCalledWith({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + }); + + it("invalidates the wizard cache when config or env contents change in place", () => { + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], + wizard: { + setup: { + choiceLabel: "SGLang setup", + groupId: "sglang", + groupLabel: "SGLang", + }, + }, + }); + const config = { + plugins: { + allow: ["sglang"], + }, + }; + const env = { OPENCLAW_HOME: "/tmp/openclaw-home-a" } as NodeJS.ProcessEnv; + resolvePluginProviders.mockReturnValue([provider]); + + expect( + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }), + ).toHaveLength(1); + + config.plugins.allow = ["vllm"]; + env.OPENCLAW_HOME = "/tmp/openclaw-home-b"; + + expect( + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }), + ).toHaveLength(1); + + expect(resolvePluginProviders).toHaveBeenCalledTimes(2); + }); + + it("skips provider-wizard memoization when plugin cache opt-outs are set", () => { + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], + wizard: { + setup: { + choiceLabel: "SGLang setup", + groupId: "sglang", + groupLabel: "SGLang", + }, + }, + }); + const config = { + plugins: { + allow: ["sglang"], + }, + }; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + } as NodeJS.ProcessEnv; + resolvePluginProviders.mockReturnValue([provider]); + + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + + expect(resolvePluginProviders).toHaveBeenCalledTimes(2); + }); + + it("skips provider-wizard memoization when discovery cache ttl is zero", () => { + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], + wizard: { + setup: { + choiceLabel: "SGLang setup", + groupId: "sglang", + groupLabel: "SGLang", + }, + }, + }); + const config = { + plugins: { + allow: ["sglang"], + }, + }; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", + } as NodeJS.ProcessEnv; + resolvePluginProviders.mockReturnValue([provider]); + + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + + expect(resolvePluginProviders).toHaveBeenCalledTimes(2); + }); + + it("expires provider-wizard memoization after the shortest plugin cache ttl", () => { + vi.useFakeTimers(); + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], + wizard: { + setup: { + choiceLabel: "SGLang setup", + groupId: "sglang", + groupLabel: "SGLang", + }, + }, + }); + const config = {}; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5", + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20", + } as NodeJS.ProcessEnv; + resolvePluginProviders.mockReturnValue([provider]); + + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + vi.advanceTimersByTime(4); + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + vi.advanceTimersByTime(2); + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + + expect(resolvePluginProviders).toHaveBeenCalledTimes(2); + }); + + it("invalidates provider-wizard snapshots when cache-control env values change in place", () => { + const provider = makeProvider({ + id: "sglang", + label: "SGLang", + auth: [{ id: "server", label: "Server", kind: "custom", run: vi.fn() }], + wizard: { + setup: { + choiceLabel: "SGLang setup", + groupId: "sglang", + groupLabel: "SGLang", + }, + }, + }); + const config = {}; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000", + } as NodeJS.ProcessEnv; + resolvePluginProviders.mockReturnValue([provider]); + + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + + env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5"; + + resolveProviderWizardOptions({ + config, + workspaceDir: "/tmp/workspace", + env, + }); + + expect(resolvePluginProviders).toHaveBeenCalledTimes(2); + }); + it("routes model-selected hooks only to the matching provider", async () => { const matchingHook = vi.fn(async () => {}); const otherHook = vi.fn(async () => {}); diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 05bb364e9a8..e52db53491e 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -12,6 +12,89 @@ import type { } from "./types.js"; export const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; +type ProviderWizardCacheEntry = { + expiresAt: number; + providers: ProviderPlugin[]; +}; +const providerWizardCache = new WeakMap< + OpenClawConfig, + WeakMap> +>(); + +const DEFAULT_DISCOVERY_CACHE_MS = 1000; +const DEFAULT_MANIFEST_CACHE_MS = 1000; + +function shouldUseProviderWizardCache(env: NodeJS.ProcessEnv): boolean { + if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { + return false; + } + if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { + return false; + } + const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); + if (discoveryCacheMs === "0") { + return false; + } + const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); + if (manifestCacheMs === "0") { + return false; + } + return true; +} + +function resolveProviderWizardCacheTtlMs(env: NodeJS.ProcessEnv): number { + const discoveryCacheMs = resolveCacheMs( + env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, + DEFAULT_DISCOVERY_CACHE_MS, + ); + const manifestCacheMs = resolveCacheMs( + env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, + DEFAULT_MANIFEST_CACHE_MS, + ); + return Math.min(discoveryCacheMs, manifestCacheMs); +} + +function resolveCacheMs(rawValue: string | undefined, defaultMs: number): number { + const raw = rawValue?.trim(); + if (raw === "" || raw === "0") { + return 0; + } + if (!raw) { + return defaultMs; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) { + return defaultMs; + } + return Math.max(0, parsed); +} + +function buildProviderWizardCacheKey(params: { + config: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; +}): string { + return JSON.stringify({ + workspaceDir: params.workspaceDir ?? "", + config: params.config, + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: params.env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: + params.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: + params.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: params.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: params.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", + OPENCLAW_HOME: params.env.OPENCLAW_HOME ?? "", + OPENCLAW_STATE_DIR: params.env.OPENCLAW_STATE_DIR ?? "", + CLAWDBOT_STATE_DIR: params.env.CLAWDBOT_STATE_DIR ?? "", + OPENCLAW_CONFIG_PATH: params.env.OPENCLAW_CONFIG_PATH ?? "", + HOME: params.env.HOME ?? "", + USERPROFILE: params.env.USERPROFILE ?? "", + VITEST: params.env.VITEST ?? "", + }, + }); +} export type ProviderWizardOption = { value: string; @@ -97,12 +180,62 @@ export function buildProviderPluginMethodChoice(providerId: string, methodId: st return `${PROVIDER_PLUGIN_CHOICE_PREFIX}${providerId.trim()}:${methodId.trim()}`; } +function resolveProviderWizardProviders(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderPlugin[] { + if (!params.config) { + return resolvePluginProviders(params); + } + const env = params.env ?? process.env; + if (!shouldUseProviderWizardCache(env)) { + return resolvePluginProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + } + const cacheKey = buildProviderWizardCacheKey({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + const configCache = providerWizardCache.get(params.config); + const envCache = configCache?.get(env); + const cached = envCache?.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.providers; + } + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + }); + const ttlMs = resolveProviderWizardCacheTtlMs(env); + let nextConfigCache = configCache; + if (!nextConfigCache) { + nextConfigCache = new WeakMap>(); + providerWizardCache.set(params.config, nextConfigCache); + } + let nextEnvCache = nextConfigCache.get(env); + if (!nextEnvCache) { + nextEnvCache = new Map(); + nextConfigCache.set(env, nextEnvCache); + } + nextEnvCache.set(cacheKey, { + expiresAt: Date.now() + ttlMs, + providers, + }); + return providers; +} + export function resolveProviderWizardOptions(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderWizardOption[] { - const providers = resolvePluginProviders(params); + const providers = resolveProviderWizardProviders(params); const options: ProviderWizardOption[] = []; for (const provider of providers) { @@ -171,7 +304,7 @@ export function resolveProviderModelPickerEntries(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): ProviderModelPickerEntry[] { - const providers = resolvePluginProviders(params); + const providers = resolveProviderWizardProviders(params); const entries: ProviderModelPickerEntry[] = []; for (const provider of providers) { @@ -259,7 +392,7 @@ export async function runProviderModelSelectedHook(params: { return; } - const providers = resolvePluginProviders({ + const providers = resolveProviderWizardProviders({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index 7c69aa7ca41..e45ce590fb5 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -132,12 +132,13 @@ export function resolvePluginProviders(params: { activate?: boolean; cache?: boolean; }): ProviderPlugin[] { + const env = params.env ?? process.env; const bundledProviderCompatPluginIds = params.bundledProviderAllowlistCompat || params.bundledProviderVitestCompat ? resolveBundledProviderCompatPluginIds({ config: params.config, workspaceDir: params.workspaceDir, - env: params.env, + env, onlyPluginIds: params.onlyPluginIds, }) : []; @@ -164,7 +165,7 @@ export function resolvePluginProviders(params: { const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, - env: params.env, + env, onlyPluginIds: params.onlyPluginIds, cache: params.cache ?? false, activate: params.activate ?? false, diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index 27478cbb1a1..c36e53f8cb1 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -72,6 +72,7 @@ describe("resolvePluginWebSearchProviders", () => { beforeEach(() => { loadOpenClawPluginsMock.mockClear(); setActivePluginRegistry(createEmptyPluginRegistry()); + vi.useRealTimers(); }); afterEach(() => { @@ -93,6 +94,212 @@ describe("resolvePluginWebSearchProviders", () => { expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); }); + it("memoizes snapshot provider resolution for the same config and env", () => { + const config = { + plugins: { + allow: ["brave"], + }, + }; + const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; + + const first = resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + const second = resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + expect(second).toBe(first); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); + + it("invalidates the snapshot cache when config or env contents change in place", () => { + const config = { + plugins: { + allow: ["brave"], + }, + }; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home-a", + } as NodeJS.ProcessEnv; + + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + config.plugins.allow = ["perplexity"]; + env.OPENCLAW_HOME = "/tmp/openclaw-home-b"; + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); + }); + + it("skips web-search snapshot memoization when plugin cache opt-outs are set", () => { + const config = { + plugins: { + allow: ["brave"], + }, + }; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1", + } as NodeJS.ProcessEnv; + + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); + }); + + it("skips web-search snapshot memoization when discovery cache ttl is zero", () => { + const config = { + plugins: { + allow: ["brave"], + }, + }; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "0", + } as NodeJS.ProcessEnv; + + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); + }); + + it("invalidates the snapshot cache when global Vitest fallback changes", () => { + const originalVitest = process.env.VITEST; + const config = {}; + const env = { OPENCLAW_HOME: "/tmp/openclaw-home" } as NodeJS.ProcessEnv; + + try { + delete process.env.VITEST; + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + process.env.VITEST = "1"; + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + } finally { + if (originalVitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = originalVitest; + } + } + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); + }); + + it("expires web-search snapshot memoization after the shortest plugin cache ttl", () => { + vi.useFakeTimers(); + const config = { + plugins: { + allow: ["brave"], + }, + }; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "5", + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "20", + } as NodeJS.ProcessEnv; + + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + vi.advanceTimersByTime(4); + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + vi.advanceTimersByTime(2); + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); + }); + + it("invalidates web-search snapshots when cache-control env values change in place", () => { + const config = { + plugins: { + allow: ["brave"], + }, + }; + const env = { + OPENCLAW_HOME: "/tmp/openclaw-home", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: "1000", + } as NodeJS.ProcessEnv; + + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS = "5"; + + resolvePluginWebSearchProviders({ + config, + env, + bundledAllowlistCompat: true, + workspaceDir: "/tmp/workspace", + }); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(2); + }); + it("prefers the active plugin registry for runtime resolution", () => { const registry = createEmptyPluginRegistry(); registry.webSearchProviders.push({ diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 494936d9857..4ccbcd76179 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { loadOpenClawPlugins } from "./loader.js"; import type { PluginLoadOptions } from "./loader.js"; @@ -10,6 +11,92 @@ import { } from "./web-search-providers.shared.js"; const log = createSubsystemLogger("plugins"); +type WebSearchProviderSnapshotCacheEntry = { + expiresAt: number; + providers: PluginWebSearchProviderEntry[]; +}; +const webSearchProviderSnapshotCache = new WeakMap< + OpenClawConfig, + WeakMap> +>(); + +const DEFAULT_DISCOVERY_CACHE_MS = 1000; +const DEFAULT_MANIFEST_CACHE_MS = 1000; + +function shouldUseWebSearchProviderSnapshotCache(env: NodeJS.ProcessEnv): boolean { + if (env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim()) { + return false; + } + if (env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE?.trim()) { + return false; + } + const discoveryCacheMs = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim(); + if (discoveryCacheMs === "0") { + return false; + } + const manifestCacheMs = env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS?.trim(); + if (manifestCacheMs === "0") { + return false; + } + return true; +} + +function resolveWebSearchProviderSnapshotCacheTtlMs(env: NodeJS.ProcessEnv): number { + const discoveryCacheMs = resolveCacheMs( + env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS, + DEFAULT_DISCOVERY_CACHE_MS, + ); + const manifestCacheMs = resolveCacheMs( + env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS, + DEFAULT_MANIFEST_CACHE_MS, + ); + return Math.min(discoveryCacheMs, manifestCacheMs); +} + +function resolveCacheMs(rawValue: string | undefined, defaultMs: number): number { + const raw = rawValue?.trim(); + if (raw === "" || raw === "0") { + return 0; + } + if (!raw) { + return defaultMs; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) { + return defaultMs; + } + return Math.max(0, parsed); +} + +function buildWebSearchSnapshotCacheKey(params: { + config?: OpenClawConfig; + workspaceDir?: string; + bundledAllowlistCompat?: boolean; + env: NodeJS.ProcessEnv; +}): string { + const effectiveVitest = params.env.VITEST ?? process.env.VITEST ?? ""; + return JSON.stringify({ + workspaceDir: params.workspaceDir ?? "", + bundledAllowlistCompat: params.bundledAllowlistCompat === true, + config: params.config ?? null, + env: { + OPENCLAW_BUNDLED_PLUGINS_DIR: params.env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "", + OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: + params.env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE ?? "", + OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: + params.env.OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE ?? "", + OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS: params.env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS ?? "", + OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: params.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS ?? "", + OPENCLAW_HOME: params.env.OPENCLAW_HOME ?? "", + OPENCLAW_STATE_DIR: params.env.OPENCLAW_STATE_DIR ?? "", + CLAWDBOT_STATE_DIR: params.env.CLAWDBOT_STATE_DIR ?? "", + OPENCLAW_CONFIG_PATH: params.env.OPENCLAW_CONFIG_PATH ?? "", + HOME: params.env.HOME ?? "", + USERPROFILE: params.env.USERPROFILE ?? "", + VITEST: effectiveVitest, + }, + }); +} export function resolvePluginWebSearchProviders(params: { config?: PluginLoadOptions["config"]; @@ -19,22 +106,66 @@ export function resolvePluginWebSearchProviders(params: { activate?: boolean; cache?: boolean; }): PluginWebSearchProviderEntry[] { - const { config } = resolveBundledWebSearchResolutionConfig(params); + const env = params.env ?? process.env; + const cacheOwnerConfig = params.config; + const shouldMemoizeSnapshot = + params.activate !== true && + params.cache !== true && + shouldUseWebSearchProviderSnapshotCache(env); + const cacheKey = buildWebSearchSnapshotCacheKey({ + config: cacheOwnerConfig, + workspaceDir: params.workspaceDir, + bundledAllowlistCompat: params.bundledAllowlistCompat, + env, + }); + if (cacheOwnerConfig && shouldMemoizeSnapshot) { + const configCache = webSearchProviderSnapshotCache.get(cacheOwnerConfig); + const envCache = configCache?.get(env); + const cached = envCache?.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.providers; + } + } + const { config } = resolveBundledWebSearchResolutionConfig({ + ...params, + env, + }); const registry = loadOpenClawPlugins({ config, workspaceDir: params.workspaceDir, - env: params.env, + env, cache: params.cache ?? false, activate: params.activate ?? false, logger: createPluginLoaderLogger(log), }); - return sortWebSearchProviders( + const resolved = sortWebSearchProviders( registry.webSearchProviders.map((entry) => ({ ...entry.provider, pluginId: entry.pluginId, })), ); + if (cacheOwnerConfig && shouldMemoizeSnapshot) { + const ttlMs = resolveWebSearchProviderSnapshotCacheTtlMs(env); + let configCache = webSearchProviderSnapshotCache.get(cacheOwnerConfig); + if (!configCache) { + configCache = new WeakMap< + NodeJS.ProcessEnv, + Map + >(); + webSearchProviderSnapshotCache.set(cacheOwnerConfig, configCache); + } + let envCache = configCache.get(env); + if (!envCache) { + envCache = new Map(); + configCache.set(env, envCache); + } + envCache.set(cacheKey, { + expiresAt: Date.now() + ttlMs, + providers: resolved, + }); + } + return resolved; } export function resolveRuntimeWebSearchProviders(params: {