Files
openclaw/src/secrets/runtime-web-tools.test.ts
Peter Steinberger 8a5c8690e1 fix(secrets): keep unaffected owners live during reload failures (#110779)
* fix(secrets): isolate reload failures per owner

* refactor(secrets): split runtime activation helpers

* fix(secrets): export web warning type

* fix(secrets): reject unsafe degraded config writes

* fix(secrets): derive reload defaults type

* fix(secrets): defer reload state publication

* fix(secrets): preserve partial refresh state

* fix(secrets): retry superseded reload preflight

* fix(secrets): bind stale credentials to owner contracts

* fix(secrets): scope degraded credential contracts

* fix(secrets): restore source ownership guards

* fix(secrets): recover provider-only degradation

* fix(secrets): enforce degraded reload contracts

* fix(secrets): preserve scoped reload state

* fix(secrets): reconcile deferred descendant state

* fix(secrets): commit reload state atomically

* fix(secrets): preserve source transaction lineage

* test(secrets): use non-secret lineage marker

* chore(plugin-sdk): refresh API baseline

* fix(secrets): canonicalize web owner contracts

* fix(plugin-sdk): preserve legacy secret owner contracts

* fix(secrets): satisfy startup activation types

* test(secrets): align reload fixtures with owner contracts

* refactor(secrets): move source recovery scope helper

* fix(secrets): preserve owner contracts on web failures

* fix(secrets): bind legacy web resolution contract

* fix(secrets): retry stale auth publication
2026-07-18 18:29:24 +01:00

2170 lines
69 KiB
TypeScript

/** Tests web-tool secret metadata resolution from config and plugins. */
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type {
PluginWebFetchProviderEntry,
PluginWebSearchProviderEntry,
} from "../plugins/types.js";
import { listSecretResolutionErrorOwners } from "./runtime-degraded-state.js";
import {
activateSecretsRuntimeSnapshotState,
clearSecretsRuntimeSnapshot,
} from "./runtime-state.js";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "duckduckgo";
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => buildTestWebSearchProviders()),
}));
const { resolvePluginWebFetchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebFetchProvidersMock: vi.fn(() => buildTestWebFetchProviders()),
}));
const {
resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock,
resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock,
} = vi.hoisted(() => ({
resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock: vi.fn(() =>
buildTestWebSearchProviders(),
),
resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock: vi.fn(() =>
buildTestWebFetchProviders(),
),
}));
const {
resolveBundledWebSearchProvidersFromPublicArtifactsMock,
resolveBundledWebFetchProvidersFromPublicArtifactsMock,
} = vi.hoisted(() => ({
resolveBundledWebSearchProvidersFromPublicArtifactsMock: vi.fn(() =>
buildTestWebSearchProviders(),
),
resolveBundledWebFetchProvidersFromPublicArtifactsMock: vi.fn(() => buildTestWebFetchProviders()),
}));
const {
resolveManifestContractPluginIdsMock,
resolveManifestContractPluginIdsByCompatibilityRuntimePathMock,
resolveManifestContractOwnerPluginIdMock,
} = vi.hoisted(() => ({
resolveManifestContractPluginIdsMock: vi.fn(() => [
"brave",
"duckduckgo",
"google",
"moonshot",
"perplexity",
"xai",
]),
resolveManifestContractPluginIdsByCompatibilityRuntimePathMock: vi.fn(() => ["brave"]),
resolveManifestContractOwnerPluginIdMock: vi.fn(
({ value }: { value: string }) =>
(
({
brave: "brave",
firecrawl: "firecrawl",
gemini: "google",
grok: "xai",
kimi: "moonshot",
perplexity: "perplexity",
}) as Record<string, string | undefined>
)[value],
),
}));
const { loadInstalledPluginIndexInstallRecordsSyncMock } = vi.hoisted(() => ({
loadInstalledPluginIndexInstallRecordsSyncMock: vi.fn(() => ({})),
}));
let secretResolve: typeof import("./resolve.js");
let createResolverContext: typeof import("./runtime-shared.js").createResolverContext;
let resolveRuntimeWebTools: typeof import("./runtime-web-tools.js").resolveRuntimeWebTools;
let restoreResolveSecretRefValuesSpy: (() => void) | undefined;
vi.mock("./runtime-web-tools-fallback.runtime.js", () => ({
runtimeWebToolsFallbackProviders: {
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
resolvePluginWebFetchProviders: resolvePluginWebFetchProvidersMock,
},
}));
vi.mock("../plugins/web-provider-public-artifacts.explicit.js", () => ({
resolveBundledExplicitWebSearchProvidersFromPublicArtifacts:
resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock,
resolveBundledExplicitWebFetchProvidersFromPublicArtifacts:
resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock,
}));
vi.mock("./runtime-web-tools-public-artifacts.runtime.js", () => ({
resolveBundledWebSearchProvidersFromPublicArtifacts:
resolveBundledWebSearchProvidersFromPublicArtifactsMock,
resolveBundledWebFetchProvidersFromPublicArtifacts:
resolveBundledWebFetchProvidersFromPublicArtifactsMock,
}));
vi.mock("./runtime-web-tools-manifest.runtime.js", () => ({
resolveManifestContractPluginIds: resolveManifestContractPluginIdsMock,
resolveManifestContractOwnerPluginId: resolveManifestContractOwnerPluginIdMock,
resolveManifestContractPluginIdsByCompatibilityRuntimePath:
resolveManifestContractPluginIdsByCompatibilityRuntimePathMock,
}));
vi.mock("../plugins/installed-plugin-index-records.js", () => ({
loadInstalledPluginIndexInstallRecordsSync: loadInstalledPluginIndexInstallRecordsSyncMock,
}));
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function providerPluginId(provider: ProviderUnderTest): string {
switch (provider) {
case "duckduckgo":
return "duckduckgo";
case "gemini":
return "google";
case "grok":
return "xai";
case "kimi":
return "moonshot";
default:
return provider;
}
}
function ensureRecord(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (typeof current === "object" && current !== null && !Array.isArray(current)) {
return current as Record<string, unknown>;
}
const next: Record<string, unknown> = {};
target[key] = next;
return next;
}
function setConfiguredProviderKey(
configTarget: OpenClawConfig,
pluginId: string,
value: unknown,
): void {
const plugins = ensureRecord(configTarget as Record<string, unknown>, "plugins");
const entries = ensureRecord(plugins, "entries");
const pluginEntry = ensureRecord(entries, pluginId);
const config = ensureRecord(pluginEntry, "config");
const webSearch = ensureRecord(config, "webSearch");
webSearch.apiKey = value;
}
function setConfiguredFetchProviderKey(configTarget: OpenClawConfig, value: unknown): void {
const plugins = ensureRecord(configTarget as Record<string, unknown>, "plugins");
const entries = ensureRecord(plugins, "entries");
const pluginEntry = ensureRecord(entries, "firecrawl");
const config = ensureRecord(pluginEntry, "config");
const webFetch = ensureRecord(config, "webFetch");
webFetch.apiKey = value;
}
function createTestProvider(params: {
provider: ProviderUnderTest;
pluginId: string;
order: number;
}): PluginWebSearchProviderEntry {
const credentialPath = `plugins.entries.${params.pluginId}.config.webSearch.apiKey`;
return {
pluginId: params.pluginId,
id: params.provider,
label: params.provider,
hint: `${params.provider} test provider`,
requiresCredential: params.provider === "duckduckgo" ? false : undefined,
envVars: params.provider === "duckduckgo" ? [] : [`${params.provider.toUpperCase()}_API_KEY`],
placeholder: params.provider === "duckduckgo" ? "(no key needed)" : `${params.provider}-...`,
signupUrl: `https://example.com/${params.provider}`,
autoDetectOrder: params.order,
credentialPath: params.provider === "duckduckgo" ? "" : credentialPath,
inactiveSecretPaths: params.provider === "duckduckgo" ? [] : [credentialPath],
getCredentialValue: (searchConfig) =>
params.provider === "duckduckgo" ? "duckduckgo-no-key-needed" : searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
getConfiguredCredentialValue: (config) => {
const entryConfig = config?.plugins?.entries?.[params.pluginId]?.config;
const configuredValue =
entryConfig && typeof entryConfig === "object"
? (entryConfig as { webSearch?: { apiKey?: unknown } }).webSearch?.apiKey
: undefined;
if (configuredValue !== undefined || params.provider !== "brave") {
return configuredValue;
}
const search = config?.tools?.web?.search;
return search && typeof search === "object"
? (search as { apiKey?: unknown }).apiKey
: undefined;
},
getConfiguredCredentialFallback: (config) => {
if (params.provider === "brave") {
const search = config?.tools?.web?.search;
return search && typeof search === "object" && "apiKey" in search
? {
path: "tools.web.search.apiKey",
value: (search as { apiKey?: unknown }).apiKey,
}
: undefined;
}
if (params.provider === "gemini") {
const provider = config?.models?.providers?.google;
return provider && typeof provider === "object" && "apiKey" in provider
? {
path: "models.providers.google.apiKey",
value: (provider as { apiKey?: unknown }).apiKey,
}
: undefined;
}
return undefined;
},
setConfiguredCredentialValue: (configTarget, value) => {
setConfiguredProviderKey(configTarget, params.pluginId, value);
},
resolveRuntimeMetadata:
params.provider === "perplexity"
? () => ({
perplexityTransport: "search_api" as const,
})
: undefined,
createTool: () => null,
};
}
function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] {
return [
createTestProvider({ provider: "brave", pluginId: "brave", order: 10 }),
createTestProvider({ provider: "gemini", pluginId: "google", order: 20 }),
createTestProvider({ provider: "grok", pluginId: "xai", order: 30 }),
createTestProvider({ provider: "kimi", pluginId: "moonshot", order: 40 }),
createTestProvider({ provider: "perplexity", pluginId: "perplexity", order: 50 }),
createTestProvider({ provider: "duckduckgo", pluginId: "duckduckgo", order: 100 }),
];
}
function buildTestWebFetchProviders(): PluginWebFetchProviderEntry[] {
return [
{
pluginId: "firecrawl",
id: "firecrawl",
label: "firecrawl",
hint: "firecrawl test provider",
requiresCredential: false,
envVars: ["FIRECRAWL_API_KEY"],
placeholder: "fc-...",
signupUrl: "https://example.com/firecrawl",
autoDetectOrder: 50,
credentialPath: "plugins.entries.firecrawl.config.webFetch.apiKey",
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webFetch.apiKey"],
getCredentialValue: (fetchConfig) => fetchConfig?.apiKey,
setCredentialValue: (fetchConfigTarget, value) => {
fetchConfigTarget.apiKey = value;
},
getConfiguredCredentialValue: (config) => {
const entryConfig = config?.plugins?.entries?.firecrawl?.config;
return entryConfig && typeof entryConfig === "object"
? (entryConfig as { webFetch?: { apiKey?: unknown } }).webFetch?.apiKey
: undefined;
},
getConfiguredCredentialFallback: (config) => {
const entryConfig = config?.plugins?.entries?.firecrawl?.config;
const apiKey =
entryConfig && typeof entryConfig === "object"
? (entryConfig as { webSearch?: { apiKey?: unknown } }).webSearch?.apiKey
: undefined;
return apiKey === undefined
? undefined
: {
path: "plugins.entries.firecrawl.config.webSearch.apiKey",
value: apiKey,
};
},
setConfiguredCredentialValue: (configTarget, value) => {
setConfiguredFetchProviderKey(configTarget, value);
},
createTool: () => null,
},
];
}
async function runRuntimeWebTools(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
allowUnavailableSecretOwners?: boolean;
}) {
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const context = createResolverContext({
sourceConfig,
env: params.env ?? {},
});
const result = await resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
allowUnavailableSecretOwners: params.allowUnavailableSecretOwners,
});
return { ...result, resolvedConfig, context };
}
function activateRuntimeWebToolsResult(
sourceConfig: OpenClawConfig,
result: Awaited<ReturnType<typeof runRuntimeWebTools>>,
): void {
activateSecretsRuntimeSnapshotState({
snapshot: {
sourceConfig,
config: result.resolvedConfig,
authStores: [],
authStoreCredentialsRevision: 0,
warnings: result.context.warnings,
degradedOwners: result.degradedOwners,
secretOwners: result.secretOwners,
webTools: result.metadata,
},
refreshContext: null,
refreshHandler: null,
});
}
function createProviderSecretRefConfig(
provider: ProviderUnderTest,
envRefId: string,
): OpenClawConfig {
return asConfig({
tools: {
web: {
search: {
enabled: true,
provider,
},
},
},
plugins: {
entries: {
[providerPluginId(provider)]: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: envRefId },
},
},
},
},
},
});
}
function readProviderKey(config: OpenClawConfig, provider: ProviderUnderTest): unknown {
const pluginConfig = config.plugins?.entries?.[providerPluginId(provider)]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined;
return pluginConfig?.webSearch?.apiKey;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object") {
throw new Error(`expected ${label}`);
}
return value as Record<string, unknown>;
}
function diagnostics(value: unknown) {
expect(Array.isArray(value), "diagnostics").toBe(true);
return value as Array<Record<string, unknown>>;
}
function expectDiagnostic(
value: unknown,
fields: { code: string; path?: string; messageIncludes?: string },
) {
const diagnostic = diagnostics(value).find(
(candidate) =>
candidate.code === fields.code &&
(fields.path === undefined || candidate.path === fields.path),
);
if (!diagnostic) {
throw new Error(`Expected diagnostic ${fields.code}${fields.path ? ` ${fields.path}` : ""}`);
}
if (fields.messageIncludes) {
expect(typeof diagnostic.message).toBe("string");
expect(diagnostic.message).toContain(fields.messageIncludes);
}
}
function expectNoDiagnosticCode(value: unknown, code: string) {
expect(diagnostics(value).some((diagnostic) => diagnostic.code === code)).toBe(false);
}
function firstMockArg(source: { mock: { calls: Array<Array<unknown>> } }) {
const call = source.mock.calls[0];
if (!call) {
throw new Error("expected mock call options");
}
return requireRecord(call[0], "mock call options");
}
describe("runtime web tools resolution", () => {
beforeAll(async () => {
secretResolve = await import("./resolve.js");
({ createResolverContext } = await import("./runtime-shared.js"));
({ resolveRuntimeWebTools } = await import("./runtime-web-tools.js"));
// The managed-index branch lazily loads this stable runtime once per process.
await import("./runtime-web-tools-fallback.runtime.js");
});
beforeEach(() => {
resolvePluginWebSearchProvidersMock.mockReset();
resolvePluginWebSearchProvidersMock.mockImplementation(() => buildTestWebSearchProviders());
resolvePluginWebFetchProvidersMock.mockClear();
resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock.mockClear();
resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock.mockClear();
resolveBundledWebSearchProvidersFromPublicArtifactsMock.mockClear();
resolveBundledWebFetchProvidersFromPublicArtifactsMock.mockClear();
resolveManifestContractOwnerPluginIdMock.mockReset();
resolveManifestContractOwnerPluginIdMock.mockImplementation(
({ value }: { value: string }) =>
(
({
brave: "brave",
firecrawl: "firecrawl",
gemini: "google",
grok: "xai",
kimi: "moonshot",
perplexity: "perplexity",
}) as Record<string, string | undefined>
)[value],
);
resolveManifestContractPluginIdsMock.mockClear();
resolveManifestContractPluginIdsByCompatibilityRuntimePathMock.mockClear();
loadInstalledPluginIndexInstallRecordsSyncMock.mockReset();
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({});
});
afterEach(() => {
restoreResolveSecretRefValuesSpy?.();
restoreResolveSecretRefValuesSpy = undefined;
clearSecretsRuntimeSnapshot();
});
it("keeps web search inactive when only web fetch is configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY" },
},
},
},
},
},
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-runtime-key", // pragma: allowlist secret
},
});
expect(metadata.search.selectedProvider).toBeUndefined();
expect(metadata.search.providerSource).toBe("none");
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(metadata.fetch.selectedProviderKeySource).toBe("secretRef");
expect(resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
});
it("keeps web fetch inactive when only web search is configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "XAI_API_KEY_REF" },
},
},
},
},
},
tools: {
web: {
search: {
provider: "grok",
},
},
},
}),
env: {
XAI_API_KEY_REF: "xai-runtime-key", // pragma: allowlist secret
},
});
expect(metadata.search.selectedProvider).toBe("grok");
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(metadata.fetch.selectedProvider).toBeUndefined();
expect(metadata.fetch.providerSource).toBe("none");
expect(resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolveBundledWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
});
it("skips fetch provider discovery when web fetch only configures runtime limits", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
enabled: true,
maxChars: 200_000,
maxCharsCap: 2_000_000,
},
},
},
plugins: {
enabled: true,
allow: [],
entries: {},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-key-should-not-resolve", // pragma: allowlist secret
},
});
expect(metadata.fetch.providerSource).toBe("none");
expect(metadata.fetch.selectedProvider).toBeUndefined();
expect(resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolveBundledWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
});
it("skips fetch provider discovery when web fetch is explicitly disabled", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
enabled: false,
provider: "firecrawl",
},
},
},
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY" },
},
},
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-key-should-not-resolve", // pragma: allowlist secret
},
});
expect(metadata.fetch.providerSource).toBe("none");
expect(metadata.fetch.selectedProvider).toBeUndefined();
expect(resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolveBundledWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
});
it("keeps active fetch provider SecretRefs on the discovery path", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY" },
},
},
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-key", // pragma: allowlist secret
},
});
expect(metadata.fetch.providerSource).toBe("configured");
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock).toHaveBeenCalledWith({
onlyPluginIds: ["firecrawl"],
});
});
it("selects the configured keyless Firecrawl fetch provider without an API key", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
});
expect(metadata.fetch.providerSource).toBe("configured");
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(metadata.fetch.selectedProviderKeySource).toBe("missing");
});
it("does not auto-select keyless Firecrawl fetch without a credential", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
enabled: true,
},
},
},
}),
});
expect(metadata.fetch.providerSource).toBe("none");
expect(metadata.fetch.selectedProvider).toBeUndefined();
});
it("does not auto-select a keyless provider when no credentials are configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
}),
});
expect(metadata.search.selectedProvider).toBeUndefined();
expect(metadata.search.providerSource).toBe("none");
expect(metadata.search.diagnostics).toEqual([]);
});
it.each([
{
provider: "brave" as const,
envRefId: "BRAVE_PROVIDER_REF",
resolvedKey: "brave-provider-key",
},
{
provider: "gemini" as const,
envRefId: "GEMINI_PROVIDER_REF",
resolvedKey: "gemini-provider-key",
},
{
provider: "grok" as const,
envRefId: "GROK_PROVIDER_REF",
resolvedKey: "grok-provider-key",
},
{
provider: "kimi" as const,
envRefId: "KIMI_PROVIDER_REF",
resolvedKey: "kimi-provider-key",
},
{
provider: "perplexity" as const,
envRefId: "PERPLEXITY_PROVIDER_REF",
resolvedKey: "pplx-provider-key",
},
])(
"resolves configured provider SecretRef for $provider",
async ({ provider, envRefId, resolvedKey }) => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: createProviderSecretRefConfig(provider, envRefId),
env: {
[envRefId]: resolvedKey,
},
});
expect(metadata.search.providerConfigured).toBe(provider);
expect(metadata.search.providerSource).toBe("configured");
expect(metadata.search.selectedProvider).toBe(provider);
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(readProviderKey(resolvedConfig, provider)).toBe(resolvedKey);
expect(context.warnings.map((warning) => warning.code)).not.toContain(
"WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
);
if (provider === "perplexity") {
expect(metadata.search.perplexityTransport).toBe("search_api");
}
},
);
it("retains a stale web credential across repeated failed refreshes", async () => {
const sourceConfig = createProviderSecretRefConfig("brave", "BRAVE_PROVIDER_REF");
const active = await runRuntimeWebTools({
config: sourceConfig,
env: { BRAVE_PROVIDER_REF: "brave-last-known-good" },
});
activateRuntimeWebToolsResult(sourceConfig, active);
const firstFailure = await runRuntimeWebTools({
config: sourceConfig,
allowUnavailableSecretOwners: true,
});
expect(readProviderKey(firstFailure.resolvedConfig, "brave")).toBe("brave-last-known-good");
expect(firstFailure.degradedOwners).toMatchObject([
{ ownerId: "web-search:brave", degradationState: "stale" },
]);
expect(firstFailure.secretOwners).toContainEqual(
expect.objectContaining({
ownerId: "web-search:brave",
resolvedValues: [
{ refKey: "env:default:BRAVE_PROVIDER_REF", value: "brave-last-known-good" },
],
}),
);
activateRuntimeWebToolsResult(sourceConfig, firstFailure);
const secondFailure = await runRuntimeWebTools({
config: sourceConfig,
allowUnavailableSecretOwners: true,
});
expect(readProviderKey(secondFailure.resolvedConfig, "brave")).toBe("brave-last-known-good");
expect(secondFailure.degradedOwners).toMatchObject([
{ ownerId: "web-search:brave", degradationState: "stale" },
]);
});
it("retains a stale web credential for a plugin id containing a dot", async () => {
const pluginId = "external.search";
const dottedProvider: PluginWebSearchProviderEntry = {
...createTestProvider({ provider: "brave", pluginId, order: 10 }),
id: "dotted",
};
resolvePluginWebSearchProvidersMock.mockReturnValue([dottedProvider]);
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({
[pluginId]: { source: "npm", spec: "@openclaw/external-search" },
});
resolveManifestContractOwnerPluginIdMock.mockReturnValue(undefined);
const sourceConfig = asConfig({
tools: { web: { search: { enabled: true, provider: "dotted" } } },
plugins: {
entries: {
[pluginId]: {
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "DOTTED_PROVIDER_REF" },
},
},
},
},
},
});
const readDottedKey = (config: OpenClawConfig) =>
(
config.plugins?.entries?.[pluginId]?.config as
| { webSearch?: { apiKey?: unknown } }
| undefined
)?.webSearch?.apiKey;
const active = await runRuntimeWebTools({
config: sourceConfig,
env: { DOTTED_PROVIDER_REF: "dotted-last-known-good" },
});
activateRuntimeWebToolsResult(sourceConfig, active);
const failed = await runRuntimeWebTools({
config: sourceConfig,
allowUnavailableSecretOwners: true,
});
expect(readDottedKey(failed.resolvedConfig)).toBe("dotted-last-known-good");
expect(failed.degradedOwners).toMatchObject([
{ ownerId: "web-search:dotted", degradationState: "stale" },
]);
});
it("does not reuse a web credential after its plugin routing config changes", async () => {
const pluginId = "external.search";
const provider: PluginWebSearchProviderEntry = {
...createTestProvider({ provider: "brave", pluginId, order: 10 }),
id: "external",
};
resolvePluginWebSearchProvidersMock.mockReturnValue([provider]);
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({
[pluginId]: { source: "npm", spec: "@openclaw/external-search" },
});
resolveManifestContractOwnerPluginIdMock.mockReturnValue(undefined);
const config = (baseUrl: string) =>
asConfig({
tools: { web: { search: { enabled: true, provider: "external" } } },
plugins: {
entries: {
[pluginId]: {
config: {
webSearch: {
baseUrl,
apiKey: { source: "env", provider: "default", id: "EXTERNAL_SEARCH_REF" },
},
},
},
},
},
});
const activeConfig = config("https://old.example.invalid/v1");
const active = await runRuntimeWebTools({
config: activeConfig,
env: { EXTERNAL_SEARCH_REF: "web-last-known-good" },
});
activateRuntimeWebToolsResult(activeConfig, active);
const failed = await runRuntimeWebTools({
config: config("https://new.example.invalid/v1"),
allowUnavailableSecretOwners: true,
});
expect(failed.degradedOwners).toMatchObject([
{ ownerId: "web-search:external", degradationState: "cold" },
]);
});
it("resolves selected provider SecretRef even when provider config is disabled", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
provider: "gemini",
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
enabled: false,
apiKey: {
source: "env",
provider: "default",
id: "WEB_SEARCH_GEMINI_API_KEY",
},
},
},
},
},
},
}),
env: {
WEB_SEARCH_GEMINI_API_KEY: "web-search-gemini-ref",
},
});
expect(metadata.search.providerConfigured).toBe("gemini");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("web-search-gemini-ref");
expect(context.warnings.map((warning) => warning.path)).not.toContain(
"plugins.entries.google.config.webSearch.apiKey",
);
});
it("auto-detects provider precedence across all configured providers", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "BRAVE_REF" } },
},
},
google: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "GEMINI_REF" } },
},
},
xai: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "GROK_REF" } },
},
},
moonshot: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "KIMI_REF" } },
},
},
perplexity: {
enabled: true,
config: {
webSearch: { apiKey: { source: "env", provider: "default", id: "PERPLEXITY_REF" } },
},
},
},
},
}),
env: {
BRAVE_REF: "brave-precedence-key",
GEMINI_REF: "gemini-precedence-key",
GROK_REF: "grok-precedence-key",
KIMI_REF: "kimi-precedence-key",
PERPLEXITY_REF: "pplx-precedence-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(readProviderKey(resolvedConfig, "brave")).toBe("brave-precedence-key");
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.google.config.webSearch.apiKey",
});
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.xai.config.webSearch.apiKey",
});
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.moonshot.config.webSearch.apiKey",
});
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.perplexity.config.webSearch.apiKey",
});
});
it("auto-detects first available provider and keeps lower-priority refs inactive", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "BRAVE_API_KEY_REF" },
},
},
},
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" },
},
},
},
},
},
}),
env: {
BRAVE_API_KEY_REF: "brave-runtime-key", // pragma: allowlist secret
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(readProviderKey(resolvedConfig, "brave")).toBe("brave-runtime-key");
expect(readProviderKey(resolvedConfig, "gemini")).toEqual({
source: "env",
provider: "default",
id: "MISSING_GEMINI_API_KEY_REF",
});
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.google.config.webSearch.apiKey",
});
expect(context.warnings.map((warning) => warning.code)).not.toContain(
"WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
);
});
it("auto-detects the next provider when a higher-priority ref is unresolved", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "MISSING_BRAVE_API_KEY_REF" },
},
},
},
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
},
},
},
},
},
}),
env: {
GEMINI_API_KEY_REF: "gemini-runtime-key", // pragma: allowlist secret
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-runtime-key");
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.brave.config.webSearch.apiKey",
});
expect(context.warnings.map((warning) => warning.code)).not.toContain(
"WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
);
});
it("isolates unresolved auto-detected providers during cold start", async () => {
const { metadata, degradedOwners } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: {
source: "env",
provider: "default",
id: "MISSING_GEMINI_API_KEY_REF",
},
},
},
},
},
},
}),
allowUnavailableSecretOwners: true,
});
expect(metadata.search.selectedProvider).toBeUndefined();
expect(degradedOwners).toMatchObject([
{
ownerKind: "capability",
ownerId: "web-search:gemini",
reason: "secret reference was not found",
},
]);
});
it("auto-detects Gemini from the Google model provider key after env fallbacks", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
models: {
providers: {
google: {
apiKey: "google-provider-runtime-key",
},
},
},
}),
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(metadata.search.selectedProviderKeySource).toBe("config");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("google-provider-runtime-key");
});
it("prefers GEMINI_API_KEY over the Google model provider key", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
models: {
providers: {
google: {
apiKey: "google-provider-runtime-key",
},
},
},
}),
env: {
GEMINI_API_KEY: "gemini-env-runtime-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(metadata.search.selectedProviderKeySource).toBe("env");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-env-runtime-key");
});
it("does not mirror provider env fallback over configured fallback SecretRefs", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
models: {
providers: {
google: {
apiKey: { source: "env", provider: "default", id: "GOOGLE_PROVIDER_REF" },
},
},
},
}),
env: {
GEMINI_API_KEY: "gemini-env-runtime-key",
GOOGLE_PROVIDER_REF: "google-provider-ref-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("gemini");
expect(metadata.search.selectedProviderKeySource).toBe("env");
expect(readProviderKey(resolvedConfig, "gemini")).toBe("gemini-env-runtime-key");
expect(resolvedConfig.models?.providers?.google?.apiKey).toBe("google-provider-ref-key");
});
it("keeps an invalid provider unselected without resolving another provider", async () => {
const { metadata, resolvedConfig, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
provider: "invalid-provider",
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY_REF" },
},
},
},
},
},
}),
env: {
GEMINI_API_KEY_REF: "gemini-runtime-key", // pragma: allowlist secret
},
});
expect(metadata.search.providerConfigured).toBeUndefined();
expect(metadata.search.providerSource).toBe("none");
expect(metadata.search.selectedProvider).toBeUndefined();
expect(readProviderKey(resolvedConfig, "gemini")).toEqual({
source: "env",
provider: "default",
id: "GEMINI_API_KEY_REF",
});
expectDiagnostic(metadata.search.diagnostics, {
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
path: "tools.web.search.provider",
});
expectDiagnostic(context.warnings, {
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
path: "tools.web.search.provider",
});
});
it("fails fast when configured provider ref is unresolved with no fallback", async () => {
const sourceConfig = asConfig({
tools: {
web: {
search: {
provider: "gemini",
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "MISSING_GEMINI_API_KEY_REF" },
},
},
},
},
},
});
const resolvedConfig = structuredClone(sourceConfig);
const context = createResolverContext({
sourceConfig,
env: {},
});
await expect(
resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
}),
).rejects.toThrow("[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK]");
expectDiagnostic(context.warnings, {
code: "WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK",
path: "plugins.entries.google.config.webSearch.apiKey",
});
});
it("uses bundled-only runtime provider resolution for configured bundled providers", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
provider: "gemini",
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GEMINI_PROVIDER_REF" },
},
},
},
},
},
}),
env: {
GEMINI_PROVIDER_REF: "gemini-provider-key",
},
});
expect(metadata.search.selectedProvider).toBe("gemini");
expect(resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock).toHaveBeenCalledWith({
onlyPluginIds: ["google"],
});
expect(resolveManifestContractOwnerPluginIdMock).not.toHaveBeenCalled();
expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
});
it("uses exact plugin-id hints for configured bundled provider entries without manifest owner lookup", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
provider: "brave",
},
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "BRAVE_PROVIDER_REF" },
},
},
},
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GOOGLE_PROVIDER_REF" },
},
},
},
},
},
}),
env: {
BRAVE_PROVIDER_REF: "brave-provider-key",
GOOGLE_PROVIDER_REF: "google-provider-key",
},
});
expect(metadata.search.selectedProvider).toBe("brave");
expect(resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock).toHaveBeenCalledWith({
onlyPluginIds: ["brave"],
});
expect(resolveManifestContractOwnerPluginIdMock).not.toHaveBeenCalled();
expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
});
it("uses single plugin-scoped web search config as a bundled provider hint", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GOOGLE_PROVIDER_REF" },
},
},
},
},
},
}),
env: {
GOOGLE_PROVIDER_REF: "google-provider-key",
},
});
expect(metadata.search.selectedProvider).toBe("gemini");
expect(resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock).toHaveBeenCalledWith({
onlyPluginIds: ["google"],
});
expect(resolveManifestContractOwnerPluginIdMock).not.toHaveBeenCalled();
expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
});
it("auto-detects Brave from legacy top-level web search apiKey", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "LEGACY_WEB_SEARCH_REF" },
},
},
},
}),
env: {
LEGACY_WEB_SEARCH_REF: "legacy-web-search-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(readProviderKey(resolvedConfig, "brave")).toBe("legacy-web-search-key");
expect(resolveManifestContractPluginIdsByCompatibilityRuntimePathMock).not.toHaveBeenCalled();
expect(resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
});
it("prefers legacy top-level web search apiKey over provider env fallback", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
apiKey: { source: "env", provider: "default", id: "LEGACY_WEB_SEARCH_REF" },
},
},
},
}),
env: {
BRAVE_API_KEY: "ambient-brave-key",
LEGACY_WEB_SEARCH_REF: "legacy-web-search-key",
},
});
expect(metadata.search.providerSource).toBe("auto-detect");
expect(metadata.search.selectedProvider).toBe("brave");
expect(metadata.search.selectedProviderKeySource).toBe("secretRef");
expect(readProviderKey(resolvedConfig, "brave")).toBe("legacy-web-search-key");
});
it("does not resolve web fetch provider SecretRef when web fetch is inactive", async () => {
const resolveSpy = vi.spyOn(secretResolve, "resolveSecretRefValues");
restoreResolveSecretRefValuesSpy = () => resolveSpy.mockRestore();
const { metadata, context } = await runRuntimeWebTools({
config: asConfig({
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: "MISSING_FIRECRAWL_REF" },
},
},
},
},
},
tools: {
web: {
fetch: {
enabled: false,
provider: "firecrawl",
},
},
},
}),
});
expect(resolveSpy).not.toHaveBeenCalled();
expect(metadata.fetch.selectedProvider).toBeUndefined();
expect(metadata.fetch.selectedProviderKeySource).toBeUndefined();
expect(context.warnings).toStrictEqual([]);
expect(resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolveBundledWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
});
it("keeps configured provider metadata and inactive warnings when search is disabled", async () => {
const { metadata, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: false,
provider: "gemini",
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "GEMINI_PROVIDER_REF" },
},
},
},
},
},
}),
});
expect(metadata.search.providerConfigured).toBe("gemini");
expect(metadata.search.providerSource).toBe("configured");
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.google.config.webSearch.apiKey",
});
});
it("emits inactive warnings for configured and lower-priority web-search providers when search is disabled", async () => {
const { context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: false,
apiKey: { source: "env", provider: "default", id: "DISABLED_WEB_SEARCH_API_KEY" },
},
},
},
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: {
source: "env",
provider: "default",
id: "DISABLED_WEB_SEARCH_GEMINI_API_KEY",
},
},
},
},
},
},
}),
});
expectDiagnostic(context.warnings, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: "plugins.entries.google.config.webSearch.apiKey",
});
});
it("does not auto-enable search when tools.web.search is absent", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({}),
});
expect(metadata.search.providerSource).toBe("none");
expect(metadata.search.selectedProvider).toBeUndefined();
});
it("skips provider discovery when no web surfaces are configured", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({}),
});
expect(metadata.search.providerSource).toBe("none");
expect(metadata.fetch.providerSource).toBe("none");
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
});
it("uses bundled public artifacts for bundled web search provider discovery", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
provider: "brave",
},
},
},
}),
env: {
BRAVE_API_KEY: "brave-key", // pragma: allowlist secret
},
});
expect(metadata.search.selectedProvider).toBe("brave");
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
});
it("uses runtime web search discovery when the managed plugin index install records is populated", async () => {
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({
"external-search": {
source: "npm",
spec: "@openclaw/external-search",
},
});
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
enabled: true,
},
},
},
}),
env: {
BRAVE_API_KEY: "brave-key", // pragma: allowlist secret
},
});
expect(metadata.search.selectedProvider).toBe("brave");
expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(firstMockArg(resolvePluginWebSearchProvidersMock).config).toBeDefined();
});
it("uses bundled public artifacts for bundled web fetch provider discovery", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-key", // pragma: allowlist secret
},
});
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
});
it("resolves SecretRefs for verified installed Firecrawl fetch config", async () => {
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({
firecrawl: {
source: "npm",
spec: "@openclaw/firecrawl-plugin",
},
});
resolveManifestContractOwnerPluginIdMock.mockReturnValueOnce(undefined);
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: {
source: "env",
provider: "default",
id: "FIRECRAWL_API_KEY",
},
},
},
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-config-key",
},
});
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(metadata.fetch.selectedProviderKeySource).toBe("secretRef");
expect(
(
resolvedConfig.plugins?.entries?.firecrawl?.config as
| { webFetch?: { apiKey?: unknown } }
| undefined
)?.webFetch?.apiKey,
).toBe("firecrawl-config-key");
expect(resolveBundledWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(firstMockArg(resolvePluginWebFetchProvidersMock).sandboxed).toBe(true);
});
it("isolates an explicit web fetch provider when its ref is unavailable", async () => {
const { metadata, resolvedConfig, context, degradedOwners } = await runRuntimeWebTools({
config: asConfig({
secrets: {
providers: {
default: { source: "file", path: "/missing/firecrawl-secrets.json" },
},
},
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "file", provider: "default", id: "/firecrawl/apiKey" },
},
},
},
},
},
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-fallback-key", // pragma: allowlist secret
},
allowUnavailableSecretOwners: true,
});
expect(metadata.fetch.providerConfigured).toBe("firecrawl");
expect(metadata.fetch.selectedProvider).toBeUndefined();
expect(metadata.fetch.selectedProviderKeySource).toBeUndefined();
expect(
(
resolvedConfig.plugins?.entries?.firecrawl?.config as
| { webFetch?: { apiKey?: unknown } }
| undefined
)?.webFetch?.apiKey,
).toEqual({ source: "file", provider: "default", id: "/firecrawl/apiKey" });
expect(degradedOwners).toContainEqual(
expect.objectContaining({
ownerKind: "capability",
ownerId: "web-fetch:firecrawl",
state: "unavailable",
paths: ["plugins.entries.firecrawl.config.webFetch.apiKey"],
reason: "secret provider failed",
}),
);
expect(degradedOwners[0]?.refKeys).toEqual(["file:default:/firecrawl/apiKey"]);
expect(degradedOwners[0]?.reason).not.toContain("/missing/firecrawl-secrets.json");
expectDiagnostic(context.warnings, {
code: "SECRETS_OWNER_UNAVAILABLE",
path: "plugins.entries.firecrawl.config.webFetch.apiKey",
});
});
it("fails fast on an invalid resolved value without exposing its ref", async () => {
const refId = "FIRECRAWL_API_KEY";
const resolveSpy = vi
.spyOn(secretResolve, "resolveSecretRefValues")
.mockResolvedValue(new Map([[`env:default:${refId}`, { value: "fixture-api-key" }]]));
restoreResolveSecretRefValuesSpy = () => resolveSpy.mockRestore();
const error = await runRuntimeWebTools({
config: asConfig({
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: refId },
},
},
},
},
},
tools: { web: { fetch: { provider: "firecrawl" } } },
}),
allowUnavailableSecretOwners: true,
}).then(
() => undefined,
(caught: unknown) => caught,
);
expect(error).toBeInstanceOf(Error);
const message = error instanceof Error ? error.message : String(error);
expect(message).toBe(
"plugins.entries.firecrawl.config.webFetch.apiKey resolved to a non-string or empty value.",
);
expect(message).not.toContain(refId);
expect(message).not.toContain("fixture-api-key");
expect(listSecretResolutionErrorOwners(error)).toEqual([
expect.objectContaining({
ownerKind: "capability",
ownerId: "web-fetch:firecrawl",
reason: "resolved secret value was invalid",
degradationState: "cold",
failureMatched: true,
source: "config",
}),
]);
});
it("attributes an invalid legacy x_search value to the Grok capability", async () => {
const refId = "X_SEARCH_KEY_REF";
const resolveSpy = vi
.spyOn(secretResolve, "resolveSecretRefValues")
.mockResolvedValue(new Map([[`env:default:${refId}`, { value: "fixture-api-key" }]]));
restoreResolveSecretRefValuesSpy = () => resolveSpy.mockRestore();
const error = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
x_search: {
apiKey: { source: "env", provider: "default", id: refId },
},
},
},
}),
}).catch((caught: unknown) => caught);
expect(listSecretResolutionErrorOwners(error)).toEqual([
expect.objectContaining({
ownerKind: "capability",
ownerId: "web-search:grok",
reason: "resolved secret value was invalid",
failureMatched: true,
}),
]);
});
it("rejects denied providers instead of restoring stale web credentials", async () => {
const refId = "FIRECRAWL_API_KEY";
const error = await runRuntimeWebTools({
config: asConfig({
secrets: {
providers: {
default: { source: "env", allowlist: ["OTHER_API_KEY"] },
},
},
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: refId },
},
},
},
},
},
tools: { web: { fetch: { provider: "firecrawl" } } },
}),
env: { [refId]: "fixture-api-key" },
allowUnavailableSecretOwners: true,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect(listSecretResolutionErrorOwners(error)).toEqual([
expect.objectContaining({
ownerKind: "capability",
ownerId: "web-fetch:firecrawl",
reason: "secret provider policy denied resolution",
failureMatched: true,
}),
]);
expect(String(error)).not.toContain("fixture-api-key");
});
it("resolves web fetch fallback SecretRefs with provider env var allowlist", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
plugins: {
entries: {
firecrawl: {
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY" },
},
},
},
},
},
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-search-ref-key",
},
});
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(metadata.fetch.selectedProviderKeySource).toBe("env");
expect(
(
resolvedConfig.plugins?.entries?.firecrawl?.config as
| { webFetch?: { apiKey?: unknown } }
| undefined
)?.webFetch?.apiKey,
).toBe("firecrawl-search-ref-key");
});
it("resolves plugin-owned web fetch SecretRefs without tools.web.fetch", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY" },
},
},
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-runtime-key",
},
});
expect(metadata.fetch.providerSource).toBe("auto-detect");
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(metadata.fetch.selectedProviderKeySource).toBe("secretRef");
expect(
(
resolvedConfig.plugins?.entries?.firecrawl?.config as
| { webFetch?: { apiKey?: unknown } }
| undefined
)?.webFetch?.apiKey,
).toBe("firecrawl-runtime-key");
});
it("resolves legacy Firecrawl web fetch SecretRefs through the plugin-owned path", async () => {
const { metadata, resolvedConfig } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
fetch: {
firecrawl: {
apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY" },
},
},
},
},
}),
env: {
FIRECRAWL_API_KEY: "firecrawl-legacy-key",
},
});
expect(metadata.fetch.providerSource).toBe("auto-detect");
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(metadata.fetch.selectedProviderKeySource).toBe("env");
expect(
(
resolvedConfig.plugins?.entries?.firecrawl?.config as
| { webFetch?: { apiKey?: unknown } }
| undefined
)?.webFetch?.apiKey,
).toBe("firecrawl-legacy-key");
});
it("fails fast when active web fetch provider SecretRef is unresolved with no fallback", async () => {
const sourceConfig = asConfig({
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: "FIRECRAWL_API_KEY" },
},
},
},
},
},
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
});
const resolvedConfig = structuredClone(sourceConfig);
const context = createResolverContext({
sourceConfig,
env: {},
});
await expect(
resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
}),
).rejects.toThrow("[WEB_FETCH_PROVIDER_KEY_UNRESOLVED_NO_FALLBACK]");
expectDiagnostic(context.warnings, {
code: "WEB_FETCH_PROVIDER_KEY_UNRESOLVED_NO_FALLBACK",
path: "plugins.entries.firecrawl.config.webFetch.apiKey",
});
});
it("rejects env SecretRefs for web fetch provider keys outside provider allowlists", async () => {
const sourceConfig = asConfig({
plugins: {
entries: {
firecrawl: {
config: {
webFetch: {
apiKey: { source: "env", provider: "default", id: "AWS_SECRET_ACCESS_KEY" },
},
},
},
},
},
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
});
const resolvedConfig = structuredClone(sourceConfig);
const context = createResolverContext({
sourceConfig,
env: {
AWS_SECRET_ACCESS_KEY: "not-allowed",
},
});
await expect(
resolveRuntimeWebTools({
sourceConfig,
resolvedConfig,
context,
allowUnavailableSecretOwners: true,
}),
).rejects.toThrow(
"plugins.entries.firecrawl.config.webFetch.apiKey SecretRef is not allowed for this provider.",
);
expect(context.warnings).toEqual([]);
});
it("keeps web fetch provider discovery bundled-only during runtime secret resolution", async () => {
const { metadata } = await runRuntimeWebTools({
config: asConfig({
plugins: {
load: {
paths: ["/tmp/malicious-plugin"],
},
entries: {
firecrawl: {
enabled: true,
config: {
webFetch: {
apiKey: "firecrawl-config-key",
},
},
},
},
},
tools: {
web: {
fetch: {
provider: "firecrawl",
},
},
},
}),
});
expect(metadata.fetch.selectedProvider).toBe("firecrawl");
expect(resolveBundledExplicitWebFetchProvidersFromPublicArtifactsMock).toHaveBeenCalledWith({
onlyPluginIds: ["firecrawl"],
});
expect(resolveBundledWebFetchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
expect(resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
});
describe("when brave is installed as an external plugin and explicitly configured", () => {
const externalBraveImpl = ({
value,
origin,
}: {
value: string;
origin?: string;
}): string | undefined => {
if (origin === "bundled" && value === "brave") {
return undefined;
}
return (
{
brave: "brave",
firecrawl: "firecrawl",
gemini: "google",
grok: "xai",
kimi: "moonshot",
perplexity: "perplexity",
} as Record<string, string | undefined>
)[value];
};
const defaultImpl = ({ value }: { value: string }): string | undefined =>
(
({
brave: "brave",
firecrawl: "firecrawl",
gemini: "google",
grok: "xai",
kimi: "moonshot",
perplexity: "perplexity",
}) as Record<string, string | undefined>
)[value];
beforeEach(() => {
loadInstalledPluginIndexInstallRecordsSyncMock.mockReturnValue({
brave: { source: "npm", spec: "@openclaw/brave-search" },
});
resolveManifestContractOwnerPluginIdMock.mockImplementation(externalBraveImpl);
});
afterEach(() => {
resolveManifestContractOwnerPluginIdMock.mockImplementation(defaultImpl);
});
it("selects the configured provider without re-invoking provider discovery when found in the first pass", async () => {
resolvePluginWebSearchProvidersMock
.mockReturnValueOnce(buildTestWebSearchProviders())
.mockReturnValueOnce([]);
const { metadata, context } = await runRuntimeWebTools({
config: asConfig({
tools: {
web: {
search: {
provider: "brave",
},
},
},
plugins: {
entries: {
brave: {
config: {
webSearch: {
apiKey: "brave-api-key", // pragma: allowlist secret
},
},
},
},
},
}),
});
expect(metadata.search.selectedProvider).toBe("brave");
expect(metadata.search.providerSource).toBe("configured");
expect(metadata.search.selectedProviderKeySource).toBe("config");
expectNoDiagnosticCode(context.warnings, "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT");
expect(resolvePluginWebSearchProvidersMock).toHaveBeenCalledTimes(1);
expect(
resolveBundledExplicitWebSearchProvidersFromPublicArtifactsMock,
).not.toHaveBeenCalled();
expect(resolveBundledWebSearchProvidersFromPublicArtifactsMock).not.toHaveBeenCalled();
});
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */