From 372c0051baa7e5f22bb685f2dd4586b5b4a789e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Apr 2026 21:14:06 +0100 Subject: [PATCH] test: speed up slow import-boundary tests --- .../discord/security-audit-contract-api.ts | 1 + extensions/discord/src/security-audit.ts | 12 +- extensions/feishu/src/security-audit.ts | 4 +- .../ollama/src/embedding-provider.test.ts | 146 +++++++++++++++ extensions/slack/src/security-audit.ts | 4 +- .../telegram/security-audit-contract-api.ts | 1 + extensions/telegram/src/security-audit.ts | 10 +- extensions/zalouser/src/security-audit.ts | 2 +- package.json | 4 + .../src/host/embeddings-ollama.test.ts | 167 ++++-------------- scripts/lib/plugin-sdk-entrypoints.json | 1 + .../host/embeddings-lmstudio.test.ts | 47 +++-- .../native-command-config-runtime.ts | 1 + src/plugin-sdk/telegram.ts | 10 +- .../web-search-credential-presence.test.ts | 56 ++++++ src/plugins/web-search-credential-presence.ts | 65 +++++++ .../channels/security-audit-contract.ts | 15 +- 17 files changed, 380 insertions(+), 166 deletions(-) create mode 100644 extensions/discord/security-audit-contract-api.ts create mode 100644 extensions/ollama/src/embedding-provider.test.ts create mode 100644 extensions/telegram/security-audit-contract-api.ts create mode 100644 src/plugin-sdk/native-command-config-runtime.ts create mode 100644 src/plugins/web-search-credential-presence.test.ts diff --git a/extensions/discord/security-audit-contract-api.ts b/extensions/discord/security-audit-contract-api.ts new file mode 100644 index 00000000000..2b44bcaaf7d --- /dev/null +++ b/extensions/discord/security-audit-contract-api.ts @@ -0,0 +1 @@ +export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/discord/src/security-audit.ts b/extensions/discord/src/security-audit.ts index 1dc0a2b72b4..8478a3e3ccc 100644 --- a/extensions/discord/src/security-audit.ts +++ b/extensions/discord/src/security-audit.ts @@ -1,15 +1,19 @@ import { coerceNativeSetting, normalizeAllowFromList } from "openclaw/plugin-sdk/channel-policy"; +import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import { - isDangerousNameMatchingEnabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, -} from "openclaw/plugin-sdk/config-runtime"; -import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/native-command-config-runtime"; import type { ResolvedDiscordAccount } from "./accounts.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { isDiscordMutableAllowEntry } from "./security-doctor.js"; +function normalizeOptionalString(value: string | null | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + function addDiscordNameBasedEntries(params: { target: Set; values: unknown; diff --git a/extensions/feishu/src/security-audit.ts b/extensions/feishu/src/security-audit.ts index 9e9e8918b5f..77a040212a2 100644 --- a/extensions/feishu/src/security-audit.ts +++ b/extensions/feishu/src/security-audit.ts @@ -1,5 +1,5 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup"; +import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; +import type { OpenClawConfig } from "../runtime-api.js"; import { asRecord, hasNonEmptyString } from "./comment-shared.js"; function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean { diff --git a/extensions/ollama/src/embedding-provider.test.ts b/extensions/ollama/src/embedding-provider.test.ts new file mode 100644 index 00000000000..cb3c1bbac39 --- /dev/null +++ b/extensions/ollama/src/embedding-provider.test.ts @@ -0,0 +1,146 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(async ({ init, url }: { init?: RequestInit; url: string }) => ({ + response: await fetch(url, init), + release: async () => {}, + })), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, + formatErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), +})); + +let createOllamaEmbeddingProvider: typeof import("./embedding-provider.js").createOllamaEmbeddingProvider; + +beforeAll(async () => { + ({ createOllamaEmbeddingProvider } = await import("./embedding-provider.js")); +}); + +beforeEach(() => { + fetchWithSsrFGuardMock.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +function mockEmbeddingFetch(embedding: number[]) { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ embedding }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("ollama embedding provider", () => { + it("calls /api/embeddings and returns normalized vectors", async () => { + const fetchMock = mockEmbeddingFetch([3, 4]); + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "http://127.0.0.1:11434" }, + }); + + const vector = await provider.embedQuery("hi"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(vector[0]).toBeCloseTo(0.6, 5); + expect(vector[1]).toBeCloseTo(0.8, 5); + }); + + it("resolves configured base URL, API key, and headers", async () => { + const fetchMock = mockEmbeddingFetch([1, 0]); + + const { provider } = await createOllamaEmbeddingProvider({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + apiKey: "ollama-\nlocal\r\n", // pragma: allowlist secret + headers: { + "X-Provider-Header": "provider", + }, + }, + }, + }, + } as unknown as OpenClawConfig, + provider: "ollama", + model: "", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434/api/embeddings", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer ollama-local", + "X-Provider-Header": "provider", + }), + }), + ); + }); + + it("fails fast when memory-search remote apiKey is an unresolved SecretRef", async () => { + await expect( + createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { + baseUrl: "http://127.0.0.1:11434", + apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, + }, + }), + ).rejects.toThrow(/agents\.\*\.memorySearch\.remote\.apiKey: unresolved SecretRef/i); + }); + + it("falls back to env key when provider apiKey is an unresolved SecretRef", async () => { + const fetchMock = mockEmbeddingFetch([1, 0]); + vi.stubEnv("OLLAMA_API_KEY", "ollama-env"); + + const { provider } = await createOllamaEmbeddingProvider({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434/v1", + apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, + models: [], + }, + }, + }, + } as unknown as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434/api/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer ollama-env", + }), + }), + ); + }); +}); diff --git a/extensions/slack/src/security-audit.ts b/extensions/slack/src/security-audit.ts index 7c385658217..a60d2b4a24d 100644 --- a/extensions/slack/src/security-audit.ts +++ b/extensions/slack/src/security-audit.ts @@ -1,9 +1,9 @@ import { coerceNativeSetting, normalizeAllowFromList } from "openclaw/plugin-sdk/channel-policy"; +import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, -} from "openclaw/plugin-sdk/config-runtime"; -import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; +} from "openclaw/plugin-sdk/native-command-config-runtime"; import type { ResolvedSlackAccount } from "./accounts.js"; import type { OpenClawConfig } from "./runtime-api.js"; diff --git a/extensions/telegram/security-audit-contract-api.ts b/extensions/telegram/security-audit-contract-api.ts new file mode 100644 index 00000000000..e9d793879af --- /dev/null +++ b/extensions/telegram/security-audit-contract-api.ts @@ -0,0 +1 @@ +export { collectTelegramSecurityAuditFindings } from "./src/security-audit.js"; diff --git a/extensions/telegram/src/security-audit.ts b/extensions/telegram/src/security-audit.ts index 3e4fcc12481..b3191fc6dca 100644 --- a/extensions/telegram/src/security-audit.ts +++ b/extensions/telegram/src/security-audit.ts @@ -1,10 +1,14 @@ -import { resolveNativeSkillsEnabled } from "openclaw/plugin-sdk/config-runtime"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { resolveNativeSkillsEnabled } from "openclaw/plugin-sdk/native-command-config-runtime"; +import type { OpenClawConfig } from "../runtime-api.js"; import type { ResolvedTelegramAccount } from "./accounts.js"; import { isNumericTelegramSenderUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; +function normalizeOptionalString(value: string | null | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + function collectInvalidTelegramAllowFromEntries(params: { entries: unknown; target: Set }) { if (!Array.isArray(params.entries)) { return; diff --git a/extensions/zalouser/src/security-audit.ts b/extensions/zalouser/src/security-audit.ts index 2d6fb6f1304..0423df4efd0 100644 --- a/extensions/zalouser/src/security-audit.ts +++ b/extensions/zalouser/src/security-audit.ts @@ -1,4 +1,4 @@ -import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import type { ResolvedZalouserAccount } from "./accounts.js"; export function isZalouserMutableGroupEntry(raw: string): boolean { diff --git a/package.json b/package.json index d74056fa6c4..ac2be5f2e15 100644 --- a/package.json +++ b/package.json @@ -879,6 +879,10 @@ "types": "./dist/plugin-sdk/skill-commands-runtime.d.ts", "default": "./dist/plugin-sdk/skill-commands-runtime.js" }, + "./plugin-sdk/native-command-config-runtime": { + "types": "./dist/plugin-sdk/native-command-config-runtime.d.ts", + "default": "./dist/plugin-sdk/native-command-config-runtime.js" + }, "./plugin-sdk/native-command-registry": { "types": "./dist/plugin-sdk/native-command-registry.d.ts", "default": "./dist/plugin-sdk/native-command-registry.js" diff --git a/packages/memory-host-sdk/src/host/embeddings-ollama.test.ts b/packages/memory-host-sdk/src/host/embeddings-ollama.test.ts index 08fb33c9cc4..77212567018 100644 --- a/packages/memory-host-sdk/src/host/embeddings-ollama.test.ts +++ b/packages/memory-host-sdk/src/host/embeddings-ollama.test.ts @@ -1,146 +1,43 @@ -import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from "vitest"; -import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -let createOllamaEmbeddingProvider: typeof import("./embeddings-ollama.js").createOllamaEmbeddingProvider; +const { createOllamaEmbeddingProviderMock } = vi.hoisted(() => ({ + createOllamaEmbeddingProviderMock: vi.fn(async (options: unknown) => ({ + provider: { source: "mock-provider", options }, + client: { source: "mock-client" }, + })), +})); -beforeAll(async () => { - ({ createOllamaEmbeddingProvider } = await import("./embeddings-ollama.js")); -}); +vi.mock("../../../../src/plugin-sdk/ollama-runtime.js", () => ({ + DEFAULT_OLLAMA_EMBEDDING_MODEL: "nomic-embed-text", + createOllamaEmbeddingProvider: createOllamaEmbeddingProviderMock, +})); -beforeEach(() => { - vi.useRealTimers(); - vi.doUnmock("undici"); -}); +describe("memory-host-sdk Ollama embedding facade", () => { + beforeEach(() => { + createOllamaEmbeddingProviderMock.mockClear(); + }); -afterEach(() => { - vi.doUnmock("undici"); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.resetAllMocks(); -}); + it("re-exports the default Ollama embedding model", async () => { + const mod = await import("./embeddings-ollama.js"); + expect(mod.DEFAULT_OLLAMA_EMBEDDING_MODEL).toBe("nomic-embed-text"); + }); -describe("embeddings-ollama", () => { - it("calls /api/embeddings and returns normalized vectors", async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ embedding: [3, 4] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const { provider } = await createOllamaEmbeddingProvider({ - config: {} as OpenClawConfig, + it("delegates provider creation to the plugin-sdk runtime facade", async () => { + const mod = await import("./embeddings-ollama.js"); + const options = { provider: "ollama", model: "nomic-embed-text", fallback: "none", - remote: { baseUrl: "http://127.0.0.1:11434" }, + config: {}, + }; + + const result = await mod.createOllamaEmbeddingProvider(options as never); + + expect(createOllamaEmbeddingProviderMock).toHaveBeenCalledTimes(1); + expect(createOllamaEmbeddingProviderMock).toHaveBeenCalledWith(options); + expect(result).toEqual({ + provider: { source: "mock-provider", options }, + client: { source: "mock-client" }, }); - - const v = await provider.embedQuery("hi"); - expect(fetchMock).toHaveBeenCalledTimes(1); - // normalized [3,4] => [0.6,0.8] - expect(v[0]).toBeCloseTo(0.6, 5); - expect(v[1]).toBeCloseTo(0.8, 5); - }); - - it("resolves baseUrl/apiKey/headers from models.providers.ollama and strips /v1", async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ embedding: [1, 0] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const { provider } = await createOllamaEmbeddingProvider({ - config: { - models: { - providers: { - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - apiKey: "ollama-\nlocal\r\n", // pragma: allowlist secret - headers: { - "X-Provider-Header": "provider", - }, - }, - }, - }, - } as unknown as OpenClawConfig, - provider: "ollama", - model: "", - fallback: "none", - }); - - await provider.embedQuery("hello"); - - expect(fetchMock).toHaveBeenCalledWith( - "http://127.0.0.1:11434/api/embeddings", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/json", - Authorization: "Bearer ollama-local", - "X-Provider-Header": "provider", - }), - }), - ); - }); - - it("fails fast when memory-search remote apiKey is an unresolved SecretRef", async () => { - await expect( - createOllamaEmbeddingProvider({ - config: {} as OpenClawConfig, - provider: "ollama", - model: "nomic-embed-text", - fallback: "none", - remote: { - baseUrl: "http://127.0.0.1:11434", - apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, - }, - }), - ).rejects.toThrow(/agents\.\*\.memorySearch\.remote\.apiKey: unresolved SecretRef/i); - }); - - it("falls back to env key when models.providers.ollama.apiKey is an unresolved SecretRef", async () => { - const fetchMock = vi.fn( - async () => - new Response(JSON.stringify({ embedding: [1, 0] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - vi.stubEnv("OLLAMA_API_KEY", "ollama-env"); - - const { provider } = await createOllamaEmbeddingProvider({ - config: { - models: { - providers: { - ollama: { - baseUrl: "http://127.0.0.1:11434/v1", - apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" }, - models: [], - }, - }, - }, - } as unknown as OpenClawConfig, - provider: "ollama", - model: "nomic-embed-text", - fallback: "none", - }); - - await provider.embedQuery("hello"); - - expect(fetchMock).toHaveBeenCalledWith( - "http://127.0.0.1:11434/api/embeddings", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer ollama-env", - }), - }), - ); }); }); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 017f4203025..b296f5b5214 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -206,6 +206,7 @@ "msteams", "models-provider-runtime", "skill-commands-runtime", + "native-command-config-runtime", "native-command-registry", "nextcloud-talk", "nostr", diff --git a/src/memory-host-sdk/host/embeddings-lmstudio.test.ts b/src/memory-host-sdk/host/embeddings-lmstudio.test.ts index 87250cc977b..f953daba36f 100644 --- a/src/memory-host-sdk/host/embeddings-lmstudio.test.ts +++ b/src/memory-host-sdk/host/embeddings-lmstudio.test.ts @@ -1,17 +1,40 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn()); const resolveLmstudioRuntimeApiKeyMock = vi.hoisted(() => vi.fn()); -vi.mock("../../plugin-sdk/lmstudio-runtime.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - ensureLmstudioModelLoaded: (...args: unknown[]) => ensureLmstudioModelLoadedMock(...args), - resolveLmstudioRuntimeApiKey: (...args: unknown[]) => resolveLmstudioRuntimeApiKeyMock(...args), - }; -}); +vi.mock("../../plugin-sdk/lmstudio-runtime.js", () => ({ + buildLmstudioAuthHeaders: ({ + apiKey, + json, + headers, + }: { + apiKey?: string; + json?: boolean; + headers?: Record; + }) => ({ + ...(json ? { "Content-Type": "application/json" } : {}), + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + ...headers, + }), + ensureLmstudioModelLoaded: (...args: unknown[]) => ensureLmstudioModelLoadedMock(...args), + LMSTUDIO_DEFAULT_EMBEDDING_MODEL: "text-embedding-nomic-embed-text-v1.5", + LMSTUDIO_PROVIDER_ID: "lmstudio", + resolveLmstudioInferenceBase: (baseUrl?: string) => { + const normalized = (baseUrl || "http://localhost:1234").replace(/\/+$/u, ""); + if (normalized.endsWith("/api/v1")) { + return normalized.slice(0, -"/api/v1".length) + "/v1"; + } + if (normalized.endsWith("/v1")) { + return normalized; + } + return `${normalized}/v1`; + }, + resolveLmstudioProviderHeaders: ({ headers }: { headers?: Record }) => + headers ?? {}, + resolveLmstudioRuntimeApiKey: (...args: unknown[]) => resolveLmstudioRuntimeApiKeyMock(...args), +})); let createLmstudioEmbeddingProvider: typeof import("./embeddings-lmstudio.js").createLmstudioEmbeddingProvider; @@ -35,9 +58,11 @@ describe("embeddings-lmstudio", () => { return fetchMock; } - beforeEach(async () => { - vi.resetModules(); + beforeAll(async () => { ({ createLmstudioEmbeddingProvider } = await import("./embeddings-lmstudio.js")); + }); + + beforeEach(() => { ensureLmstudioModelLoadedMock.mockReset(); resolveLmstudioRuntimeApiKeyMock.mockReset(); }); diff --git a/src/plugin-sdk/native-command-config-runtime.ts b/src/plugin-sdk/native-command-config-runtime.ts new file mode 100644 index 00000000000..c0db6ba48ea --- /dev/null +++ b/src/plugin-sdk/native-command-config-runtime.ts @@ -0,0 +1 @@ +export { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index f271b255e76..d4a3fd3fb86 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -1,5 +1,6 @@ // Manual facade. Keep loader boundary explicit. type FacadeModule = typeof import("@openclaw/telegram/contract-api.js"); +type SecurityAuditFacadeModule = typeof import("@openclaw/telegram/security-audit-contract-api.js"); import { createLazyFacadeArrayValue, loadBundledPluginPublicSurfaceModuleSync, @@ -12,6 +13,13 @@ function loadFacadeModule(): FacadeModule { }); } +function loadSecurityAuditFacadeModule(): SecurityAuditFacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "telegram", + artifactBasename: "security-audit-contract-api.js", + }); +} + export const parseTelegramTopicConversation: FacadeModule["parseTelegramTopicConversation"] = (( ...args ) => @@ -24,7 +32,7 @@ export const singleAccountKeysToMove: FacadeModule["singleAccountKeysToMove"] = export const collectTelegramSecurityAuditFindings: FacadeModule["collectTelegramSecurityAuditFindings"] = ((...args) => - loadFacadeModule().collectTelegramSecurityAuditFindings( + loadSecurityAuditFacadeModule().collectTelegramSecurityAuditFindings( ...args, )) as FacadeModule["collectTelegramSecurityAuditFindings"]; diff --git a/src/plugins/web-search-credential-presence.test.ts b/src/plugins/web-search-credential-presence.test.ts new file mode 100644 index 00000000000..afedee7deea --- /dev/null +++ b/src/plugins/web-search-credential-presence.test.ts @@ -0,0 +1,56 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({ + resolvePluginWebSearchProvidersMock: vi.fn(() => [ + { + id: "brave", + pluginId: "brave", + envVars: ["BRAVE_API_KEY"], + getCredentialValue: (searchConfig: Record | undefined) => + searchConfig?.apiKey, + }, + ]), +})); + +vi.mock("./web-search-providers.runtime.js", () => ({ + resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, +})); + +let hasConfiguredWebSearchCredential: typeof import("./web-search-credential-presence.js").hasConfiguredWebSearchCredential; + +beforeAll(async () => { + ({ hasConfiguredWebSearchCredential } = await import("./web-search-credential-presence.js")); +}); + +beforeEach(() => { + resolvePluginWebSearchProvidersMock.mockClear(); +}); + +describe("hasConfiguredWebSearchCredential", () => { + it("keeps empty config and env on the manifest-only path", () => { + expect( + hasConfiguredWebSearchCredential({ + config: {} as OpenClawConfig, + env: {}, + origin: "bundled", + bundledAllowlistCompat: true, + }), + ).toBe(false); + expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled(); + }); + + it("loads provider runtime only when a credential candidate exists", () => { + expect( + hasConfiguredWebSearchCredential({ + config: { + tools: { web: { search: { apiKey: "brave-key" } } }, + } as OpenClawConfig, + env: {}, + origin: "bundled", + bundledAllowlistCompat: true, + }), + ).toBe(true); + expect(resolvePluginWebSearchProvidersMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/web-search-credential-presence.ts b/src/plugins/web-search-credential-presence.ts index 2eb11a9fe78..78aeb76a36c 100644 --- a/src/plugins/web-search-credential-presence.ts +++ b/src/plugins/web-search-credential-presence.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import { resolvePluginWebSearchProviders } from "./web-search-providers.runtime.js"; @@ -9,6 +10,59 @@ function hasConfiguredCredentialValue(value: unknown): boolean { return value !== undefined && value !== null; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function hasConfiguredSearchCredentialCandidate(searchConfig: unknown): boolean { + if (!isRecord(searchConfig)) { + return false; + } + return Object.entries(searchConfig).some( + ([key, value]) => key !== "enabled" && hasConfiguredCredentialValue(value), + ); +} + +function hasConfiguredPluginWebSearchCandidate(config: OpenClawConfig): boolean { + const entries = isRecord(config.plugins?.entries) ? config.plugins.entries : undefined; + if (!entries) { + return false; + } + return Object.values(entries).some((entry) => { + const pluginConfig = isRecord(entry) ? entry.config : undefined; + return isRecord(pluginConfig) && hasConfiguredSearchCredentialCandidate(pluginConfig.webSearch); + }); +} + +function hasManifestWebSearchEnvCredentialCandidate(params: { + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + origin?: PluginManifestRecord["origin"]; +}): boolean { + const env = params.env; + if (!env) { + return false; + } + return loadPluginManifestRegistry({ + config: params.config, + env, + }).plugins.some((plugin) => { + if (params.origin && plugin.origin !== params.origin) { + return false; + } + if ((plugin.contracts?.webSearchProviders?.length ?? 0) === 0) { + return false; + } + const providerAuthEnvVars = plugin.providerAuthEnvVars; + if (!providerAuthEnvVars) { + return false; + } + return Object.values(providerAuthEnvVars) + .flat() + .some((envVar) => hasConfiguredCredentialValue(env[envVar])); + }); +} + export function hasConfiguredWebSearchCredential(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; @@ -19,6 +73,17 @@ export function hasConfiguredWebSearchCredential(params: { const searchConfig = params.searchConfig ?? (params.config.tools?.web?.search as Record | undefined); + if ( + !hasConfiguredSearchCredentialCandidate(searchConfig) && + !hasConfiguredPluginWebSearchCandidate(params.config) && + !hasManifestWebSearchEnvCredentialCandidate({ + config: params.config, + env: params.env, + origin: params.origin, + }) + ) { + return false; + } return resolvePluginWebSearchProviders({ config: params.config, env: params.env, diff --git a/test/helpers/channels/security-audit-contract.ts b/test/helpers/channels/security-audit-contract.ts index 12ba5cd7f20..8c576d069ff 100644 --- a/test/helpers/channels/security-audit-contract.ts +++ b/test/helpers/channels/security-audit-contract.ts @@ -1,16 +1,17 @@ import { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; -type DiscordSecuritySurface = typeof import("@openclaw/discord/contract-api.js"); +type DiscordSecurityAuditSurface = + typeof import("@openclaw/discord/security-audit-contract-api.js"); type FeishuSecuritySurface = typeof import("@openclaw/feishu/security-contract-api.js"); type SlackSecuritySurface = typeof import("@openclaw/slack/security-contract-api.js"); type SynologyChatSecuritySurface = typeof import("@openclaw/synology-chat/contract-api.js"); type TelegramSecuritySurface = typeof import("@openclaw/telegram/contract-api.js"); type ZalouserSecuritySurface = typeof import("@openclaw/zalouser/contract-api.js"); -function loadDiscordSecuritySurface(): DiscordSecuritySurface { - return loadBundledPluginPublicSurfaceSync({ +function loadDiscordSecurityAuditSurface(): DiscordSecurityAuditSurface { + return loadBundledPluginPublicSurfaceSync({ pluginId: "discord", - artifactBasename: "contract-api.js", + artifactBasename: "security-audit-contract-api.js", }); } @@ -49,11 +50,11 @@ function loadZalouserSecuritySurface(): ZalouserSecuritySurface { }); } -export const collectDiscordSecurityAuditFindings: DiscordSecuritySurface["collectDiscordSecurityAuditFindings"] = +export const collectDiscordSecurityAuditFindings: DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"] = ((...args) => - loadDiscordSecuritySurface().collectDiscordSecurityAuditFindings( + loadDiscordSecurityAuditSurface().collectDiscordSecurityAuditFindings( ...args, - )) as DiscordSecuritySurface["collectDiscordSecurityAuditFindings"]; + )) as DiscordSecurityAuditSurface["collectDiscordSecurityAuditFindings"]; export const collectFeishuSecurityAuditFindings: FeishuSecuritySurface["collectFeishuSecurityAuditFindings"] = ((...args) =>