Compare commits

...

23 Commits

Author SHA1 Message Date
Tak Hoffman
4e9d290554 chore: refresh plugin import boundary baseline 2026-03-15 15:14:58 -05:00
Tak Hoffman
c87b8b549b build: enforce plugin import boundaries 2026-03-15 15:12:42 -05:00
Tak Hoffman
ab26781b13 fix: reopen configured search provider setup 2026-03-15 14:58:11 -05:00
Tak Hoffman
ead2968bd6 refactor: unify search provider install metadata 2026-03-15 14:49:47 -05:00
Tak Hoffman
d96601a8a2 refactor: complete search provider migration 2026-03-15 14:40:58 -05:00
Tak Hoffman
74b5c2e875 refactor: unify search provider onboarding metadata 2026-03-15 13:42:07 -05:00
Tak Hoffman
98c5c04608 fix: split search provider configure and switch flows 2026-03-15 12:17:49 -05:00
Tak Hoffman
f4ea5221df feat: improve pluggable web search onboarding 2026-03-14 13:00:52 -05:00
Tak Hoffman
8e5b535d48 refactor: isolate bundled search provider implementations 2026-03-14 12:53:29 -05:00
Tak Hoffman
80206bf20a feat: migrate core search providers to bundled plugins 2026-03-14 12:53:29 -05:00
Tak Hoffman
04769d7fe2 feat: treat bundled Tavily as a bundled provider 2026-03-14 12:52:48 -05:00
Tak Hoffman
e2b7c4c6a3 fix: harden provider plugin install and hook compatibility 2026-03-14 12:52:48 -05:00
Tak Hoffman
685f6c5132 fix: remove ineffective dynamic imports 2026-03-14 12:52:48 -05:00
Tak Hoffman
7603c30377 feat: polish web search provider install flow 2026-03-14 12:49:30 -05:00
Tak Hoffman
aecb2fc62d feat: simplify provider plugin install input 2026-03-14 12:49:30 -05:00
Tak Hoffman
5d0012471c feat: generalize plugin provider capabilities and hooks 2026-03-14 12:49:30 -05:00
Tak Hoffman
9cffd72953 feat: add search provider lifecycle hooks 2026-03-14 12:49:30 -05:00
Tak Hoffman
0997deb0e9 feat: add plugin capability slots and diagnostics 2026-03-14 12:49:30 -05:00
Tak Hoffman
034798b101 feat: refine pluggable web search configure flow 2026-03-14 12:48:33 -05:00
Tak Hoffman
8542194901 feat: extend pluggable web search onboarding 2026-03-14 12:47:52 -05:00
Tak Hoffman
667cc46f01 Add Tavily external search plugin 2026-03-14 12:44:44 -05:00
Tak Hoffman
3396e21d79 Preserve plugin web search config flows 2026-03-14 12:44:44 -05:00
Tak Hoffman
d7f5a6d308 Add pluggable web search providers 2026-03-14 12:42:56 -05:00
99 changed files with 20708 additions and 3117 deletions

View File

@ -28,6 +28,7 @@ When you touch tests or want extra confidence:
- Coverage gate: `pnpm test:coverage`
- E2E suite: `pnpm test:e2e`
- Targeted local repro: `pnpm test -- <path/to/test>` or `pnpm test:macmini -- <path/to/test>` on lower-memory hosts
When debugging real providers/models (requires real creds):

View File

@ -201,7 +201,7 @@ Notes:
- Where to host SDK types: separate package or core export?
- Runtime type distribution: in SDK (types only) or in core?
- How to expose docs links for bundled vs external plugins?
- How to expose docs links from one plugin-owned metadata path regardless of provenance?
- Do we allow limited direct core imports for in-repo plugins during transition?
## Success criteria

View File

@ -28,7 +28,7 @@ For local PR land/gate checks, run:
- `pnpm test`
- `pnpm check:docs`
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm test -- <path/to/test>` so the repo test wrapper still applies the intended config/profile logic. On lower-memory hosts, prefer `pnpm test:macmini -- <path/to/test>`. For memory-constrained full-suite runs, use:
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`

View File

@ -274,6 +274,13 @@ Compatibility note:
- New and migrated bundled plugins should use channel or extension-specific
subpaths; use `core` for generic surfaces and `compat` only when broader
shared helpers are required.
- Plugin code under `extensions/**` must not import OpenClaw core internals
directly. Allowed imports are:
- files inside the same extension
- `openclaw/plugin-sdk` and `openclaw/plugin-sdk/*`
- Node builtins and third-party packages
- Direct imports into `src/**`, `openclaw/src/**`, or another extension's source
tree are treated as plugin boundary violations and rejected by repo checks.
## Read-only channel inspection

View File

@ -80,6 +80,18 @@ describe("diffs plugin registration", () => {
registerHttpRoute(params: RegisteredHttpRouteParams) {
registeredHttpRouteHandler = params.handler;
},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerSearchProvider() {},
registerCommand() {},
registerContextEngine() {},
resolvePath(input: string) {
return input;
},
on() {},
});
plugin.register?.(api as unknown as OpenClawPluginApi);

View File

@ -1,4 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import {
readNumberParam,
readStringArrayParam,
@ -8,7 +9,6 @@ import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-a
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";

View File

@ -1,4 +1,5 @@
import type { Guild, User } from "@buape/carbon";
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js";
import {
buildChannelKeyCandidates,
@ -6,7 +7,6 @@ import {
resolveChannelMatchConfig,
type ChannelMatchSource,
} from "../../../../src/channels/channel-config.js";
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
import { formatDiscordUserTag } from "./format.js";
export type DiscordAllowList = {

View File

@ -1,7 +1,7 @@
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js";
import { danger } from "../../../../src/globals.js";
import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts";
import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js";
import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
import { processDiscordMessage } from "./message-handler.process.js";

View File

@ -1,13 +1,10 @@
import os from "node:os";
import path from "node:path";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
import { resolveStateDir } from "../../../../src/config/paths.js";
import { withFileLock } from "../../../../src/infra/file-lock.js";
import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js";
import {
readJsonFileWithFallback,
writeJsonFileAtomically,
} from "../../../../src/plugin-sdk/json-store.js";
import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js";
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {

View File

@ -1,4 +1,4 @@
import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js";
import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from";
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };

View File

@ -43,6 +43,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
registerCli() {},
registerService() {},
registerProvider() {},
registerSearchProvider() {},
registerHook() {},
registerHttpRoute() {},
registerCommand() {},

View File

@ -31,6 +31,17 @@ function createApi(params: {
writeConfigFile: (next: Record<string, unknown>) => params.writeConfig(next),
},
} as OpenClawPluginApi["runtime"],
logger: { info() {}, warn() {}, error() {} },
registerTool() {},
registerHook() {},
registerHttpRoute() {},
registerChannel() {},
registerGatewayMethod() {},
registerCli() {},
registerService() {},
registerProvider() {},
registerSearchProvider() {},
registerContextEngine() {},
registerCommand: params.registerCommand,
}) as OpenClawPluginApi;
}

View File

@ -0,0 +1,9 @@
{
"id": "search-brave",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}
},
"provides": ["providers.search.brave"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/search-brave",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Brave search plugin",
"type": "module",
"openclaw": {
"extensions": [
"./src/index.ts"
]
}
}

View File

@ -0,0 +1,13 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createBundledBraveSearchProvider } from "./provider.js";
const plugin = {
id: "search-brave",
name: "Brave Search",
description: "Bundled Brave web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledBraveSearchProvider());
},
};
export default plugin;

View File

@ -0,0 +1,606 @@
import { Type } from "@sinclair/typebox";
import {
CacheEntry,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
formatCliCommand,
normalizeCacheKey,
normalizeResolvedSecretInputString,
normalizeSecretInput,
readCache,
readResponseText,
resolveSearchConfig,
resolveSiteName,
type OpenClawConfig,
type SearchProviderContext,
type SearchProviderErrorResult,
type SearchProviderExecutionResult,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
type SearchProviderRequest,
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/web-search";
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
const BRAVE_SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const BRAVE_SEARCH_LANG_CODES = new Set([
"ar",
"eu",
"bn",
"bg",
"ca",
"zh-hans",
"zh-hant",
"hr",
"cs",
"da",
"nl",
"en",
"en-gb",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"gu",
"he",
"hi",
"hu",
"is",
"it",
"jp",
"kn",
"ko",
"lv",
"lt",
"ms",
"ml",
"mr",
"nb",
"pl",
"pt-br",
"pt-pt",
"pa",
"ro",
"ru",
"sr",
"sk",
"sl",
"es",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
]);
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
ja: "jp",
zh: "zh-hans",
"zh-cn": "zh-hans",
"zh-hk": "zh-hant",
"zh-sg": "zh-hans",
"zh-tw": "zh-hant",
};
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
const MAX_SEARCH_COUNT = 10;
type BraveSearchResult = {
title?: string;
url?: string;
description?: string;
age?: string;
};
type BraveSearchResponse = {
web?: {
results?: BraveSearchResult[];
};
};
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
};
type BraveConfig = {
mode?: string;
};
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
function resolveBraveConfig(search?: WebSearchConfig): BraveConfig {
if (!search || typeof search !== "object") {
return {};
}
const brave = "brave" in search ? search.brave : undefined;
return brave && typeof brave === "object" ? (brave as BraveConfig) : {};
}
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
return brave.mode === "llm-context" ? "llm-context" : "web";
}
function resolveBraveApiKey(search?: WebSearchConfig): string | undefined {
const fromConfigRaw = search
? normalizeResolvedSecretInputString({
value: search.apiKey,
path: "tools.web.search.apiKey",
})
: undefined;
const fromConfig = normalizeSecretInput(fromConfigRaw);
const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY);
return fromConfig || fromEnv || undefined;
}
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
return undefined;
}
return canonical;
}
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${language.toLowerCase()}-${region.toUpperCase()}`;
}
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = params.search_lang?.trim() || undefined;
const rawUiLang = params.ui_lang?.trim() || undefined;
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
function isValidIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
}
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return false;
}
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
);
}
function normalizeFreshness(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const lower = trimmed.toLowerCase();
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) {
return lower;
}
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
if (match) {
const [, start, end] = match;
if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) {
return `${start}to${end}`;
}
}
return undefined;
}
function buildBraveCacheIdentity(params: {
query: string;
count: number;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
braveMode: "web" | "llm-context";
}): string {
return [
params.query,
params.count,
params.country || "default",
params.search_lang || "default",
params.ui_lang || "default",
params.freshness || "default",
params.dateAfter || "default",
params.dateBefore || "default",
params.braveMode,
].join(":");
}
async function throwBraveApiError(res: Response, label: string): Promise<never> {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
const detail = detailResult.text;
throw new Error(`${label} API error (${res.status}): ${detail || res.statusText}`);
}
function mapBraveLlmContextResults(
data: BraveLlmContextResponse,
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
return genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
siteName: resolveSiteName(entry.url) || undefined,
}));
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
freshness?: string;
}) {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
return withTrustedWebToolsEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async ({ response }) => {
if (!response.ok) {
return await throwBraveApiError(response, "Brave LLM Context");
}
const data = (await response.json()) as BraveLlmContextResponse;
return { results: mapBraveLlmContextResults(data), sources: data.sources };
},
);
}
async function runBraveWebSearch(params: {
query: string;
count: number;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
}) {
const url = new URL(BRAVE_SEARCH_ENDPOINT);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.ui_lang) {
url.searchParams.set("ui_lang", params.ui_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
} else if (params.dateAfter && params.dateBefore) {
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
} else if (params.dateAfter) {
url.searchParams.set(
"freshness",
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
);
} else if (params.dateBefore) {
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
}
return withTrustedWebToolsEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async ({ response }) => {
if (!response.ok) {
return await throwBraveApiError(response, "Brave Search");
}
const data = (await response.json()) as BraveSearchResponse;
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
return results.map((entry) => {
const description = entry.description ?? "";
const title = entry.title ?? "";
const url = entry.url ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published: entry.age || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
);
}
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "brave",
label: "Brave Search",
hint: "Structured results · country/language/time filters",
envKeys: ["BRAVE_API_KEY"],
placeholder: "BSA...",
signupUrl: "https://brave.com/search/api/",
apiKeyConfigPath: "tools.web.search.apiKey",
autodetectPriority: 10,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
country: Type.Optional(Type.String()),
language: Type.Optional(Type.String()),
freshness: Type.Optional(Type.String()),
date_after: Type.Optional(Type.String()),
date_before: Type.Optional(Type.String()),
search_lang: Type.Optional(Type.String()),
ui_lang: Type.Optional(Type.String()),
}),
readApiKeyValue: (search) =>
search && typeof search === "object" && !Array.isArray(search) ? search.apiKey : undefined,
writeApiKeyValue: (search, value) => {
search.apiKey = value;
},
});
export function createBundledBraveSearchProvider(): SearchProviderPlugin {
return {
id: "brave",
name: "Brave Search",
description:
"Search the web using Brave Search. Supports web and llm-context modes, region-specific search, and localized search parameters.",
pluginOwnedExecution: true,
docsUrl: "https://brave.com/search/api/",
setup: BRAVE_SEARCH_PROVIDER_METADATA,
isAvailable: (config) => {
const search = config?.tools?.web?.search;
return Boolean(
resolveBraveApiKey(resolveSearchConfig<WebSearchConfig>(search as Record<string, unknown>)),
);
},
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
const braveConfig = resolveBraveConfig(search);
const braveMode = resolveBraveMode(braveConfig);
const apiKey = resolveBraveApiKey(search);
if (!apiKey) {
return createMissingSearchKeyPayload(
"missing_brave_api_key",
`web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
);
}
const normalizedLanguageParams = normalizeBraveLanguageParams({
search_lang: request.search_lang || request.language,
ui_lang: request.ui_lang,
});
if (normalizedLanguageParams.invalidField === "search_lang") {
return {
error: "invalid_search_lang",
message:
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguageParams.invalidField === "ui_lang") {
return {
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (normalizedLanguageParams.ui_lang && braveMode === "llm-context") {
return {
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (request.freshness && braveMode === "llm-context") {
return {
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const normalizedFreshness = request.freshness
? normalizeFreshness(request.freshness)
: undefined;
if (request.freshness && !normalizedFreshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if ((request.dateAfter || request.dateBefore) && braveMode === "llm-context") {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const cacheKey = normalizeCacheKey(
`brave:${buildBraveCacheIdentity({
query: request.query,
count: request.count,
country: request.country,
search_lang: normalizedLanguageParams.search_lang,
ui_lang: normalizedLanguageParams.ui_lang,
freshness: normalizedFreshness,
dateAfter: request.dateAfter,
dateBefore: request.dateBefore,
braveMode,
})}`,
);
const cached = readCache(BRAVE_SEARCH_CACHE, cacheKey);
if (cached) {
return { ...cached.value, cached: true } as Record<
string,
unknown
> as SearchProviderExecutionResult;
}
const startedAt = Date.now();
if (braveMode === "llm-context") {
const { results, sources } = await runBraveLlmContextSearch({
query: request.query,
apiKey,
timeoutSeconds: ctx.timeoutSeconds,
country: request.country,
search_lang: normalizedLanguageParams.search_lang,
freshness: normalizedFreshness,
});
const mappedResults = results.map(
(entry: { title: string; url: string; snippets: string[]; siteName?: string }) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((s: string) => wrapWebContent(s, "web_search")),
siteName: entry.siteName,
}),
);
const payload = {
query: request.query,
provider: "brave",
mode: "llm-context" as const,
count: mappedResults.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results: mappedResults,
sources,
};
writeCache(BRAVE_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
return payload as Record<string, unknown> as SearchProviderExecutionResult;
}
const results = await runBraveWebSearch({
query: request.query,
count: request.count,
apiKey,
timeoutSeconds: ctx.timeoutSeconds,
country: request.country,
search_lang: normalizedLanguageParams.search_lang,
ui_lang: normalizedLanguageParams.ui_lang,
freshness: normalizedFreshness,
dateAfter: request.dateAfter,
dateBefore: request.dateBefore,
});
const payload = {
query: request.query,
provider: "brave",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "brave",
wrapped: true,
},
results,
};
writeCache(BRAVE_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
return payload as Record<string, unknown> as SearchProviderExecutionResult;
},
};
}
export const __testing = {
resolveBraveApiKey,
resolveBraveMode,
normalizeBraveLanguageParams,
normalizeFreshness,
clearSearchProviderCaches() {
BRAVE_SEARCH_CACHE.clear();
},
};

View File

@ -0,0 +1,9 @@
{
"id": "search-gemini",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}
},
"provides": ["providers.search.gemini"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/search-gemini",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Gemini search plugin",
"type": "module",
"openclaw": {
"extensions": [
"./src/index.ts"
]
}
}

View File

@ -0,0 +1,13 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createBundledGeminiSearchProvider } from "./provider.js";
const plugin = {
id: "search-gemini",
name: "Gemini Search",
description: "Bundled Gemini web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledGeminiSearchProvider());
},
};
export default plugin;

View File

@ -0,0 +1,261 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
readCache,
readResponseText,
rejectUnsupportedSearchFilters,
resolveCitationRedirectUrl,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/web-search";
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; expiresAt: number }
>();
const MAX_SEARCH_COUNT = 10;
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
type GeminiConfig = {
apiKey?: string;
model?: string;
};
type GeminiGroundingResponse = {
candidates?: Array<{
content?: {
parts?: Array<{
text?: string;
}>;
};
groundingMetadata?: {
groundingChunks?: Array<{
web?: {
uri?: string;
title?: string;
};
}>;
};
}>;
error?: {
code?: number;
message?: string;
status?: string;
};
};
function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig {
return resolveSearchProviderSectionConfig<GeminiConfig>(
search as Record<string, unknown> | undefined,
"gemini",
);
}
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
return (
normalizeSecretInput(gemini?.apiKey) ||
normalizeSecretInput(process.env.GEMINI_API_KEY) ||
undefined
);
}
function resolveGeminiModel(gemini?: GeminiConfig): string {
const fromConfig =
gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : "";
return fromConfig || DEFAULT_GEMINI_MODEL;
}
async function runGeminiSearch(params: {
query: string;
apiKey: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
return withTrustedWebToolsEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": params.apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: params.query }] }],
tools: [{ google_search: {} }],
}),
},
},
async ({ response }) => {
if (!response.ok) {
const detailResult = await readResponseText(response, { maxBytes: 64_000 });
const safeDetail = (detailResult.text || response.statusText).replace(
/key=[^&\s]+/gi,
"key=***",
);
throw new Error(`Gemini API error (${response.status}): ${safeDetail}`);
}
let data: GeminiGroundingResponse;
try {
data = (await response.json()) as GeminiGroundingResponse;
} catch (err) {
const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***");
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err });
}
if (data.error) {
const rawMsg = data.error.message || data.error.status || "unknown";
const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***");
throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`);
}
const candidate = data.candidates?.[0];
const content =
candidate?.content?.parts
?.map((p) => p.text)
.filter(Boolean)
.join("\n") ?? "No response";
const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? [];
const rawCitations = groundingChunks
.filter((chunk) => chunk.web?.uri)
.map((chunk) => ({
url: chunk.web!.uri!,
title: chunk.web?.title || undefined,
}));
const citations: Array<{ url: string; title?: string }> = [];
const MAX_CONCURRENT_REDIRECTS = 10;
for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) {
const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS);
const resolved = await Promise.all(
batch.map(async (citation) => ({
...citation,
url: await resolveCitationRedirectUrl(citation.url),
})),
);
citations.push(...resolved);
}
return { content, citations };
},
);
}
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "gemini",
label: "Gemini (Google Search)",
hint: "Google Search grounding · AI-synthesized",
envKeys: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
autodetectPriority: 20,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
}),
});
export function createBundledGeminiSearchProvider(): SearchProviderPlugin {
return {
id: "gemini",
name: "Gemini Search",
description:
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
pluginOwnedExecution: true,
setup: GEMINI_SEARCH_PROVIDER_METADATA,
isAvailable: (config) =>
Boolean(
resolveGeminiApiKey(
resolveGeminiConfig(
resolveSearchConfig<WebSearchConfig>(
config?.tools?.web?.search as Record<string, unknown>,
),
),
),
),
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
const geminiConfig = resolveGeminiConfig(search);
const apiKey = resolveGeminiApiKey(geminiConfig);
if (!apiKey) {
return createMissingSearchKeyPayload(
"missing_gemini_api_key",
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
);
}
const unsupportedFilter = rejectUnsupportedSearchFilters({
providerName: "gemini",
request,
support: {
country: false,
language: false,
freshness: false,
date: false,
domainFilter: false,
},
});
if (unsupportedFilter) {
return unsupportedFilter;
}
const model = resolveGeminiModel(geminiConfig);
const cacheKey = normalizeCacheKey(
`gemini:${model}:${buildSearchRequestCacheIdentity({
query: request.query,
count: request.count,
})}`,
);
const cached = readCache(GEMINI_SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
const startedAt = Date.now();
const result = await runGeminiSearch({
query: request.query,
apiKey,
model,
timeoutSeconds: ctx.timeoutSeconds,
});
const payload = {
query: request.query,
provider: "gemini",
model,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "gemini",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
};
writeCache(GEMINI_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
return payload as SearchProviderExecutionResult;
},
};
}
export const __testing = {
GEMINI_SEARCH_CACHE,
clearSearchProviderCaches() {
GEMINI_SEARCH_CACHE.clear();
},
} as const;

View File

@ -0,0 +1,9 @@
{
"id": "search-grok",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}
},
"provides": ["providers.search.grok"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/search-grok",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Grok search plugin",
"type": "module",
"openclaw": {
"extensions": [
"./src/index.ts"
]
}
}

View File

@ -0,0 +1,13 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createBundledGrokSearchProvider } from "./provider.js";
const plugin = {
id: "search-grok",
name: "Grok Search",
description: "Bundled xAI Grok web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledGrokSearchProvider());
},
};
export default plugin;

View File

@ -0,0 +1,272 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
readCache,
rejectUnsupportedSearchFilters,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
throwWebSearchApiError,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/web-search";
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
const DEFAULT_GROK_MODEL = "grok-4-1-fast";
const GROK_SEARCH_CACHE = new Map<string, { value: Record<string, unknown>; expiresAt: number }>();
const MAX_SEARCH_COUNT = 10;
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
type GrokConfig = {
apiKey?: string;
model?: string;
inlineCitations?: boolean;
};
type GrokSearchResponse = {
output?: Array<{
type?: string;
role?: string;
text?: string;
content?: Array<{
type?: string;
text?: string;
annotations?: Array<{
type?: string;
url?: string;
start_index?: number;
end_index?: number;
}>;
}>;
annotations?: Array<{
type?: string;
url?: string;
start_index?: number;
end_index?: number;
}>;
}>;
output_text?: string;
citations?: string[];
inline_citations?: Array<{
start_index: number;
end_index: number;
url: string;
}>;
};
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
return resolveSearchProviderSectionConfig<GrokConfig>(
search as Record<string, unknown> | undefined,
"grok",
);
}
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
return (
normalizeSecretInput(grok?.apiKey) || normalizeSecretInput(process.env.XAI_API_KEY) || undefined
);
}
function resolveGrokModel(grok?: GrokConfig): string {
const fromConfig =
grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : "";
return fromConfig || DEFAULT_GROK_MODEL;
}
function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
return grok?.inlineCitations === true;
}
function extractGrokContent(data: GrokSearchResponse): {
text: string | undefined;
annotationCitations: string[];
} {
for (const output of data.output ?? []) {
if (output.type === "message") {
for (const block of output.content ?? []) {
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
const urls = (block.annotations ?? [])
.filter((a) => a.type === "url_citation" && typeof a.url === "string")
.map((a) => a.url as string);
return { text: block.text, annotationCitations: [...new Set(urls)] };
}
}
}
if (
output.type === "output_text" &&
"text" in output &&
typeof output.text === "string" &&
output.text
) {
const rawAnnotations =
"annotations" in output && Array.isArray(output.annotations) ? output.annotations : [];
const urls = rawAnnotations
.filter(
(a: Record<string, unknown>) => a.type === "url_citation" && typeof a.url === "string",
)
.map((a: Record<string, unknown>) => a.url as string);
return { text: output.text, annotationCitations: [...new Set(urls)] };
}
}
const text = typeof data.output_text === "string" ? data.output_text : undefined;
return { text, annotationCitations: [] };
}
async function runGrokSearch(params: {
query: string;
apiKey: string;
model: string;
timeoutSeconds: number;
inlineCitations: boolean;
}) {
const body: Record<string, unknown> = {
model: params.model,
input: [{ role: "user", content: params.query }],
tools: [{ type: "web_search" }],
};
return withTrustedWebToolsEndpoint(
{
url: XAI_API_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify(body),
},
},
async ({ response }) => {
if (!response.ok) {
return await throwWebSearchApiError(response, "xAI");
}
const data = (await response.json()) as GrokSearchResponse;
const { text: extractedText, annotationCitations } = extractGrokContent(data);
return {
content: extractedText ?? "No response",
citations: (data.citations ?? []).length > 0 ? data.citations! : annotationCitations,
inlineCitations: data.inline_citations,
};
},
);
}
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "grok",
label: "Grok (xAI)",
hint: "xAI web-grounded responses",
envKeys: ["XAI_API_KEY"],
placeholder: "xai-...",
signupUrl: "https://console.x.ai/",
apiKeyConfigPath: "tools.web.search.grok.apiKey",
autodetectPriority: 30,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
}),
});
export function createBundledGrokSearchProvider(): SearchProviderPlugin {
return {
id: "grok",
name: "xAI Grok",
description:
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
pluginOwnedExecution: true,
setup: GROK_SEARCH_PROVIDER_METADATA,
isAvailable: (config) =>
Boolean(
resolveGrokApiKey(
resolveGrokConfig(
resolveSearchConfig<WebSearchConfig>(
config?.tools?.web?.search as Record<string, unknown>,
),
),
),
),
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
const grokConfig = resolveGrokConfig(search);
const apiKey = resolveGrokApiKey(grokConfig);
if (!apiKey) {
return createMissingSearchKeyPayload(
"missing_xai_api_key",
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
);
}
const unsupportedFilter = rejectUnsupportedSearchFilters({
providerName: "grok",
request,
support: {
country: false,
language: false,
freshness: false,
date: false,
domainFilter: false,
},
});
if (unsupportedFilter) {
return unsupportedFilter;
}
const model = resolveGrokModel(grokConfig);
const inlineCitationsEnabled = resolveGrokInlineCitations(grokConfig);
const cacheKey = normalizeCacheKey(
`grok:${model}:${String(inlineCitationsEnabled)}:${buildSearchRequestCacheIdentity({
query: request.query,
count: request.count,
})}`,
);
const cached = readCache(GROK_SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
const startedAt = Date.now();
const result = await runGrokSearch({
query: request.query,
apiKey,
model,
timeoutSeconds: ctx.timeoutSeconds,
inlineCitations: inlineCitationsEnabled,
});
const payload = {
query: request.query,
provider: "grok",
model,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "grok",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
inlineCitations: result.inlineCitations,
};
writeCache(GROK_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
return payload as SearchProviderExecutionResult;
},
};
}
export const __testing = {
GROK_SEARCH_CACHE,
clearSearchProviderCaches() {
GROK_SEARCH_CACHE.clear();
},
} as const;

View File

@ -0,0 +1,9 @@
{
"id": "search-kimi",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}
},
"provides": ["providers.search.kimi"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/search-kimi",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Kimi search plugin",
"type": "module",
"openclaw": {
"extensions": [
"./src/index.ts"
]
}
}

View File

@ -0,0 +1,13 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createBundledKimiSearchProvider } from "./provider.js";
const plugin = {
id: "search-kimi",
name: "Kimi Search",
description: "Bundled Kimi web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledKimiSearchProvider());
},
};
export default plugin;

View File

@ -0,0 +1,323 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
normalizeCacheKey,
normalizeSecretInput,
readCache,
rejectUnsupportedSearchFilters,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/web-search";
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1";
const DEFAULT_KIMI_MODEL = "moonshot-v1-128k";
const KIMI_WEB_SEARCH_TOOL = {
type: "builtin_function",
function: { name: "$web_search" },
} as const;
const KIMI_SEARCH_CACHE = new Map<string, { value: Record<string, unknown>; expiresAt: number }>();
const MAX_SEARCH_COUNT = 10;
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
type KimiConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type KimiToolCall = {
id?: string;
type?: string;
function?: {
name?: string;
arguments?: string;
};
};
type KimiMessage = {
role?: string;
content?: string;
reasoning_content?: string;
tool_calls?: KimiToolCall[];
};
type KimiSearchResponse = {
choices?: Array<{
finish_reason?: string;
message?: KimiMessage;
}>;
search_results?: Array<{
title?: string;
url?: string;
content?: string;
}>;
};
function resolveKimiConfig(search?: WebSearchConfig): KimiConfig {
return resolveSearchProviderSectionConfig<KimiConfig>(
search as Record<string, unknown> | undefined,
"kimi",
);
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
return (
normalizeSecretInput(kimi?.apiKey) ||
normalizeSecretInput(process.env.KIMI_API_KEY) ||
normalizeSecretInput(process.env.MOONSHOT_API_KEY) ||
undefined
);
}
function resolveKimiModel(kimi?: KimiConfig): string {
const fromConfig =
kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : "";
return fromConfig || DEFAULT_KIMI_MODEL;
}
function resolveKimiBaseUrl(kimi?: KimiConfig): string {
const fromConfig =
kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : "";
return fromConfig || DEFAULT_KIMI_BASE_URL;
}
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
const content = message?.content?.trim();
if (content) return content;
const reasoning = message?.reasoning_content?.trim();
return reasoning || undefined;
}
function extractKimiCitations(data: KimiSearchResponse): string[] {
const citations = (data.search_results ?? [])
.map((entry) => entry.url?.trim())
.filter((url): url is string => Boolean(url));
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
const rawArguments = toolCall.function?.arguments;
if (!rawArguments) continue;
try {
const parsed = JSON.parse(rawArguments) as {
search_results?: Array<{ url?: string }>;
url?: string;
};
if (typeof parsed.url === "string" && parsed.url.trim()) citations.push(parsed.url.trim());
for (const result of parsed.search_results ?? []) {
if (typeof result.url === "string" && result.url.trim()) citations.push(result.url.trim());
}
} catch {
// ignore malformed tool arguments
}
}
return [...new Set(citations)];
}
function buildKimiToolResultContent(data: KimiSearchResponse): string {
return JSON.stringify({
search_results: (data.search_results ?? []).map((entry) => ({
title: entry.title ?? "",
url: entry.url ?? "",
content: entry.content ?? "",
})),
});
}
async function runKimiSearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
}) {
const baseUrl = params.baseUrl.trim().replace(/\/$/, "");
const endpoint = `${baseUrl}/chat/completions`;
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
const collectedCitations = new Set<string>();
const MAX_ROUNDS = 3;
for (let round = 0; round < MAX_ROUNDS; round += 1) {
const nextResult = await withTrustedWebToolsEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
messages,
tools: [KIMI_WEB_SEARCH_TOOL],
}),
},
},
async ({
response,
}): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`Kimi API error (${response.status}): ${detail || response.statusText}`);
}
const data = (await response.json()) as KimiSearchResponse;
for (const citation of extractKimiCitations(data)) {
collectedCitations.add(citation);
}
const choice = data.choices?.[0];
const message = choice?.message;
const text = extractKimiMessageText(message);
const toolCalls = message?.tool_calls ?? [];
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
}
messages.push({
role: "assistant",
content: message?.content ?? "",
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
tool_calls: toolCalls,
});
const toolContent = buildKimiToolResultContent(data);
let pushedToolResult = false;
for (const toolCall of toolCalls) {
const toolCallId = toolCall.id?.trim();
if (!toolCallId) continue;
pushedToolResult = true;
messages.push({
role: "tool",
tool_call_id: toolCallId,
content: toolContent,
});
}
if (!pushedToolResult) {
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
}
return { done: false };
},
);
if (nextResult.done) {
return { content: nextResult.content, citations: nextResult.citations };
}
}
return {
content: "Search completed but no final answer was produced.",
citations: [...collectedCitations],
};
}
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "kimi",
label: "Kimi (Moonshot)",
hint: "Moonshot web search",
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
placeholder: "sk-...",
signupUrl: "https://platform.moonshot.cn/",
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
autodetectPriority: 40,
requestSchema: Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
}),
});
export function createBundledKimiSearchProvider(): SearchProviderPlugin {
return {
id: "kimi",
name: "Kimi by Moonshot",
description:
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
pluginOwnedExecution: true,
setup: KIMI_SEARCH_PROVIDER_METADATA,
isAvailable: (config) =>
Boolean(
resolveKimiApiKey(
resolveKimiConfig(
resolveSearchConfig<WebSearchConfig>(
config?.tools?.web?.search as Record<string, unknown>,
),
),
),
),
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
const kimiConfig = resolveKimiConfig(search);
const apiKey = resolveKimiApiKey(kimiConfig);
if (!apiKey) {
return createMissingSearchKeyPayload(
"missing_kimi_api_key",
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
);
}
const unsupportedFilter = rejectUnsupportedSearchFilters({
providerName: "kimi",
request,
support: {
country: false,
language: false,
freshness: false,
date: false,
domainFilter: false,
},
});
if (unsupportedFilter) {
return unsupportedFilter;
}
const baseUrl = resolveKimiBaseUrl(kimiConfig);
const model = resolveKimiModel(kimiConfig);
const cacheKey = normalizeCacheKey(
`kimi:${baseUrl}:${model}:${buildSearchRequestCacheIdentity({
query: request.query,
count: request.count,
})}`,
);
const cached = readCache(KIMI_SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
const startedAt = Date.now();
const result = await runKimiSearch({
query: request.query,
apiKey,
baseUrl,
model,
timeoutSeconds: ctx.timeoutSeconds,
});
const payload = {
query: request.query,
provider: "kimi",
model,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "kimi",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
};
writeCache(KIMI_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
return payload as SearchProviderExecutionResult;
},
};
}
export const __testing = {
KIMI_SEARCH_CACHE,
clearSearchProviderCaches() {
KIMI_SEARCH_CACHE.clear();
},
} as const;

View File

@ -0,0 +1,9 @@
{
"id": "search-perplexity",
"defaultEnabledWhenBundled": true,
"configSchema": {
"type": "object",
"properties": {}
},
"provides": ["providers.search.perplexity"]
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/search-perplexity",
"version": "2026.3.12",
"private": true,
"description": "OpenClaw Perplexity search plugin",
"type": "module",
"openclaw": {
"extensions": [
"./src/index.ts"
]
}
}

View File

@ -0,0 +1,13 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createBundledPerplexitySearchProvider } from "./provider.js";
const plugin = {
id: "search-perplexity",
name: "Perplexity Search",
description: "Bundled Perplexity web search provider for OpenClaw.",
register(api: OpenClawPluginApi) {
api.registerSearchProvider(createBundledPerplexitySearchProvider());
},
};
export default plugin;

View File

@ -0,0 +1,662 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchRequestCacheIdentity,
createSearchProviderSetupMetadata,
createMissingSearchKeyPayload,
createSearchProviderErrorResult,
normalizeCacheKey,
normalizeDateInputToIso,
normalizeResolvedSecretInputString,
normalizeSecretInput,
readCache,
resolveSearchConfig,
resolveSearchProviderSectionConfig,
resolveSiteName,
throwWebSearchApiError,
type OpenClawConfig,
type SearchProviderExecutionResult,
type SearchProviderSetupMetadata,
type SearchProviderPlugin,
withTrustedWebToolsEndpoint,
wrapWebContent,
writeCache,
} from "openclaw/plugin-sdk/web-search";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]);
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const MAX_SEARCH_COUNT = 10;
const PERPLEXITY_SEARCH_CACHE = new Map<
string,
{ value: Record<string, unknown>; expiresAt: number }
>();
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
: undefined
: undefined;
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
type PerplexityTransport = "search_api" | "chat_completions";
type PerplexityBaseUrlHint = "direct" | "openrouter";
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
annotations?: Array<{
type?: string;
url?: string;
url_citation?: {
url?: string;
};
}>;
};
}>;
citations?: string[];
};
type PerplexitySearchApiResult = {
title?: string;
url?: string;
snippet?: string;
date?: string;
};
type PerplexitySearchApiResponse = {
results?: PerplexitySearchApiResult[];
};
function normalizeApiKey(key: unknown): string {
return normalizeSecretInput(key);
}
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
const normalizeUrl = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
const topLevel = (data.citations ?? [])
.map(normalizeUrl)
.filter((url): url is string => Boolean(url));
if (topLevel.length > 0) {
return [...new Set(topLevel)];
}
const citations: string[] = [];
for (const choice of data.choices ?? []) {
for (const annotation of choice.message?.annotations ?? []) {
if (annotation.type !== "url_citation") {
continue;
}
const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url);
if (url) {
citations.push(url);
}
}
}
return [...new Set(citations)];
}
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
return resolveSearchProviderSectionConfig<PerplexityConfig>(
search as Record<string, unknown> | undefined,
"perplexity",
);
}
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
apiKey?: string;
source: PerplexityApiKeySource;
} {
const fromConfig = normalizeApiKey(perplexity?.apiKey);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
}
const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY);
if (fromEnvPerplexity) {
return { apiKey: fromEnvPerplexity, source: "perplexity_env" };
}
const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY);
if (fromEnvOpenRouter) {
return { apiKey: fromEnvOpenRouter, source: "openrouter_env" };
}
return { apiKey: undefined, source: "none" };
}
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined {
if (!apiKey) {
return undefined;
}
const normalized = apiKey.toLowerCase();
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
authSource: PerplexityApiKeySource = "none",
configuredKey?: string,
): string {
const fromConfig =
perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
? perplexity.baseUrl.trim()
: "";
if (fromConfig) {
return fromConfig;
}
if (authSource === "perplexity_env") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (authSource === "openrouter_env") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
if (authSource === "config") {
const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey);
if (inferred === "openrouter") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
return PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const fromConfig =
perplexity && "model" in perplexity && typeof perplexity.model === "string"
? perplexity.model.trim()
: "";
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
}
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
const trimmed = baseUrl.trim();
if (!trimmed) {
return false;
}
try {
return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai";
} catch {
return false;
}
}
function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
if (!isDirectPerplexityBaseUrl(baseUrl)) {
return model;
}
return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model;
}
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
apiKey?: string;
source: PerplexityApiKeySource;
baseUrl: string;
model: string;
transport: PerplexityTransport;
} {
const auth = resolvePerplexityApiKey(perplexity);
const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey);
const model = resolvePerplexityModel(perplexity);
const hasLegacyOverride = Boolean(
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
(perplexity?.model && perplexity.model.trim()),
);
return {
...auth,
baseUrl,
model,
transport:
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
};
}
async function runPerplexitySearchApi(params: {
query: string;
apiKey: string;
count: number;
timeoutSeconds: number;
country?: string;
searchDomainFilter?: string[];
searchRecencyFilter?: string;
searchLanguageFilter?: string[];
searchAfterDate?: string;
searchBeforeDate?: string;
maxTokens?: number;
maxTokensPerPage?: number;
}) {
const body: Record<string, unknown> = { query: params.query, max_results: params.count };
if (params.country) body.country = params.country;
if (params.searchDomainFilter?.length) body.search_domain_filter = params.searchDomainFilter;
if (params.searchRecencyFilter) body.search_recency_filter = params.searchRecencyFilter;
if (params.searchLanguageFilter?.length)
body.search_language_filter = params.searchLanguageFilter;
if (params.searchAfterDate) body.search_after_date = params.searchAfterDate;
if (params.searchBeforeDate) body.search_before_date = params.searchBeforeDate;
if (params.maxTokens !== undefined) body.max_tokens = params.maxTokens;
if (params.maxTokensPerPage !== undefined) body.max_tokens_per_page = params.maxTokensPerPage;
return withTrustedWebToolsEndpoint(
{
url: PERPLEXITY_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw Web Search",
},
body: JSON.stringify(body),
},
},
async ({ response }) => {
if (!response.ok) {
return await throwWebSearchApiError(response, "Perplexity Search");
}
const data = (await response.json()) as PerplexitySearchApiResponse;
const results = Array.isArray(data.results) ? data.results : [];
return results.map((entry) => {
const title = entry.title ?? "";
const url = entry.url ?? "";
const snippet = entry.snippet ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: snippet ? wrapWebContent(snippet, "web_search") : "",
published: entry.date ?? undefined,
siteName: resolveSiteName(url) || undefined,
};
});
},
);
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
freshness?: string;
}) {
const baseUrl = params.baseUrl.trim().replace(/\/$/, "");
const endpoint = `${baseUrl}/chat/completions`;
const model = resolvePerplexityRequestModel(baseUrl, params.model);
const body: Record<string, unknown> = {
model,
messages: [{ role: "user", content: params.query }],
};
if (params.freshness) {
body.search_recency_filter = params.freshness;
}
return withTrustedWebToolsEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw Web Search",
},
body: JSON.stringify(body),
},
},
async ({ response }) => {
if (!response.ok) {
return await throwWebSearchApiError(response, "Perplexity");
}
const data = (await response.json()) as PerplexitySearchResponse;
return {
content: data.choices?.[0]?.message?.content ?? "No response",
citations: extractPerplexityCitations(data),
};
},
);
}
function isoToPerplexityDate(iso: string): string | undefined {
const match = iso.match(ISO_DATE_PATTERN);
if (!match) {
return undefined;
}
const [, year, month, day] = match;
return `${Number.parseInt(month, 10)}/${Number.parseInt(day, 10)}/${year}`;
}
function createPerplexityPayload(params: {
request: { query: string };
startedAt: number;
model?: string;
results?: unknown[];
content?: string;
citations?: string[];
}) {
const payload: Record<string, unknown> = {
query: params.request.query,
provider: "perplexity",
tookMs: Date.now() - params.startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
};
if (params.model) payload.model = params.model;
if (params.results) {
payload.results = params.results;
payload.count = params.results.length;
}
if (params.content) payload.content = wrapWebContent(params.content, "web_search");
if (params.citations) payload.citations = params.citations;
return payload;
}
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
createSearchProviderSetupMetadata({
provider: "perplexity",
label: "Perplexity Search",
hint: "Structured results · domain/country/language/time filters",
envKeys: ["PERPLEXITY_API_KEY"],
placeholder: "pplx-...",
signupUrl: "https://www.perplexity.ai/settings/api",
apiKeyConfigPath: "tools.web.search.perplexity.apiKey",
resolveRuntimeMetadata: (params) => ({
perplexityTransport: resolvePerplexityTransport(
resolvePerplexityConfig(resolveSearchConfig<WebSearchConfig>(params.search)),
).transport,
}),
autodetectPriority: 50,
resolveRequestSchema: (params) => {
const runtimeTransport =
params.runtimeMetadata && typeof params.runtimeMetadata.perplexityTransport === "string"
? params.runtimeMetadata.perplexityTransport
: undefined;
return runtimeTransport === "chat_completions"
? createPerplexityChatSchema()
: createPerplexitySearchApiSchema();
},
});
function createPerplexitySearchApiSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
country: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
}),
),
language: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
}),
),
date_after: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
}),
),
domain_filter: Type.Optional(
Type.Array(Type.String(), {
description:
"Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.",
}),
),
max_tokens: Type.Optional(
Type.Number({
description:
"Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).",
minimum: 1,
maximum: 1000000,
}),
),
max_tokens_per_page: Type.Optional(
Type.Number({
description:
"Native Perplexity Search API only. Max tokens extracted per page (default: 2048).",
minimum: 1,
}),
),
});
}
function createPerplexityChatSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
}),
),
});
}
export function createBundledPerplexitySearchProvider(): SearchProviderPlugin {
return {
id: "perplexity",
name: "Perplexity",
description:
"Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.",
pluginOwnedExecution: true,
setup: PERPLEXITY_SEARCH_PROVIDER_METADATA,
resolveRuntimeMetadata: PERPLEXITY_SEARCH_PROVIDER_METADATA.credentials?.resolveRuntimeMetadata,
isAvailable: (config) =>
Boolean(
resolvePerplexityApiKey(
resolvePerplexityConfig(
resolveSearchConfig<WebSearchConfig>(
config?.tools?.web?.search as Record<string, unknown>,
),
),
).apiKey,
),
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
const runtime = resolvePerplexityTransport(resolvePerplexityConfig(search));
if (!runtime.apiKey) {
return createMissingSearchKeyPayload(
"missing_perplexity_api_key",
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
);
}
const supportsStructured = runtime.transport === "search_api";
if (request.country && !supportsStructured) {
return createSearchProviderErrorResult(
"unsupported_country",
"country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
);
}
if (request.language && !supportsStructured) {
return createSearchProviderErrorResult(
"unsupported_language",
"language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
);
}
if (request.language && !/^[a-z]{2}$/i.test(request.language)) {
return createSearchProviderErrorResult(
"invalid_language",
"language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.",
);
}
const normalizedFreshness = request.freshness
? PERPLEXITY_RECENCY_VALUES.has(request.freshness.trim().toLowerCase())
? request.freshness.trim().toLowerCase()
: undefined
: undefined;
if (request.freshness && !normalizedFreshness) {
return createSearchProviderErrorResult(
"invalid_freshness",
"freshness must be day, week, month, or year.",
);
}
if ((request.dateAfter || request.dateBefore) && !supportsStructured) {
return createSearchProviderErrorResult(
"unsupported_date_filter",
"date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
);
}
if (request.domainFilter && request.domainFilter.length > 0 && !supportsStructured) {
return createSearchProviderErrorResult(
"unsupported_domain_filter",
"domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
);
}
if (request.domainFilter && request.domainFilter.length > 0) {
const hasDenylist = request.domainFilter.some((domain) => domain.startsWith("-"));
const hasAllowlist = request.domainFilter.some((domain) => !domain.startsWith("-"));
if (hasDenylist && hasAllowlist) {
return createSearchProviderErrorResult(
"invalid_domain_filter",
"domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).",
);
}
if (request.domainFilter.length > 20) {
return createSearchProviderErrorResult(
"invalid_domain_filter",
"domain_filter supports a maximum of 20 domains.",
);
}
}
if (
runtime.transport === "chat_completions" &&
(request.maxTokens !== undefined || request.maxTokensPerPage !== undefined)
) {
return createSearchProviderErrorResult(
"unsupported_content_budget",
"max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
);
}
if (request.dateAfter && !normalizeDateInputToIso(request.dateAfter)) {
return createSearchProviderErrorResult(
"invalid_date_after",
"date_after must be a valid YYYY-MM-DD date.",
);
}
if (request.dateBefore && !normalizeDateInputToIso(request.dateBefore)) {
return createSearchProviderErrorResult(
"invalid_date_before",
"date_before must be a valid YYYY-MM-DD date.",
);
}
const cacheKey = normalizeCacheKey(
`perplexity:${runtime.transport}:${runtime.baseUrl}:${runtime.model}:${buildSearchRequestCacheIdentity(
{
query: request.query,
count: request.count,
country: request.country,
language: request.language,
freshness: normalizedFreshness,
dateAfter: request.dateAfter,
dateBefore: request.dateBefore,
domainFilter: request.domainFilter,
maxTokens: request.maxTokens,
maxTokensPerPage: request.maxTokensPerPage,
},
)}`,
);
const cached = readCache(PERPLEXITY_SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
const startedAt = Date.now();
let payload: Record<string, unknown>;
if (runtime.transport === "chat_completions") {
const result = await runPerplexitySearch({
query: request.query,
apiKey: runtime.apiKey,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds: ctx.timeoutSeconds,
freshness: normalizedFreshness,
});
payload = createPerplexityPayload({
request,
startedAt,
model: runtime.model,
content: result.content,
citations: result.citations,
});
} else {
const results = await runPerplexitySearchApi({
query: request.query,
apiKey: runtime.apiKey,
count: request.count,
timeoutSeconds: ctx.timeoutSeconds,
country: request.country,
searchDomainFilter: request.domainFilter,
searchRecencyFilter: normalizedFreshness,
searchLanguageFilter: request.language ? [request.language] : undefined,
searchAfterDate: request.dateAfter ? isoToPerplexityDate(request.dateAfter) : undefined,
searchBeforeDate: request.dateBefore
? isoToPerplexityDate(request.dateBefore)
: undefined,
maxTokens: request.maxTokens,
maxTokensPerPage: request.maxTokensPerPage,
});
payload = createPerplexityPayload({ request, startedAt, results });
}
writeCache(PERPLEXITY_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
return payload as SearchProviderExecutionResult;
},
};
}
export const __testing = {
PERPLEXITY_SEARCH_CACHE,
clearSearchProviderCaches() {
PERPLEXITY_SEARCH_CACHE.clear();
},
} as const;

View File

@ -1,4 +1,4 @@
import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import { normalizeE164 } from "../../../src/utils.js";
export type SignalSender =

View File

@ -1,9 +1,9 @@
import type { WebClient as SlackWebClient } from "@slack/web-api";
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
import { normalizeHostname } from "../../../../src/infra/net/hostname.js";
import type { FetchLike } from "../../../../src/media/fetch.js";
import { fetchRemoteMedia } from "../../../../src/media/fetch.js";
import { saveMediaBuffer } from "../../../../src/media/store.js";
import { resolveRequestUrl } from "../../../../src/plugin-sdk/request-url.js";
import type { SlackAttachment, SlackFile } from "../types.js";
function isSlackHostname(hostname: string): boolean {

View File

@ -1,4 +1,4 @@
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
export function isSlackChannelAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";

View File

@ -0,0 +1,138 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import plugin from "./index.js";
function createApi(params?: {
config?: OpenClawPluginApi["config"];
pluginConfig?: Record<string, unknown>;
}) {
let registeredProvider: Parameters<OpenClawPluginApi["registerSearchProvider"]>[0] | undefined;
const api = {
id: "tavily-search",
name: "Tavily Search",
source: "/tmp/tavily-search/index.ts",
config: params?.config ?? {},
pluginConfig: params?.pluginConfig,
runtime: {} as never,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
registerSearchProvider: vi.fn((provider) => {
registeredProvider = provider;
}),
} as unknown as OpenClawPluginApi;
plugin.register?.(api);
if (!registeredProvider) {
throw new Error("search provider was not registered");
}
return registeredProvider;
}
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe("tavily-search plugin", () => {
it("registers a tavily search provider and detects availability from plugin config", () => {
const provider = createApi({
config: {
plugins: {
entries: {
"tavily-search": {
config: {
apiKey: "tvly-test-key",
},
},
},
},
},
});
expect(provider.id).toBe("tavily");
expect(provider.isAvailable?.({})).toBe(false);
expect(
provider.isAvailable?.({
plugins: {
entries: {
"tavily-search": {
config: {
apiKey: "tvly-test-key",
},
},
},
},
}),
).toBe(true);
});
it("maps Tavily responses into plugin search results", async () => {
const provider = createApi({
pluginConfig: {
apiKey: "tvly-test-key",
searchDepth: "advanced",
},
});
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
answer: "Tavily says hello",
results: [
{
title: "Example",
url: "https://example.com/article",
content: "Snippet",
published_date: "2026-03-10",
},
],
}),
}));
vi.stubGlobal("fetch", fetchMock);
const result = await provider.search(
{
query: "hello",
count: 3,
country: "US",
freshness: "week",
},
{
config: {},
timeoutSeconds: 5,
cacheTtlMs: 1000,
pluginConfig: {
apiKey: "tvly-test-key",
searchDepth: "advanced",
},
},
);
expect(fetchMock).toHaveBeenCalled();
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(JSON.parse(String(init.body))).toMatchObject({
api_key: "tvly-test-key",
query: "hello",
max_results: 3,
search_depth: "advanced",
topic: "news",
days: 7,
country: "US",
});
expect(result).toEqual({
content: "Tavily says hello",
citations: ["https://example.com/article"],
results: [
{
title: "Example",
url: "https://example.com/article",
description: "Snippet",
published: "2026-03-10",
},
],
});
});
});

View File

@ -0,0 +1,197 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { createSearchProviderSetupMetadata } from "openclaw/plugin-sdk/web-search";
const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search";
const MAX_SEARCH_COUNT = 10;
type TavilyPluginConfig = {
apiKey?: string;
searchDepth?: "basic" | "advanced";
};
type TavilySearchResult = {
title?: string;
url?: string;
content?: string;
published_date?: string;
};
type TavilySearchResponse = {
answer?: string;
results?: TavilySearchResult[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function resolvePluginConfig(value: unknown): TavilyPluginConfig {
if (!isRecord(value)) {
return {};
}
return value as TavilyPluginConfig;
}
function resolveRootPluginConfig(
config: OpenClawPluginApi["config"],
pluginId: string,
): TavilyPluginConfig {
return resolvePluginConfig(config?.plugins?.entries?.[pluginId]?.config);
}
function resolveApiKey(config: TavilyPluginConfig): string | undefined {
return normalizeString(config.apiKey) ?? normalizeString(process.env.TAVILY_API_KEY);
}
function resolveSearchDepth(config: TavilyPluginConfig): "basic" | "advanced" {
return config.searchDepth === "advanced" ? "advanced" : "basic";
}
function resolveFreshnessDays(freshness?: string): number | undefined {
const normalized = normalizeString(freshness)?.toLowerCase();
if (normalized === "day") {
return 1;
}
if (normalized === "week") {
return 7;
}
if (normalized === "month") {
return 30;
}
if (normalized === "year") {
return 365;
}
return undefined;
}
const plugin = {
id: "tavily-search",
name: "Tavily Search",
description: "Tavily web_search plugin",
register(api: OpenClawPluginApi) {
api.registerSearchProvider({
id: "tavily",
name: "Tavily Search",
description:
"Search the web using Tavily. Returns structured results and an AI-synthesized answer when available.",
docsUrl: "https://docs.tavily.com/",
configFieldOrder: ["apiKey", "searchDepth"],
setup: createSearchProviderSetupMetadata({
provider: "tavily",
label: "Tavily Search",
hint: "Plugin search with structured results and optional AI answer synthesis.",
envKeys: ["TAVILY_API_KEY"],
placeholder: "tvly-...",
signupUrl: "https://app.tavily.com/home",
apiKeyConfigPath: "plugins.entries.tavily-search.config.apiKey",
install: {
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
requestSchema: Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(
Type.String({
description: "Optional 2-letter country code for region-specific results.",
}),
),
freshness: Type.Optional(
Type.String({
description: "Filter by time: 'day', 'week', 'month', or 'year'.",
}),
),
},
{ additionalProperties: true },
),
}),
isAvailable: (config) =>
Boolean(resolveApiKey(resolveRootPluginConfig(config ?? {}, api.id))),
search: async (params, ctx) => {
const pluginConfig = resolvePluginConfig(ctx.pluginConfig);
const apiKey = resolveApiKey(pluginConfig);
if (!apiKey) {
return {
error: "missing_tavily_api_key",
message:
"Tavily search provider needs an API key. Set plugins.entries.tavily-search.config.apiKey or TAVILY_API_KEY in the Gateway environment.",
};
}
const freshnessDays = resolveFreshnessDays(params.freshness);
const body: Record<string, unknown> = {
api_key: apiKey,
query: params.query,
max_results: params.count,
search_depth: resolveSearchDepth(pluginConfig),
include_answer: true,
include_raw_content: false,
topic: freshnessDays ? "news" : "general",
};
if (freshnessDays !== undefined) {
body.days = freshnessDays;
}
if (params.country) {
body.country = params.country;
}
const response = await fetch(TAVILY_SEARCH_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(Math.max(ctx.timeoutSeconds, 1) * 1000),
});
if (!response.ok) {
const detail = await response.text();
return {
error: "search_failed",
message: `Tavily search failed (${response.status}): ${detail || response.statusText}`,
};
}
const data = (await response.json()) as TavilySearchResponse;
const results = Array.isArray(data.results) ? data.results : [];
return {
content: normalizeString(data.answer),
citations: results
.map((entry) => normalizeString(entry.url))
.filter((entry): entry is string => Boolean(entry)),
results: results
.map((entry) => {
const url = normalizeString(entry.url);
if (!url) {
return undefined;
}
return {
url,
title: normalizeString(entry.title),
description: normalizeString(entry.content),
published: normalizeString(entry.published_date),
};
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
};
},
});
},
};
export default plugin;

View File

@ -0,0 +1,34 @@
{
"id": "tavily-search",
"name": "Tavily Search",
"description": "Search the web using Tavily.",
"provides": ["providers.search.tavily"],
"uiHints": {
"apiKey": {
"label": "Tavily API key",
"placeholder": "tvly-...",
"help": "API key for Tavily web search",
"sensitive": true
},
"searchDepth": {
"label": "Search depth",
"help": "Use advanced for deeper search at higher cost"
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string",
"minLength": 1,
"pattern": "^tvly-\\S+$"
},
"searchDepth": {
"type": "string",
"enum": ["basic", "advanced"]
}
},
"required": ["apiKey"]
}
}

View File

@ -0,0 +1,16 @@
{
"name": "@openclaw/tavily-search",
"version": "2026.3.9",
"description": "OpenClaw Tavily search plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"npmSpec": "@openclaw/tavily-search",
"localPath": "extensions/tavily-search",
"defaultChoice": "local"
}
}
}

View File

@ -1,3 +1,4 @@
import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "../../../src/config/config.js";
import {
coerceSecretRef,
@ -6,7 +7,6 @@ import {
} from "../../../src/config/types.secrets.js";
import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js";
import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js";
import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js";
import {

View File

@ -1,13 +1,13 @@
import util from "node:util";
import {
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
resolveAccountWithDefaultFallback,
} from "openclaw/plugin-sdk/account-resolution";
import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js";
import { isTruthyEnvValue } from "../../../src/infra/env.js";
import { createSubsystemLogger } from "../../../src/logging/subsystem.js";
import {
listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
resolveAccountWithDefaultFallback,
} from "../../../src/plugin-sdk/account-resolution.js";
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
import {
listBoundAccountIds,

View File

@ -1,3 +1,5 @@
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import {
readNumberParam,
readStringArrayParam,
@ -15,8 +17,6 @@ import type {
ChannelMessageActionName,
} from "../../../src/channels/plugins/types.js";
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js";
import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js";
import { resolveTelegramPollVisibility } from "../../../src/poll-params.js";
import {
createTelegramActionGate,

View File

@ -1,3 +1,4 @@
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
@ -7,7 +8,6 @@ import type {
TelegramGroupConfig,
TelegramTopicConfig,
} from "../../../src/config/types.js";
import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
import { firstDefined } from "./bot-access.js";

View File

@ -44,6 +44,10 @@
"types": "./dist/plugin-sdk/core.d.ts",
"default": "./dist/plugin-sdk/core.js"
},
"./plugin-sdk/web-search": {
"types": "./dist/plugin-sdk/web-search.d.ts",
"default": "./dist/plugin-sdk/web-search.js"
},
"./plugin-sdk/compat": {
"types": "./dist/plugin-sdk/compat.d.ts",
"default": "./dist/plugin-sdk/compat.js"
@ -208,10 +212,38 @@
"types": "./dist/plugin-sdk/account-id.d.ts",
"default": "./dist/plugin-sdk/account-id.js"
},
"./plugin-sdk/account-resolution": {
"types": "./dist/plugin-sdk/account-resolution.d.ts",
"default": "./dist/plugin-sdk/account-resolution.js"
},
"./plugin-sdk/allow-from": {
"types": "./dist/plugin-sdk/allow-from.d.ts",
"default": "./dist/plugin-sdk/allow-from.js"
},
"./plugin-sdk/boolean-param": {
"types": "./dist/plugin-sdk/boolean-param.d.ts",
"default": "./dist/plugin-sdk/boolean-param.js"
},
"./plugin-sdk/group-access": {
"types": "./dist/plugin-sdk/group-access.d.ts",
"default": "./dist/plugin-sdk/group-access.js"
},
"./plugin-sdk/json-store": {
"types": "./dist/plugin-sdk/json-store.d.ts",
"default": "./dist/plugin-sdk/json-store.js"
},
"./plugin-sdk/keyed-async-queue": {
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
"default": "./dist/plugin-sdk/keyed-async-queue.js"
},
"./plugin-sdk/request-url": {
"types": "./dist/plugin-sdk/request-url.d.ts",
"default": "./dist/plugin-sdk/request-url.js"
},
"./plugin-sdk/tool-send": {
"types": "./dist/plugin-sdk/tool-send.d.ts",
"default": "./dist/plugin-sdk/tool-send.js"
},
"./cli-entry": "./openclaw.mjs"
},
"scripts": {
@ -270,7 +302,7 @@
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"ios:run": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
"lint": "oxlint --type-aware",
"lint": "oxlint --type-aware && pnpm lint:plugins:import-boundaries",
"lint:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs",
"lint:all": "pnpm lint && pnpm lint:swift",
"lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs",
@ -278,6 +310,7 @@
"lint:docs": "pnpm dlx markdownlint-cli2",
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
"lint:fix": "oxlint --type-aware --fix && pnpm format",
"lint:plugins:import-boundaries": "node --import tsx scripts/check-plugin-import-boundaries.ts",
"lint:plugins:no-monolithic-plugin-sdk-entry-imports": "node --import tsx scripts/check-no-monolithic-plugin-sdk-entry-imports.ts",
"lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",

12
pnpm-lock.yaml generated
View File

@ -456,6 +456,16 @@ importers:
extensions/sglang: {}
extensions/search-brave: {}
extensions/search-gemini: {}
extensions/search-grok: {}
extensions/search-kimi: {}
extensions/search-perplexity: {}
extensions/signal: {}
extensions/slack: {}
@ -466,6 +476,8 @@ importers:
specifier: ^4.3.6
version: 4.3.6
extensions/tavily-search: {}
extensions/telegram: {}
extensions/tlon:

View File

@ -0,0 +1,66 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { findPluginImportBoundaryViolations } from "./check-plugin-import-boundaries.ts";
const repoRoot = "/Users/thoffman/openclaw";
function extensionFile(relativePath: string): string {
return path.join(repoRoot, relativePath);
}
describe("findPluginImportBoundaryViolations", () => {
it("allows same-extension relative imports", () => {
const violations = findPluginImportBoundaryViolations(
'import { helper } from "../shared/helper.js";',
extensionFile("extensions/demo/src/feature/index.ts"),
);
expect(violations).toEqual([]);
});
it("allows plugin-sdk imports", () => {
const violations = findPluginImportBoundaryViolations(
'import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";',
extensionFile("extensions/demo/src/feature/index.ts"),
);
expect(violations).toEqual([]);
});
it("rejects direct core imports", () => {
const violations = findPluginImportBoundaryViolations(
'import { loadConfig } from "../../../src/config/config.js";',
extensionFile("extensions/demo/src/feature/index.ts"),
);
expect(violations).toEqual([
expect.objectContaining({
reason: "relative_escape",
specifier: "../../../src/config/config.js",
}),
]);
});
it("rejects cross-extension source imports", () => {
const violations = findPluginImportBoundaryViolations(
'import { helper } from "../../other-plugin/src/helper.js";',
extensionFile("extensions/demo/src/feature/index.ts"),
);
expect(violations).toEqual([
expect.objectContaining({
reason: "cross_extension_import",
specifier: "../../other-plugin/src/helper.js",
}),
]);
});
it("rejects host-internal bare imports outside the SDK", () => {
const violations = findPluginImportBoundaryViolations(
'import { loadConfig } from "openclaw/src/config/config.js";',
extensionFile("extensions/demo/src/feature/index.ts"),
);
expect(violations).toEqual([
expect.objectContaining({
reason: "core_internal_import",
specifier: "openclaw/src/config/config.js",
}),
]);
});
});

View File

@ -0,0 +1,289 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { runAsScript, toLine } from "./lib/ts-guard-utils.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const extensionsRoot = path.join(repoRoot, "extensions");
const baselinePath = path.join(repoRoot, "scripts", "plugin-import-boundaries.baseline.json");
const codeFileRe = /\.(?:[cm]?[jt]s|tsx|jsx)$/u;
const ignoredDirNames = new Set(["node_modules", "dist", "coverage", ".git"]);
const nodeBuiltinSpecifiers = new Set([
"assert",
"buffer",
"child_process",
"crypto",
"events",
"fs",
"http",
"https",
"net",
"os",
"path",
"stream",
"timers",
"tty",
"url",
"util",
"zlib",
]);
type ViolationReason =
| "relative_escape"
| "absolute_import"
| "core_internal_import"
| "cross_extension_import";
export type PluginImportBoundaryViolation = {
path: string;
line: number;
specifier: string;
reason: ViolationReason;
};
function isCodeFile(filePath: string): boolean {
return codeFileRe.test(filePath) && !filePath.endsWith(".d.ts");
}
async function collectExtensionCodeFiles(rootDir: string): Promise<string[]> {
const out: string[] = [];
const stack = [rootDir];
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
if (ignoredDirNames.has(entry.name)) {
continue;
}
stack.push(fullPath);
continue;
}
if (entry.isFile() && isCodeFile(fullPath)) {
out.push(fullPath);
}
}
}
return out;
}
function getExtensionRoot(filePath: string): string | null {
const relative = path.relative(extensionsRoot, filePath);
if (relative.startsWith("..")) {
return null;
}
const [extensionId] = relative.split(path.sep);
return extensionId ? path.join(extensionsRoot, extensionId) : null;
}
function normalizeSpecifier(specifier: string): string {
return specifier.replaceAll("\\", "/");
}
function isNodeBuiltin(specifier: string): boolean {
return specifier.startsWith("node:") || nodeBuiltinSpecifiers.has(specifier);
}
function isAllowedBareSpecifier(specifier: string): boolean {
if (isNodeBuiltin(specifier)) {
return true;
}
if (specifier === "openclaw/plugin-sdk" || specifier.startsWith("openclaw/plugin-sdk/")) {
return true;
}
return (
!specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("openclaw/")
);
}
function classifySpecifier(params: { importerPath: string; specifier: string }): {
reason?: ViolationReason;
} {
const specifier = normalizeSpecifier(params.specifier);
if (specifier === "") {
return {};
}
if (isAllowedBareSpecifier(specifier)) {
return {};
}
if (specifier.startsWith("openclaw/src/") || specifier === "openclaw/src") {
return { reason: "core_internal_import" };
}
if (specifier.startsWith("/")) {
return { reason: "absolute_import" };
}
if (!specifier.startsWith(".")) {
return { reason: "core_internal_import" };
}
const extensionRoot = getExtensionRoot(params.importerPath);
if (!extensionRoot) {
return {};
}
const importerDir = path.dirname(params.importerPath);
const resolved = path.resolve(importerDir, specifier);
const normalizedRoot = `${extensionRoot}${path.sep}`;
const normalizedResolved = resolved.endsWith(path.sep) ? resolved : `${resolved}${path.sep}`;
if (!(resolved === extensionRoot || normalizedResolved.startsWith(normalizedRoot))) {
const relativeToExtensions = path.relative(extensionsRoot, resolved);
if (!relativeToExtensions.startsWith("..")) {
return { reason: "cross_extension_import" };
}
return { reason: "relative_escape" };
}
return {};
}
function collectModuleSpecifiers(
sourceFile: ts.SourceFile,
): Array<{ specifier: string; line: number }> {
const specifiers: Array<{ specifier: string; line: number }> = [];
const maybePushSpecifier = (node: ts.StringLiteralLike) => {
specifiers.push({ specifier: node.text, line: toLine(sourceFile, node) });
};
const visit = (node: ts.Node) => {
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
maybePushSpecifier(node.moduleSpecifier);
} else if (
ts.isExportDeclaration(node) &&
node.moduleSpecifier &&
ts.isStringLiteralLike(node.moduleSpecifier)
) {
maybePushSpecifier(node.moduleSpecifier);
} else if (
ts.isCallExpression(node) &&
node.arguments.length > 0 &&
ts.isStringLiteralLike(node.arguments[0])
) {
const firstArg = node.arguments[0];
if (
node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require") ||
(ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.name) &&
node.expression.name.text === "mock")
) {
maybePushSpecifier(firstArg);
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return specifiers;
}
export function findPluginImportBoundaryViolations(
content: string,
filePath: string,
): PluginImportBoundaryViolation[] {
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
const relativePath = path.relative(repoRoot, filePath);
const violations: PluginImportBoundaryViolation[] = [];
for (const entry of collectModuleSpecifiers(sourceFile)) {
const classified = classifySpecifier({ importerPath: filePath, specifier: entry.specifier });
if (!classified.reason) {
continue;
}
violations.push({
path: relativePath,
line: entry.line,
specifier: entry.specifier,
reason: classified.reason,
});
}
return violations;
}
async function loadBaseline(): Promise<PluginImportBoundaryViolation[]> {
const raw = await fs.readFile(baselinePath, "utf8");
return JSON.parse(raw) as PluginImportBoundaryViolation[];
}
function sortViolations(
violations: PluginImportBoundaryViolation[],
): PluginImportBoundaryViolation[] {
return [...violations].toSorted(
(left, right) =>
left.path.localeCompare(right.path) ||
left.line - right.line ||
left.specifier.localeCompare(right.specifier) ||
left.reason.localeCompare(right.reason),
);
}
async function collectViolations(): Promise<PluginImportBoundaryViolation[]> {
const files = await collectExtensionCodeFiles(extensionsRoot);
const violations: PluginImportBoundaryViolation[] = [];
for (const filePath of files) {
const content = await fs.readFile(filePath, "utf8");
violations.push(...findPluginImportBoundaryViolations(content, filePath));
}
return sortViolations(violations);
}
function violationKey(violation: PluginImportBoundaryViolation): string {
return `${violation.path}:${violation.line}:${violation.reason}:${violation.specifier}`;
}
async function writeBaseline(): Promise<void> {
const violations = await collectViolations();
await fs.writeFile(baselinePath, `${JSON.stringify(violations, null, 2)}\n`, "utf8");
console.log(`Wrote plugin import boundary baseline (${violations.length} violations).`);
}
async function main(): Promise<void> {
if (process.argv.includes("--write-baseline")) {
await writeBaseline();
return;
}
const violations = await collectViolations();
const baseline = sortViolations(await loadBaseline());
const baselineKeys = new Set(baseline.map(violationKey));
const violationKeys = new Set(violations.map(violationKey));
const newViolations = violations.filter((entry) => !baselineKeys.has(violationKey(entry)));
const resolvedViolations = baseline.filter((entry) => !violationKeys.has(violationKey(entry)));
if (newViolations.length > 0) {
console.error("New plugin import boundary violations found:");
for (const violation of newViolations) {
console.error(
`- ${violation.path}:${violation.line} ${violation.reason} ${JSON.stringify(violation.specifier)}`,
);
}
console.error(
"Extensions may only import same-extension files, openclaw/plugin-sdk/*, Node builtins, or third-party packages.",
);
process.exit(1);
}
if (resolvedViolations.length > 0) {
console.warn(
`Note: ${resolvedViolations.length} baseline plugin-boundary violations were removed. Re-run with --write-baseline to refresh scripts/plugin-import-boundaries.baseline.json.`,
);
}
console.log(
`OK: no new plugin import boundary violations (${violations.length} baseline violations tracked).`,
);
}
runAsScript(import.meta.url, main);

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-optio
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { normalizeProviderId } from "./model-selection.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery-runtime.js";
type ModelEntry = { id: string; contextWindow?: number };
type ModelRegistryLike = {
@ -157,8 +158,6 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
}
try {
const { discoverAuthStorage, discoverModels } =
await import("./pi-model-discovery-runtime.js");
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike;

View File

@ -43,6 +43,7 @@ export function resolvePluginSkillDirs(params: {
origin: record.origin,
config: normalizedPlugins,
rootConfig: params.config,
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
});
if (!enableState.enabled) {
continue;

View File

@ -1,174 +1,7 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../../test-utils/env.js";
import { __testing } from "./web-search.js";
const {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeBraveLanguageParams,
normalizeFreshness,
normalizeToIsoDate,
isoToPerplexityDate,
resolveGrokApiKey,
resolveGrokModel,
resolveGrokInlineCitations,
extractGrokContent,
resolveKimiApiKey,
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
resolveBraveMode,
mapBraveLlmContextResults,
} = __testing;
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_");
const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_");
const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_");
const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-");
const directPerplexityApiKey = ["pplx", "test"].join("-");
const enterprisePerplexityApiKey = ["enterprise", "perplexity", "test"].join("-");
describe("web_search perplexity compatibility routing", () => {
it("detects API key prefixes", () => {
expect(inferPerplexityBaseUrlFromApiKey("pplx-123")).toBe("direct");
expect(inferPerplexityBaseUrlFromApiKey("sk-or-v1-123")).toBe("openrouter");
expect(inferPerplexityBaseUrlFromApiKey("unknown-key")).toBeUndefined();
});
it("prefers explicit baseUrl over key-based defaults", () => {
expect(resolvePerplexityBaseUrl({ baseUrl: "https://example.com" }, "config", "pplx-123")).toBe(
"https://example.com",
);
});
it("resolves OpenRouter env auth and transport", () => {
withEnv(
{ [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: openRouterPerplexityApiKey },
() => {
expect(resolvePerplexityApiKey(undefined)).toEqual({
apiKey: openRouterPerplexityApiKey,
source: "openrouter_env",
});
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
transport: "chat_completions",
});
},
);
});
it("uses native Search API for direct Perplexity when no legacy overrides exist", () => {
withEnv(
{ [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined },
() => {
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
transport: "search_api",
});
},
);
});
it("switches direct Perplexity to chat completions when model override is configured", () => {
expect(resolvePerplexityModel({ model: "perplexity/sonar-reasoning-pro" })).toBe(
"perplexity/sonar-reasoning-pro",
);
expect(
resolvePerplexityTransport({
apiKey: directPerplexityApiKey,
model: "perplexity/sonar-reasoning-pro",
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-reasoning-pro",
transport: "chat_completions",
});
});
it("treats unrecognized configured keys as direct Perplexity by default", () => {
expect(
resolvePerplexityTransport({
apiKey: enterprisePerplexityApiKey,
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",
transport: "search_api",
});
});
it("normalizes direct Perplexity models for chat completions", () => {
expect(isDirectPerplexityBaseUrl("https://api.perplexity.ai")).toBe(true);
expect(isDirectPerplexityBaseUrl("https://openrouter.ai/api/v1")).toBe(false);
expect(resolvePerplexityRequestModel("https://api.perplexity.ai", "perplexity/sonar-pro")).toBe(
"sonar-pro",
);
expect(
resolvePerplexityRequestModel("https://openrouter.ai/api/v1", "perplexity/sonar-pro"),
).toBe("perplexity/sonar-pro");
});
});
describe("web_search brave language param normalization", () => {
it("normalizes and auto-corrects swapped Brave language params", () => {
expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({
search_lang: "tr",
ui_lang: "tr-TR",
});
expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({
search_lang: "en",
ui_lang: "en-US",
});
});
it("flags invalid Brave language formats", () => {
expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
invalidField: "search_lang",
});
expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
invalidField: "ui_lang",
});
});
});
describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values and maps for Perplexity", () => {
expect(normalizeFreshness("pd", "brave")).toBe("pd");
expect(normalizeFreshness("PW", "brave")).toBe("pw");
expect(normalizeFreshness("pd", "perplexity")).toBe("day");
expect(normalizeFreshness("pw", "perplexity")).toBe("week");
});
it("accepts Perplexity values and maps for Brave", () => {
expect(normalizeFreshness("day", "perplexity")).toBe("day");
expect(normalizeFreshness("week", "perplexity")).toBe("week");
expect(normalizeFreshness("day", "brave")).toBe("pd");
expect(normalizeFreshness("week", "brave")).toBe("pw");
});
it("accepts valid date ranges for Brave", () => {
expect(normalizeFreshness("2024-01-01to2024-01-31", "brave")).toBe("2024-01-01to2024-01-31");
});
it("rejects invalid values", () => {
expect(normalizeFreshness("yesterday", "brave")).toBeUndefined();
expect(normalizeFreshness("yesterday", "perplexity")).toBeUndefined();
expect(normalizeFreshness("2024-01-01to2024-01-31", "perplexity")).toBeUndefined();
});
it("rejects invalid date ranges for Brave", () => {
expect(normalizeFreshness("2024-13-01to2024-01-31", "brave")).toBeUndefined();
expect(normalizeFreshness("2024-02-30to2024-03-01", "brave")).toBeUndefined();
expect(normalizeFreshness("2024-03-10to2024-03-01", "brave")).toBeUndefined();
});
});
const { normalizeToIsoDate } = __testing;
describe("web_search date normalization", () => {
it("accepts ISO format", () => {
@ -186,285 +19,4 @@ describe("web_search date normalization", () => {
expect(normalizeToIsoDate("2024/01/15")).toBeUndefined();
expect(normalizeToIsoDate("invalid")).toBeUndefined();
});
it("converts ISO to Perplexity format", () => {
expect(isoToPerplexityDate("2024-01-15")).toBe("1/15/2024");
expect(isoToPerplexityDate("2025-12-31")).toBe("12/31/2025");
expect(isoToPerplexityDate("2024-03-05")).toBe("3/5/2024");
});
it("rejects invalid ISO dates", () => {
expect(isoToPerplexityDate("1/15/2024")).toBeUndefined();
expect(isoToPerplexityDate("invalid")).toBeUndefined();
});
});
describe("web_search grok config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); // pragma: allowlist secret
});
it("returns undefined when no apiKey is available", () => {
withEnv({ XAI_API_KEY: undefined }, () => {
expect(resolveGrokApiKey({})).toBeUndefined();
expect(resolveGrokApiKey(undefined)).toBeUndefined();
});
});
it("uses default model when not specified", () => {
expect(resolveGrokModel({})).toBe("grok-4-1-fast");
expect(resolveGrokModel(undefined)).toBe("grok-4-1-fast");
});
it("uses config model when provided", () => {
expect(resolveGrokModel({ model: "grok-3" })).toBe("grok-3");
});
it("defaults inlineCitations to false", () => {
expect(resolveGrokInlineCitations({})).toBe(false);
expect(resolveGrokInlineCitations(undefined)).toBe(false);
});
it("respects inlineCitations config", () => {
expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true);
expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false);
});
});
describe("web_search grok response parsing", () => {
it("extracts content from Responses API message blocks", () => {
const result = extractGrokContent({
output: [
{
type: "message",
content: [{ type: "output_text", text: "hello from output" }],
},
],
});
expect(result.text).toBe("hello from output");
expect(result.annotationCitations).toEqual([]);
});
it("extracts url_citation annotations from content blocks", () => {
const result = extractGrokContent({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "hello with citations",
annotations: [
{
type: "url_citation",
url: "https://example.com/a",
start_index: 0,
end_index: 5,
},
{
type: "url_citation",
url: "https://example.com/b",
start_index: 6,
end_index: 10,
},
{
type: "url_citation",
url: "https://example.com/a",
start_index: 11,
end_index: 15,
}, // duplicate
],
},
],
},
],
});
expect(result.text).toBe("hello with citations");
expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]);
});
it("falls back to deprecated output_text", () => {
const result = extractGrokContent({ output_text: "hello from output_text" });
expect(result.text).toBe("hello from output_text");
expect(result.annotationCitations).toEqual([]);
});
it("returns undefined text when no content found", () => {
const result = extractGrokContent({});
expect(result.text).toBeUndefined();
expect(result.annotationCitations).toEqual([]);
});
it("extracts output_text blocks directly in output array (no message wrapper)", () => {
const result = extractGrokContent({
output: [
{ type: "web_search_call" },
{
type: "output_text",
text: "direct output text",
annotations: [
{
type: "url_citation",
url: "https://example.com/direct",
start_index: 0,
end_index: 5,
},
],
},
],
} as Parameters<typeof extractGrokContent>[0]);
expect(result.text).toBe("direct output text");
expect(result.annotationCitations).toEqual(["https://example.com/direct"]);
});
});
describe("web_search kimi config resolution", () => {
it("uses config apiKey when provided", () => {
expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); // pragma: allowlist secret
});
it("falls back to KIMI_API_KEY, then MOONSHOT_API_KEY", () => {
const kimiEnvValue = "kimi-env"; // pragma: allowlist secret
const moonshotEnvValue = "moonshot-env"; // pragma: allowlist secret
withEnv({ [kimiApiKeyEnv]: kimiEnvValue, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
expect(resolveKimiApiKey({})).toBe(kimiEnvValue);
});
withEnv({ [kimiApiKeyEnv]: undefined, [moonshotApiKeyEnv]: moonshotEnvValue }, () => {
expect(resolveKimiApiKey({})).toBe(moonshotEnvValue);
});
});
it("returns undefined when no Kimi key is configured", () => {
withEnv({ KIMI_API_KEY: undefined, MOONSHOT_API_KEY: undefined }, () => {
expect(resolveKimiApiKey({})).toBeUndefined();
expect(resolveKimiApiKey(undefined)).toBeUndefined();
});
});
it("resolves default model and baseUrl", () => {
expect(resolveKimiModel({})).toBe("moonshot-v1-128k");
expect(resolveKimiBaseUrl({})).toBe("https://api.moonshot.ai/v1");
});
});
describe("extractKimiCitations", () => {
it("collects unique URLs from search_results and tool arguments", () => {
expect(
extractKimiCitations({
search_results: [{ url: "https://example.com/a" }, { url: "https://example.com/a" }],
choices: [
{
message: {
tool_calls: [
{
function: {
arguments: JSON.stringify({
search_results: [{ url: "https://example.com/b" }],
url: "https://example.com/c",
}),
},
},
],
},
},
],
}).toSorted(),
).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]);
});
});
describe("resolveBraveMode", () => {
it("defaults to 'web' when no config is provided", () => {
expect(resolveBraveMode({})).toBe("web");
});
it("defaults to 'web' when mode is undefined", () => {
expect(resolveBraveMode({ mode: undefined })).toBe("web");
});
it("returns 'llm-context' when configured", () => {
expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
});
it("returns 'web' when mode is explicitly 'web'", () => {
expect(resolveBraveMode({ mode: "web" })).toBe("web");
});
it("falls back to 'web' for unrecognized mode values", () => {
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
});
});
describe("mapBraveLlmContextResults", () => {
it("maps plain string snippets correctly", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
},
],
},
});
expect(results).toEqual([
{
url: "https://example.com/page",
title: "Example Page",
snippets: ["first snippet", "second snippet"],
siteName: "example.com",
},
]);
});
it("filters out non-string and empty snippets", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [
{
url: "https://example.com",
title: "Test",
snippets: ["valid", "", null, undefined, 42, { text: "object" }] as string[],
},
],
},
});
expect(results[0].snippets).toEqual(["valid"]);
});
it("handles missing snippets array", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://example.com", title: "No Snippets" } as never],
},
});
expect(results[0].snippets).toEqual([]);
});
it("handles empty grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: { generic: [] } })).toEqual([]);
});
it("handles missing grounding.generic", () => {
expect(mapBraveLlmContextResults({ grounding: {} } as never)).toEqual([]);
});
it("resolves siteName from URL hostname", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "https://docs.example.org/path", title: "Docs", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBe("docs.example.org");
});
it("sets siteName to undefined for invalid URLs", () => {
const results = mapBraveLlmContextResults({
grounding: {
generic: [{ url: "not-a-url", title: "Bad URL", snippets: ["text"] }],
},
});
expect(results[0].siteName).toBeUndefined();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,74 @@
import { EnvHttpProxyAgent } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createBundledBraveSearchProvider,
__testing as bundledBraveTesting,
} from "../../../extensions/search-brave/src/provider.js";
import {
createBundledGeminiSearchProvider,
__testing as bundledGeminiTesting,
} from "../../../extensions/search-gemini/src/provider.js";
import {
createBundledGrokSearchProvider,
__testing as bundledGrokTesting,
} from "../../../extensions/search-grok/src/provider.js";
import {
createBundledKimiSearchProvider,
__testing as bundledKimiTesting,
} from "../../../extensions/search-kimi/src/provider.js";
import {
createBundledPerplexitySearchProvider,
__testing as bundledPerplexityTesting,
} from "../../../extensions/search-perplexity/src/provider.js";
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { __testing as webSearchTesting } from "./web-search.js";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
let previousPluginRegistry = getActivePluginRegistry();
const BUNDLED_PROVIDER_CREATORS = {
brave: createBundledBraveSearchProvider,
gemini: createBundledGeminiSearchProvider,
grok: createBundledGrokSearchProvider,
kimi: createBundledKimiSearchProvider,
perplexity: createBundledPerplexitySearchProvider,
} as const;
beforeEach(() => {
previousPluginRegistry = getActivePluginRegistry();
const registry = createEmptyPluginRegistry();
(
Object.entries(BUNDLED_PROVIDER_CREATORS) as Array<
[
keyof typeof BUNDLED_PROVIDER_CREATORS,
(typeof BUNDLED_PROVIDER_CREATORS)[keyof typeof BUNDLED_PROVIDER_CREATORS],
]
>
).forEach(([providerId, createProvider]) => {
registry.searchProviders.push({
pluginId: `search-${providerId}`,
source: `/plugins/search-${providerId}`,
provider: {
...createProvider(),
pluginId: `search-${providerId}`,
},
});
});
setActivePluginRegistry(registry);
webSearchTesting.SEARCH_CACHE.clear();
bundledBraveTesting.clearSearchProviderCaches();
bundledPerplexityTesting.clearSearchProviderCaches();
bundledGrokTesting.clearSearchProviderCaches();
bundledGeminiTesting.clearSearchProviderCaches();
bundledKimiTesting.clearSearchProviderCaches();
});
afterEach(() => {
setActivePluginRegistry(previousPluginRegistry ?? createEmptyPluginRegistry());
});
function installMockFetch(payload: unknown) {
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
Promise.resolve({
@ -169,8 +234,8 @@ describe("web tools defaults", () => {
expect(tool?.name).toBe("web_search");
});
it("prefers runtime-selected web_search provider over local provider config", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("gemini"));
it("uses the configured web_search provider from config", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("brave"));
const tool = createWebSearchTool({
config: {
tools: {
@ -186,20 +251,380 @@ describe("web tools defaults", () => {
},
},
sandboxed: true,
runtimeWebSearch: {
providerConfigured: "brave",
providerSource: "auto-detect",
selectedProvider: "gemini",
selectedProviderKeySource: "secretRef",
diagnostics: [],
},
});
const result = await tool?.execute?.("call-runtime-provider", { query: "runtime override" });
const result = await tool?.execute?.("call-config-provider", { query: "config provider" });
expect(mockFetch).toHaveBeenCalled();
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini");
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.search.brave.com");
expect((result?.details as { provider?: string } | undefined)?.provider).toBe("brave");
});
});
describe("web_search plugin providers", () => {
it.each(["brave", "perplexity", "grok", "gemini", "kimi"] as const)(
"resolves configured provider %s through plugin registrations when available",
async (providerId) => {
const registry = createEmptyPluginRegistry();
const bundledProvider = BUNDLED_PROVIDER_CREATORS[providerId]();
registry.searchProviders.push({
pluginId: `search-${providerId}`,
source: `/plugins/search-${providerId}`,
provider: {
...bundledProvider,
pluginId: `search-${providerId}`,
},
});
setActivePluginRegistry(registry);
const mockFetch = installMockFetch(createProviderSuccessPayload(providerId));
const provider = webSearchTesting.resolveRegisteredSearchProvider({
config: {
tools: {
web: {
search:
providerId === "perplexity"
? { provider: providerId, perplexity: { apiKey: "pplx-config-test" } }
: providerId === "grok"
? { provider: providerId, grok: { apiKey: "xai-config-test" } }
: providerId === "gemini"
? { provider: providerId, gemini: { apiKey: "gemini-config-test" } }
: providerId === "kimi"
? { provider: providerId, kimi: { apiKey: "moonshot-config-test" } }
: { provider: providerId, apiKey: "brave-config-test" },
},
},
},
});
expect(provider.pluginId).toBe(`search-${providerId}`);
const tool = createWebSearchTool({
config: {
tools: {
web: {
search:
providerId === "perplexity"
? { provider: providerId, perplexity: { apiKey: "pplx-config-test" } }
: providerId === "grok"
? { provider: providerId, grok: { apiKey: "xai-config-test" } }
: providerId === "gemini"
? { provider: providerId, gemini: { apiKey: "gemini-config-test" } }
: providerId === "kimi"
? { provider: providerId, kimi: { apiKey: "moonshot-config-test" } }
: { provider: providerId, apiKey: "brave-config-test" },
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.(`call-bundled-${providerId}`, {
query: `bundled ${providerId}`,
});
expect(mockFetch).toHaveBeenCalled();
expect((result?.details as { provider?: string } | undefined)?.provider).toBe(providerId);
},
);
it("prefers an explicitly configured plugin provider over another registered provider with the same id", async () => {
const searchMock = vi.fn(async () => ({
results: [
{
title: "Plugin Result",
url: "https://example.com/plugin",
description: "Plugin description",
},
],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "brave",
name: "Plugin Brave Override",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "https://plugin.example" },
},
},
},
tools: {
web: {
search: {
provider: "brave",
apiKey: "brave-config-test", // pragma: allowlist secret
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("plugin-explicit", { query: "override" });
const details = result?.details as
| {
provider?: string;
results?: Array<{ url: string }>;
}
| undefined;
expect(searchMock).toHaveBeenCalledOnce();
expect(details?.provider).toBe("brave");
expect(details?.results?.[0]?.url).toBe("https://example.com/plugin");
});
it("keeps an explicitly configured plugin provider even when other provider credentials are also present", async () => {
const searchMock = vi.fn(async () => ({
content: "Plugin-configured answer",
citations: ["https://example.com/plugin-configured"],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "searxng",
name: "SearXNG",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "https://plugin.example" },
},
},
},
tools: {
web: {
search: {
provider: "searxng",
apiKey: "brave-config-test", // pragma: allowlist secret
gemini: {
apiKey: "gemini-config-test", // pragma: allowlist secret
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("plugin-over-runtime", { query: "plugin configured" });
const details = result?.details as { provider?: string; citations?: string[] } | undefined;
expect(searchMock).toHaveBeenCalledOnce();
expect(details?.provider).toBe("searxng");
expect(details?.citations).toEqual(["https://example.com/plugin-configured"]);
});
it("auto-detects registered providers before falling back to later detection candidates", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-brave-key"); // pragma: allowlist secret
const searchMock = vi.fn(async () => ({
content: "Plugin answer",
citations: ["https://example.com/plugin-auto"],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-auto",
source: "test",
provider: {
id: "plugin-auto",
name: "Plugin Auto",
pluginId: "plugin-auto",
isAvailable: () => true,
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({ config: {}, sandboxed: true });
const result = await tool?.execute?.("plugin-auto", { query: "auto" });
const details = result?.details as { provider?: string; citations?: string[] } | undefined;
expect(searchMock).toHaveBeenCalledOnce();
expect(details?.provider).toBe("plugin-auto");
expect(details?.citations).toEqual(["https://example.com/plugin-auto"]);
});
it("fails closed when a configured custom provider is not registered", async () => {
const mockFetch = installMockFetch(createProviderSuccessPayload("brave"));
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "searxng",
apiKey: "brave-config-test", // pragma: allowlist secret
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("plugin-missing", { query: "missing provider" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({
error: "unknown_search_provider",
provider: "searxng",
});
});
it("preserves plugin error payloads without caching them as success responses", async () => {
webSearchTesting.SEARCH_CACHE.clear();
const searchMock = vi
.fn()
.mockResolvedValueOnce({ error: "rate_limited" })
.mockResolvedValueOnce({
results: [
{
title: "Recovered",
url: "https://example.com/recovered",
},
],
});
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "searxng",
name: "SearXNG",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "searxng",
},
},
},
},
sandboxed: true,
});
const firstResult = await tool?.execute?.("plugin-error-1", { query: "same-query" });
const secondResult = await tool?.execute?.("plugin-error-2", { query: "same-query" });
expect(searchMock).toHaveBeenCalledTimes(2);
expect(firstResult?.details).toMatchObject({
error: "rate_limited",
provider: "searxng",
});
expect((secondResult?.details as { cached?: boolean } | undefined)?.cached).not.toBe(true);
expect(secondResult?.details).toMatchObject({
provider: "searxng",
results: [{ url: "https://example.com/recovered" }],
});
});
it("does not reuse cached plugin results across different plugin configs", async () => {
webSearchTesting.SEARCH_CACHE.clear();
const searchMock = vi
.fn()
.mockImplementation(async (_request, context: { pluginConfig?: { endpoint?: string } }) => ({
results: [
{
title: "Plugin Result",
url: `https://example.com/${context.pluginConfig?.endpoint || "missing"}`,
},
],
}));
const registry = createEmptyPluginRegistry();
registry.searchProviders.push({
pluginId: "plugin-search",
source: "test",
provider: {
id: "searxng",
name: "SearXNG",
pluginId: "plugin-search",
search: searchMock,
},
});
setActivePluginRegistry(registry);
const firstTool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "tenant-a" },
},
},
},
tools: {
web: {
search: {
provider: "searxng",
},
},
},
},
sandboxed: true,
});
const secondTool = createWebSearchTool({
config: {
plugins: {
entries: {
"plugin-search": {
enabled: true,
config: { endpoint: "tenant-b" },
},
},
},
tools: {
web: {
search: {
provider: "searxng",
},
},
},
},
sandboxed: true,
});
const firstResult = await firstTool?.execute?.("plugin-cache-a", { query: "same-query" });
const secondResult = await secondTool?.execute?.("plugin-cache-b", { query: "same-query" });
const firstDetails = firstResult?.details as
| { results?: Array<{ url: string }>; cached?: boolean }
| undefined;
const secondDetails = secondResult?.details as
| { results?: Array<{ url: string }>; cached?: boolean }
| undefined;
expect(searchMock).toHaveBeenCalledTimes(2);
expect(firstDetails?.results?.[0]?.url).toBe("https://example.com/tenant-a");
expect(secondDetails?.results?.[0]?.url).toBe("https://example.com/tenant-b");
expect(secondDetails?.cached).not.toBe(true);
});
});
@ -330,6 +755,11 @@ describe("web_search perplexity Search API", () => {
vi.unstubAllEnvs();
global.fetch = priorFetch;
webSearchTesting.SEARCH_CACHE.clear();
bundledBraveTesting.clearSearchProviderCaches();
bundledPerplexityTesting.clearSearchProviderCaches();
bundledGrokTesting.clearSearchProviderCaches();
bundledGeminiTesting.clearSearchProviderCaches();
bundledKimiTesting.clearSearchProviderCaches();
});
it("uses Perplexity Search API when PERPLEXITY_API_KEY is set", async () => {
@ -467,6 +897,11 @@ describe("web_search perplexity OpenRouter compatibility", () => {
vi.unstubAllEnvs();
global.fetch = priorFetch;
webSearchTesting.SEARCH_CACHE.clear();
bundledBraveTesting.clearSearchProviderCaches();
bundledPerplexityTesting.clearSearchProviderCaches();
bundledGrokTesting.clearSearchProviderCaches();
bundledGeminiTesting.clearSearchProviderCaches();
bundledKimiTesting.clearSearchProviderCaches();
});
it("routes OPENROUTER_API_KEY through chat completions", async () => {

View File

@ -1,3 +1,4 @@
import { ensureAuthProfileStore } from "../../agents/auth-profiles.runtime.js";
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
@ -365,7 +366,6 @@ export async function createModelSelectionState(params: {
}
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.runtime.js");
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});

View File

@ -82,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
commands: [],
channels,
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
@ -20,6 +20,20 @@ const mocks = vi.hoisted(() => ({
summarizeExistingConfig: vi.fn(),
}));
const loadOpenClawPlugins = vi.hoisted(() =>
vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })),
);
const loadPluginManifestRegistry = vi.hoisted(() =>
vi.fn(() => ({ plugins: [] as unknown[], diagnostics: [] as unknown[] })),
);
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
);
const ensureGenericOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })),
);
const reloadOnboardingPluginRegistry = vi.hoisted(() => vi.fn());
vi.mock("@clack/prompts", () => ({
intro: mocks.clackIntro,
outro: mocks.clackOutro,
@ -43,6 +57,20 @@ vi.mock("../wizard/clack-prompter.js", () => ({
createClackPrompter: mocks.createClackPrompter,
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
}));
vi.mock("./onboarding/plugin-install.js", () => ({
ensureGenericOnboardingPluginInstalled,
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
}));
vi.mock("../terminal/note.js", () => ({
note: mocks.note,
}));
@ -99,6 +127,501 @@ import { WizardCancelledError } from "../wizard/prompts.js";
import { runConfigureWizard } from "./configure.wizard.js";
describe("runConfigureWizard", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
vi.stubEnv("BRAVE_API_KEY", "");
vi.stubEnv("GEMINI_API_KEY", "");
vi.stubEnv("XAI_API_KEY", "");
vi.stubEnv("MOONSHOT_API_KEY", "");
vi.stubEnv("PERPLEXITY_API_KEY", "");
mocks.clackIntro.mockReset();
mocks.clackOutro.mockReset();
mocks.clackSelect.mockReset();
mocks.clackText.mockReset();
mocks.clackConfirm.mockReset();
mocks.readConfigFileSnapshot.mockReset();
mocks.writeConfigFile.mockReset();
mocks.resolveGatewayPort.mockReset();
mocks.ensureControlUiAssetsBuilt.mockReset();
mocks.createClackPrompter.mockReset();
mocks.note.mockReset();
mocks.printWizardHeader.mockReset();
mocks.probeGatewayReachable.mockReset();
mocks.waitForGatewayReachable.mockReset();
mocks.resolveControlUiLinks.mockReset();
mocks.summarizeExistingConfig.mockReset();
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] });
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
ensureOnboardingPluginInstalled.mockReset();
ensureOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg,
installed: false,
}),
);
ensureGenericOnboardingPluginInstalled.mockReset();
ensureGenericOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg,
installed: false,
}),
);
reloadOnboardingPluginRegistry.mockReset();
});
it("configures a plugin web search provider from the picker", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
configFieldOrder: ["apiKey", "searchDepth"],
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: {
type: "object",
properties: {
apiKey: { type: "string" },
searchDepth: { type: "string", enum: ["basic", "advanced"] },
},
},
configUiHints: {
apiKey: {
label: "Tavily API key",
placeholder: "tvly-...",
sensitive: true,
},
searchDepth: {
label: "Search depth",
},
},
},
],
});
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
agents: {
defaults: {
workspace: "/tmp/configure-workspace-search",
},
},
},
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.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
return "tavily";
}
if (params.message.startsWith("Search depth")) {
return "advanced";
}
return "__continue";
});
mocks.clackText.mockResolvedValue("tvly-test-key");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
enabled: true,
provider: "tavily",
}),
}),
}),
plugins: expect.objectContaining({
entries: expect.objectContaining({
"tavily-search": expect.objectContaining({
enabled: true,
config: {
apiKey: "tvly-test-key",
searchDepth: "advanced",
},
}),
}),
}),
}),
);
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/configure-workspace-search",
}),
);
});
it("persists enabling web_search when configuring a provider from a previously disabled state", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
agents: {
defaults: {
workspace: "/tmp/configure-workspace-enable-search",
},
},
tools: {
web: {
search: {
enabled: false,
provider: "brave",
},
},
},
},
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.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
return "brave";
}
return "__continue";
});
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
enabled: true,
provider: "brave",
}),
}),
}),
}),
);
expect(mocks.writeConfigFile.mock.calls[0]?.[0]?.tools?.web?.search?.provider).toBe("brave");
});
it("re-prompts invalid plugin config values during configure", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
configFieldOrder: ["apiKey", "searchDepth"],
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: {
type: "object",
required: ["apiKey"],
properties: {
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
searchDepth: { type: "string", enum: ["basic", "advanced"] },
},
},
configUiHints: {
apiKey: {
label: "Tavily API key",
placeholder: "tvly-...",
sensitive: true,
},
searchDepth: {
label: "Search depth",
},
},
},
],
});
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
agents: {
defaults: {
workspace: "/tmp/configure-workspace-search",
},
},
},
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.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
return "tavily";
}
if (params.message.startsWith("Search depth")) {
return "advanced";
}
return "__continue";
});
mocks.clackText.mockResolvedValueOnce("bad-key").mockResolvedValueOnce("tvly-test-key");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(
mocks.note.mock.calls.some(
([message, title]) =>
title === "Invalid plugin config" &&
typeof message === "string" &&
message.includes("Api Key"),
),
).toBe(true);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
"tavily-search": expect.objectContaining({
config: {
apiKey: "tvly-test-key",
searchDepth: "advanced",
},
}),
}),
}),
}),
);
});
it("configures a manifest-discovered search provider from configure without a separate install step", async () => {
loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => {
const enabled = config.plugins?.entries?.["tavily-search"]?.enabled === true;
return enabled
? {
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
configFieldOrder: ["apiKey", "searchDepth"],
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: {
type: "object",
properties: {
apiKey: { type: "string" },
searchDepth: { type: "string", enum: ["basic", "advanced"] },
},
},
configUiHints: {
apiKey: {
label: "Tavily API key",
placeholder: "tvly-...",
sensitive: true,
},
searchDepth: {
label: "Search depth",
},
},
},
],
}
: { searchProviders: [], plugins: [] };
});
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "Search the web using Tavily.",
provides: ["providers.search.tavily"],
origin: "bundled",
source: "/tmp/bundled/tavily-search",
install: {
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
configSchema: {
type: "object",
required: ["apiKey"],
properties: {
apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" },
searchDepth: { type: "string", enum: ["basic", "advanced"] },
},
},
configUiHints: {
apiKey: {
label: "Tavily API key",
placeholder: "tvly-...",
sensitive: true,
},
searchDepth: {
label: "Search depth",
},
},
},
],
diagnostics: [],
});
ensureOnboardingPluginInstalled.mockImplementation(
async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg: {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
"tavily-search": {
...(cfg.plugins?.entries?.["tavily-search"] as Record<string, unknown> | undefined),
enabled: true,
},
},
},
},
installed: true,
}),
);
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
agents: {
defaults: {
workspace: "/tmp/configure-install-workspace",
},
},
},
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.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(async (params: { message: string }) => {
if (params.message === "Choose active web search provider") {
return "tavily";
}
if (params.message.startsWith("Search depth")) {
return "advanced";
}
return "__continue";
});
mocks.clackText.mockResolvedValue("tvly-installed-key");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled();
expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
provider: "tavily",
enabled: true,
}),
}),
}),
plugins: expect.objectContaining({
entries: expect.objectContaining({
"tavily-search": expect.objectContaining({
enabled: true,
config: {
apiKey: "tvly-installed-key",
searchDepth: "advanced",
},
}),
}),
}),
}),
);
});
it("persists gateway.mode=local when only the run mode is selected", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
@ -158,4 +681,137 @@ describe("runConfigureWizard", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("preserves an existing plugin web search provider when keeping the current provider", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
tools: {
web: {
search: {
provider: "searxng",
enabled: true,
},
},
},
},
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.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm
.mockResolvedValueOnce(true) // enable web_search
.mockResolvedValueOnce(true); // enable web_fetch
mocks.clackSelect.mockResolvedValue("__keep_current__");
mocks.clackText.mockResolvedValue("");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.clackText).not.toHaveBeenCalled();
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
provider: "searxng",
enabled: true,
}),
}),
}),
}),
);
});
it("shows the active provider first when multiple providers are configured", async () => {
vi.stubEnv("BRAVE_API_KEY", "BSA-test-key");
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
isAvailable: () => true,
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: undefined,
configUiHints: undefined,
},
],
});
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: true,
config: {
tools: {
web: {
search: {
provider: "tavily",
enabled: true,
},
},
},
},
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.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
mocks.clackIntro.mockResolvedValue(undefined);
mocks.clackOutro.mockResolvedValue(undefined);
mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
mocks.clackSelect.mockImplementation(
async (params: { message: string; options?: Array<{ value: string; hint?: string }> }) => {
if (params.message === "Choose active web search provider") {
expect(params.options?.[0]).toMatchObject({
value: "tavily",
hint: "Plugin search",
});
expect(params.options?.[1]).toMatchObject({
value: "__install_plugin__",
hint: "Install a web search plugin from npm or a local path",
});
return "tavily";
}
return "__continue";
},
);
mocks.clackText.mockResolvedValue("");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
});
});

View File

@ -11,7 +11,7 @@ import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import { resolveOnboardingSecretInputString } from "../wizard/onboarding.secret-input.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js";
import { removeChannelConfigWizard } from "./configure.channels.js";
import { maybeInstallDaemon } from "./configure.daemon.js";
import { promptAuthConfig } from "./configure.gateway-auth.js";
@ -163,41 +163,40 @@ async function promptChannelMode(runtime: RuntimeEnv): Promise<ChannelsWizardMod
async function promptWebToolsConfig(
nextConfig: OpenClawConfig,
runtime: RuntimeEnv,
workspaceDir?: string,
): Promise<OpenClawConfig> {
const existingSearch = nextConfig.tools?.web?.search;
const existingFetch = nextConfig.tools?.web?.fetch;
const {
SEARCH_PROVIDER_OPTIONS,
resolveExistingKey,
hasExistingKey,
applySearchKey,
hasKeyInEnv,
} = await import("./onboard-search.js");
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
const hasKeyForProvider = (provider: string): boolean => {
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
if (!entry) {
return false;
}
return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry);
const prompter: WizardPrompter = {
intro: async () => {},
outro: async () => {},
note: async (message: string, title?: string) => {
note(message, title);
},
select: async <T>(params: Parameters<WizardPrompter["select"]>[0]) =>
guardCancel(await select<T>(params as never), runtime),
multiselect: async <T>() => [] as T[],
text: async (params: Parameters<WizardPrompter["text"]>[0]) =>
guardCancel(
await text({
...params,
validate: params.validate
? (value: string | undefined) => params.validate?.(value ?? "")
: undefined,
} as never),
runtime,
),
confirm: async (params) => guardCancel(await confirm(params), runtime),
progress: () => ({ update: () => {}, stop: () => {} }),
};
const existingProvider: string = (() => {
const stored = existingSearch?.provider;
if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) {
return stored;
}
return (
SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ??
SEARCH_PROVIDER_OPTIONS[0].value
);
})();
const { resolveSearchProviderPickerEntries, promptSearchProviderFlow } =
await import("./onboard-search.js");
const providerEntries = await resolveSearchProviderPickerEntries(nextConfig, workspaceDir);
note(
[
"Web search lets your agent look things up online using the `web_search` tool.",
"Choose a provider and paste your API key.",
"Choose a provider and enter the required settings.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
@ -206,8 +205,7 @@ async function promptWebToolsConfig(
const enableSearch = guardCancel(
await confirm({
message: "Enable web_search?",
initialValue:
existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)),
initialValue: existingSearch?.enabled ?? providerEntries.some((entry) => entry.configured),
}),
runtime,
);
@ -218,63 +216,28 @@ async function promptWebToolsConfig(
};
if (enableSearch) {
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasKeyForProvider(entry.value);
return {
value: entry.value,
label: entry.label,
hint: configured ? `${entry.hint} · configured` : entry.hint,
};
nextConfig = {
...nextConfig,
tools: {
...nextConfig.tools,
web: {
...nextConfig.tools?.web,
search: nextSearch,
},
},
};
const applied = await promptSearchProviderFlow({
config: nextConfig,
runtime,
prompter,
opts: {
workspaceDir,
},
includeSkipOption: true,
skipHint: "Leave the current web search setup unchanged",
});
const providerChoice = guardCancel(
await select({
message: "Choose web search provider",
options: providerOptions,
initialValue: existingProvider,
}),
runtime,
);
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envKeys.join(" / ");
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
: `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
nextSearch = { ...applied.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
nextSearch = { ...nextSearch };
} else {
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store a key here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
}
nextConfig = applied;
nextSearch = { ...applied.tools?.web?.search };
}
const enableFetch = guardCancel(
@ -527,7 +490,7 @@ export async function runConfigureWizard(
}
if (selected.includes("web")) {
nextConfig = await promptWebToolsConfig(nextConfig, runtime);
nextConfig = await promptWebToolsConfig(nextConfig, runtime, workspaceDir);
}
if (selected.includes("gateway")) {
@ -580,7 +543,7 @@ export async function runConfigureWizard(
}
if (choice === "web") {
nextConfig = await promptWebToolsConfig(nextConfig, runtime);
nextConfig = await promptWebToolsConfig(nextConfig, runtime, workspaceDir);
await persistConfig();
}

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { validateConfigObjectWithPlugins } from "../config/config.js";
import * as noteModule from "../terminal/note.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";
@ -27,7 +28,7 @@ async function collectDoctorWarnings(config: Record<string, unknown>): Promise<s
run: loadAndMaybeMigrateDoctorConfig,
});
return noteSpy.mock.calls
.filter((call) => call[1] === "Doctor warnings")
.filter((call) => call[1] === "Doctor warnings" || call[1] === "Config warnings")
.map((call) => String(call[0]));
} finally {
noteSpy.mockRestore();
@ -126,6 +127,56 @@ describe("doctor config flow", () => {
).toBe(true);
});
it("surfaces missing required plugin capabilities as doctor warnings", async () => {
const temp = await withTempHome(async (homeDir) => {
const providerDir = path.join(homeDir, "embedding-provider");
await fs.mkdir(providerDir, { recursive: true });
await fs.writeFile(
path.join(providerDir, "index.js"),
'export default { id: "embedding-provider", register() {} };',
"utf-8",
);
await fs.writeFile(
path.join(providerDir, "openclaw.plugin.json"),
JSON.stringify({
id: "embedding-provider",
configSchema: { type: "object" },
provides: ["providers.embedding.fixture"],
}),
"utf-8",
);
const consumerDir = path.join(homeDir, "embedding-consumer");
await fs.mkdir(consumerDir, { recursive: true });
await fs.writeFile(
path.join(consumerDir, "index.js"),
'export default { id: "embedding-consumer", register() {} };',
"utf-8",
);
await fs.writeFile(
path.join(consumerDir, "openclaw.plugin.json"),
JSON.stringify({
id: "embedding-consumer",
configSchema: { type: "object" },
requires: ["providers.embedding.fixture"],
}),
"utf-8",
);
return collectDoctorWarnings({
plugins: {
enabled: true,
load: { paths: [consumerDir] },
},
});
});
expect(
temp.some((line) =>
line.includes("missing required capability: providers.embedding.fixture"),
),
).toBe(true);
});
it("does not warn on mutable Zalouser group entries when dangerous name matching is enabled", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
@ -140,7 +191,6 @@ describe("doctor config flow", () => {
expect(doctorWarnings.some((line) => line.includes("channels.zalouser.groups"))).toBe(false);
});
it("warns when imessage group allowlist is empty even if allowFrom is set", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
@ -179,6 +229,138 @@ describe("doctor config flow", () => {
});
});
it("removes invalid plugin config leaves and disables the affected plugin on repair", async () => {
const tavilyPath = path.join(process.cwd(), "extensions", "tavily-search");
const result = await runDoctorConfigWithInput({
repair: true,
config: {
plugins: {
load: { paths: [tavilyPath] },
allow: ["tavily-search"],
entries: {
"tavily-search": {
enabled: true,
config: {
apiKey: "◇ Enable web_search?",
searchDepth: "basic",
},
},
},
},
tools: {
web: {
search: {
enabled: true,
provider: "brave",
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
plugins?: {
entries?: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
};
tools?: { web?: { search?: { provider?: string } } };
};
expect(cfg.plugins?.entries?.["tavily-search"]?.enabled).toBe(false);
expect(cfg.plugins?.entries?.["tavily-search"]?.config).toBeUndefined();
expect(cfg.tools?.web?.search?.provider).toBe("brave");
const validated = validateConfigObjectWithPlugins(cfg);
expect(validated.ok).toBe(true);
});
it("does not delete missing plugin entries during repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
plugins: {
allow: ["webchat"],
entries: {
webchat: {
enabled: true,
config: {
port: 3000,
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
plugins?: {
allow?: string[];
entries?: Record<string, unknown>;
};
};
expect(cfg.plugins?.entries?.webchat).toBeDefined();
expect(cfg.plugins?.allow).toContain("webchat");
const validated = validateConfigObjectWithPlugins(cfg);
expect(validated.ok).toBe(false);
expect(
validated.warnings.some(
(warning) =>
warning.path === "plugins.entries.webchat" &&
warning.message.includes("plugin not found"),
),
).toBe(true);
expect(
validated.issues.some(
(issue) => issue.path === "plugins.allow" && issue.message.includes("plugin not found"),
),
).toBe(true);
});
it("clears active web search provider when it points at a repaired plugin", async () => {
const tavilyPath = path.join(process.cwd(), "extensions", "tavily-search");
const result = await runDoctorConfigWithInput({
repair: true,
config: {
plugins: {
load: { paths: [tavilyPath] },
allow: ["tavily-search"],
entries: {
"tavily-search": {
enabled: true,
config: {
apiKey: "not-a-real-key",
searchDepth: "basic",
},
},
},
},
tools: {
web: {
search: {
enabled: true,
provider: "tavily",
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
plugins?: {
entries?: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
};
tools?: { web?: { search?: { provider?: string } } };
};
expect(cfg.plugins?.entries?.["tavily-search"]?.enabled).toBe(false);
expect(cfg.plugins?.entries?.["tavily-search"]?.config).toBeUndefined();
expect(cfg.tools?.web?.search?.provider).toBeUndefined();
const validated = validateConfigObjectWithPlugins(cfg);
expect(validated.ok).toBe(true);
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@ -16,7 +16,12 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gatewa
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
import { listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
import {
CONFIG_PATH,
migrateLegacyConfig,
readConfigFileSnapshot,
validateConfigObjectWithPlugins,
} from "../config/config.js";
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
@ -79,6 +84,181 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function parseConfigPath(pathLabel: string): Array<string | number> | null {
if (!pathLabel || pathLabel === "<root>") {
return [];
}
const parts: Array<string | number> = [];
let token = "";
for (let i = 0; i < pathLabel.length; i += 1) {
const ch = pathLabel[i];
if (ch === ".") {
if (token) {
parts.push(token);
token = "";
}
continue;
}
if (ch === "[") {
if (token) {
parts.push(token);
token = "";
}
const end = pathLabel.indexOf("]", i);
if (end === -1) {
return null;
}
const indexText = pathLabel.slice(i + 1, end);
const index = Number.parseInt(indexText, 10);
if (!Number.isInteger(index)) {
return null;
}
parts.push(index);
i = end;
continue;
}
token += ch;
}
if (token) {
parts.push(token);
}
return parts;
}
function deleteConfigPath(root: unknown, path: Array<string | number>): boolean {
if (path.length === 0) {
return false;
}
let current: unknown = root;
for (let i = 0; i < path.length - 1; i += 1) {
const part = path[i];
if (typeof part === "number") {
if (!Array.isArray(current) || part < 0 || part >= current.length) {
return false;
}
current = current[part];
continue;
}
if (!current || typeof current !== "object" || Array.isArray(current)) {
return false;
}
const record = current as Record<string, unknown>;
if (!(part in record)) {
return false;
}
current = record[part];
}
const leaf = path[path.length - 1];
if (typeof leaf === "number") {
if (!Array.isArray(current) || leaf < 0 || leaf >= current.length) {
return false;
}
current.splice(leaf, 1);
return true;
}
if (!current || typeof current !== "object" || Array.isArray(current)) {
return false;
}
const record = current as Record<string, unknown>;
if (!(leaf in record)) {
return false;
}
delete record[leaf];
return true;
}
function maybeRepairInvalidPluginConfig(candidate: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
const validation = validateConfigObjectWithPlugins(candidate);
if (validation.ok) {
return { config: candidate, changes: [] };
}
const next = structuredClone(candidate);
const changes: string[] = [];
const affectedPluginIds = new Set<string>();
for (const issue of validation.issues) {
if (!issue.path.startsWith("plugins.entries.")) {
continue;
}
if (!issue.message.startsWith("invalid config:")) {
continue;
}
const parts = parseConfigPath(issue.path);
if (!parts || parts.length <= 4) {
continue;
}
if (
parts[0] !== "plugins" ||
parts[1] !== "entries" ||
typeof parts[2] !== "string" ||
parts[3] !== "config"
) {
continue;
}
const pluginId = parts[2];
if (deleteConfigPath(next, parts)) {
affectedPluginIds.add(pluginId);
changes.push(
`- Removed invalid plugin config value at ${issue.path}; re-run configure to re-enter a valid value if you still want this plugin enabled.`,
);
}
}
if (changes.length === 0) {
return { config: candidate, changes: [] };
}
const revalidated = validateConfigObjectWithPlugins(next);
if (!revalidated.ok) {
for (const pluginId of affectedPluginIds) {
const configRoot = `plugins.entries.${pluginId}.config`;
const stillInvalid = revalidated.issues.some((issue) => issue.path.startsWith(configRoot));
if (!stillInvalid) {
continue;
}
const pluginEntry = next.plugins?.entries?.[pluginId];
if (pluginEntry) {
delete pluginEntry.config;
pluginEntry.enabled = false;
}
changes.push(
`- Disabled plugin ${pluginId} and cleared its config because required plugin settings were still incomplete after removing invalid values.`,
);
}
}
const finalValidation = validateConfigObjectWithPlugins(next);
if (!finalValidation.ok) {
const activeProvider = next.tools?.web?.search?.provider?.trim().toLowerCase();
const hasProviderIssue = finalValidation.issues.some(
(issue) =>
issue.path === "tools.web.search.provider" &&
issue.message.startsWith("unknown web search provider:"),
);
if (hasProviderIssue && activeProvider) {
if (next.tools?.web?.search) {
delete next.tools.web.search.provider;
changes.push(
`- Cleared tools.web.search.provider because it referenced repaired plugin provider "${activeProvider}", which is no longer available after the config cleanup.`,
);
}
}
}
return { config: next, changes };
}
function maybeRepairMissingPluginEntries(candidate: OpenClawConfig): {
config: OpenClawConfig;
changes: string[];
} {
return { config: candidate, changes: [] };
}
function normalizeBindingChannelKey(raw?: string | null): string {
const normalized = normalizeChatChannelId(raw);
if (normalized) {
@ -1824,6 +2004,22 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
if (safeBinProfileRepair.warnings.length > 0) {
note(safeBinProfileRepair.warnings.join("\n"), "Doctor warnings");
}
const missingPluginEntryRepair = maybeRepairMissingPluginEntries(candidate);
if (missingPluginEntryRepair.changes.length > 0) {
note(missingPluginEntryRepair.changes.join("\n"), "Doctor changes");
candidate = missingPluginEntryRepair.config;
pendingChanges = true;
cfg = missingPluginEntryRepair.config;
}
const invalidPluginConfigRepair = maybeRepairInvalidPluginConfig(candidate);
if (invalidPluginConfigRepair.changes.length > 0) {
note(invalidPluginConfigRepair.changes.join("\n"), "Doctor changes");
candidate = invalidPluginConfigRepair.config;
pendingChanges = true;
cfg = invalidPluginConfigRepair.config;
}
} else {
const hits = scanTelegramAllowFromUsernameEntries(candidate);
if (hits.length > 0) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,10 @@ const installPluginFromNpmSpec = vi.fn();
vi.mock("../../plugins/install.js", () => ({
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
}));
const loadPluginManifest = vi.fn();
vi.mock("../../plugins/manifest.js", () => ({
loadPluginManifest: (...args: unknown[]) => loadPluginManifest(...args),
}));
const resolveBundledPluginSources = vi.fn();
vi.mock("../../plugins/bundled-sources.js", () => ({
@ -61,6 +65,7 @@ import { loadOpenClawPlugins } from "../../plugins/loader.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import { makePrompter, makeRuntime } from "./__tests__/test-utils.js";
import {
ensureGenericOnboardingPluginInstalled,
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "./plugin-install.js";
@ -84,6 +89,7 @@ const baseEntry: ChannelPluginCatalogEntry = {
beforeEach(() => {
vi.clearAllMocks();
resolveBundledPluginSources.mockReturnValue(new Map());
loadPluginManifest.mockReset();
});
function mockRepoLocalPathExists() {
@ -93,12 +99,18 @@ function mockRepoLocalPathExists() {
});
}
async function runInitialValueForChannel(channel: "dev" | "beta") {
async function runPromptShapeForChannel(channel: "dev" | "beta") {
const runtime = makeRuntime();
const select = vi.fn((async <T extends string>() => "skip" as T) as WizardPrompter["select"]);
const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] });
const text = vi.fn(async () => "");
const prompter = makePrompter({
text: text as unknown as WizardPrompter["text"],
});
const cfg: OpenClawConfig = { update: { channel } };
mockRepoLocalPathExists();
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "nope",
});
await ensureOnboardingPluginInstalled({
cfg,
@ -107,8 +119,8 @@ async function runInitialValueForChannel(channel: "dev" | "beta") {
runtime,
});
const call = select.mock.calls[0];
return call?.[0]?.initialValue;
const call = text.mock.calls[0];
return call?.[0];
}
function expectPluginLoadedFromLocalPath(
@ -123,7 +135,7 @@ describe("ensureOnboardingPluginInstalled", () => {
it("installs from npm and enables the plugin", async () => {
const runtime = makeRuntime();
const prompter = makePrompter({
select: vi.fn(async () => "npm") as WizardPrompter["select"],
text: vi.fn(async () => "@openclaw/zalo") as WizardPrompter["text"],
});
const cfg: OpenClawConfig = { plugins: { allow: ["other"] } };
vi.mocked(fs.existsSync).mockReturnValue(false);
@ -152,10 +164,39 @@ describe("ensureOnboardingPluginInstalled", () => {
);
});
it("uses local path when selected", async () => {
it("accepts scoped npm package names without treating them as missing paths", async () => {
const runtime = makeRuntime();
const prompter = makePrompter({
select: vi.fn(async () => "local") as WizardPrompter["select"],
text: vi.fn(async () => "@openclaw/zalo") as WizardPrompter["text"],
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockReturnValue(false);
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "zalo",
targetDir: "/tmp/zalo",
extensions: [],
});
const result = await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@openclaw/zalo" }),
);
});
it("uses local path when selected", async () => {
const runtime = makeRuntime();
const note = vi.fn(async () => {});
const prompter = makePrompter({
text: vi.fn(async () => "extensions/zalo") as WizardPrompter["text"],
note,
});
const cfg: OpenClawConfig = {};
mockRepoLocalPathExists();
@ -169,22 +210,41 @@ describe("ensureOnboardingPluginInstalled", () => {
expectPluginLoadedFromLocalPath(result);
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
expect(note).toHaveBeenCalledWith(
`Using existing local plugin at ${path.resolve(process.cwd(), "extensions/zalo")}.\nNo download needed.`,
"Plugin install",
);
});
it("defaults to local on dev channel when local path exists", async () => {
expect(await runInitialValueForChannel("dev")).toBe("local");
it("uses a generic placeholder without prefilled local value on dev channel", async () => {
expect(await runPromptShapeForChannel("dev")).toEqual(
expect.objectContaining({
message: "npm package or local path",
placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)",
}),
);
});
it("defaults to npm on beta channel even when local path exists", async () => {
expect(await runInitialValueForChannel("beta")).toBe("npm");
it("uses the same generic placeholder without prefilled npm value on beta channel", async () => {
expect(await runPromptShapeForChannel("beta")).toEqual(
expect.objectContaining({
message: "npm package or local path",
placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)",
}),
);
});
it("defaults to bundled local path on beta channel when available", async () => {
const runtime = makeRuntime();
const select = vi.fn((async <T extends string>() => "skip" as T) as WizardPrompter["select"]);
const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] });
const text = vi.fn(async () => "");
const prompter = makePrompter({
text: text as unknown as WizardPrompter["text"],
});
const cfg: OpenClawConfig = { update: { channel: "beta" } };
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return raw === "/opt/openclaw/extensions/zalo" || raw.endsWith(`${path.sep}.git`);
});
resolveBundledPluginSources.mockReturnValue(
new Map([
[
@ -205,15 +265,10 @@ describe("ensureOnboardingPluginInstalled", () => {
runtime,
});
expect(select).toHaveBeenCalledWith(
expect(text).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: "local",
options: expect.arrayContaining([
expect.objectContaining({
value: "local",
hint: "/opt/openclaw/extensions/zalo",
}),
]),
message: "npm package or local path",
placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)",
}),
);
});
@ -223,7 +278,7 @@ describe("ensureOnboardingPluginInstalled", () => {
const note = vi.fn(async () => {});
const confirm = vi.fn(async () => true);
const prompter = makePrompter({
select: vi.fn(async () => "npm") as WizardPrompter["select"],
text: vi.fn(async () => "@openclaw/zalo") as WizardPrompter["text"],
note,
confirm,
});
@ -242,10 +297,153 @@ describe("ensureOnboardingPluginInstalled", () => {
});
expectPluginLoadedFromLocalPath(result);
expect(note).toHaveBeenCalled();
expect(note).toHaveBeenCalledWith(`Failed to install @openclaw/zalo: nope`, "Plugin install");
expect(note).toHaveBeenCalledWith(
`Using existing local plugin at ${path.resolve(process.cwd(), "extensions/zalo")}.\nNo download needed.`,
"Plugin install",
);
expect(runtime.error).not.toHaveBeenCalled();
});
it("re-prompts when a path-like input does not exist", async () => {
const runtime = makeRuntime();
const note = vi.fn(async () => {});
const text = vi
.fn()
.mockResolvedValueOnce("./missing-plugin")
.mockResolvedValueOnce("@openclaw/zalo");
const prompter = makePrompter({
text: text as unknown as WizardPrompter["text"],
note,
});
const cfg: OpenClawConfig = {};
mockRepoLocalPathExists();
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "zalo",
targetDir: "/tmp/zalo",
extensions: [],
});
const result = await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(note).toHaveBeenCalledWith("Path not found: ./missing-plugin", "Plugin install");
expect(text).toHaveBeenCalledTimes(2);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@openclaw/zalo" }),
);
});
it("re-prompts when the entered npm package does not match the selected provider", async () => {
const runtime = makeRuntime();
const note = vi.fn(async () => {});
const text = vi
.fn()
.mockResolvedValueOnce("@other/provider")
.mockResolvedValueOnce("@openclaw/zalo");
const prompter = makePrompter({
text: text as unknown as WizardPrompter["text"],
note,
});
const cfg: OpenClawConfig = {};
mockRepoLocalPathExists();
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "zalo",
targetDir: "/tmp/zalo",
extensions: [],
});
const result = await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(note).toHaveBeenCalledWith(
"This flow installs @openclaw/zalo. Enter that npm package or a local plugin path.",
"Plugin install",
);
expect(text).toHaveBeenCalledTimes(2);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@openclaw/zalo" }),
);
});
it("returns unchanged config when install input is left blank", async () => {
const runtime = makeRuntime();
const text = vi.fn(async () => "");
const prompter = makePrompter({
text: text as unknown as WizardPrompter["text"],
});
const cfg: OpenClawConfig = {};
const result = await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
});
expect(result.installed).toBe(false);
expect(result.cfg).toBe(cfg);
expect(text).toHaveBeenCalledTimes(1);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("suppresses local path affordance when local paths are unavailable", async () => {
const runtime = makeRuntime();
const note = vi.fn(async () => {});
const text = vi
.fn()
.mockResolvedValueOnce("extensions/zalo")
.mockResolvedValueOnce("@openclaw/zalo");
const prompter = makePrompter({
text: text as unknown as WizardPrompter["text"],
note,
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockReturnValue(false);
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "zalo",
targetDir: "/tmp/zalo",
extensions: [],
});
const result = await ensureOnboardingPluginInstalled({
cfg,
entry: baseEntry,
prompter,
runtime,
workspaceDir: "/tmp/no-git-workspace",
});
expect(result.installed).toBe(true);
expect(note).toHaveBeenCalledWith(
"Local plugin paths are unavailable here. Enter an npm package.",
"Plugin install",
);
expect(text).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
message: "npm package",
placeholder: "@scope/plugin-name (leave blank to skip)",
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@openclaw/zalo" }),
);
});
it("clears discovery cache before reloading the onboarding plugin registry", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
@ -269,3 +467,87 @@ describe("ensureOnboardingPluginInstalled", () => {
);
});
});
describe("ensureGenericOnboardingPluginInstalled", () => {
it("installs an arbitrary scoped npm package", async () => {
const runtime = makeRuntime();
const prompter = makePrompter({
text: vi.fn(async () => "@other/provider") as WizardPrompter["text"],
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockReturnValue(false);
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "external-search",
targetDir: "/tmp/external-search",
extensions: [],
});
const result = await ensureGenericOnboardingPluginInstalled({
cfg,
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(result.pluginId).toBe("external-search");
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({ spec: "@other/provider" }),
);
});
it("links an arbitrary existing local plugin path and derives its plugin id from the manifest", async () => {
const runtime = makeRuntime();
const note = vi.fn(async () => {});
const prompter = makePrompter({
text: vi.fn(async () => "extensions/external-search") as WizardPrompter["text"],
note,
});
const cfg: OpenClawConfig = {};
vi.mocked(fs.existsSync).mockImplementation((value) => {
const raw = String(value);
return (
raw.endsWith(`${path.sep}.git`) ||
raw.endsWith(`${path.sep}extensions${path.sep}external-search`)
);
});
loadPluginManifest.mockReturnValue({
ok: true,
manifest: { id: "external-search" },
});
const result = await ensureGenericOnboardingPluginInstalled({
cfg,
prompter,
runtime,
});
expect(result.installed).toBe(true);
expect(result.pluginId).toBe("external-search");
expect(result.cfg.plugins?.load?.paths).toContain(
path.resolve(process.cwd(), "extensions/external-search"),
);
expect(note).toHaveBeenCalledWith(
`Using existing local plugin at ${path.resolve(process.cwd(), "extensions/external-search")}.\nNo download needed.`,
"Plugin install",
);
});
it("skips cleanly when the generic install input is blank", async () => {
const runtime = makeRuntime();
const prompter = makePrompter({
text: vi.fn(async () => "") as WizardPrompter["text"],
});
const cfg: OpenClawConfig = {};
const result = await ensureGenericOnboardingPluginInstalled({
cfg,
prompter,
runtime,
});
expect(result.installed).toBe(false);
expect(result.cfg).toBe(cfg);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
});

View File

@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import { resolveBundledInstallPlanForCatalogEntry } from "../../cli/plugin-install-plan.js";
import type { OpenClawConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@ -15,14 +14,26 @@ import { installPluginFromNpmSpec } from "../../plugins/install.js";
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { createPluginLoaderLogger } from "../../plugins/logger.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
type InstallChoice = "npm" | "local" | "skip";
export type InstallablePluginCatalogEntry = {
id: string;
meta: {
label: string;
};
install: {
npmSpec: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
};
type InstallResult = {
cfg: OpenClawConfig;
installed: boolean;
pluginId?: string;
};
function hasGitWorkspace(workspaceDir?: string): boolean {
@ -40,7 +51,7 @@ function hasGitWorkspace(workspaceDir?: string): boolean {
}
function resolveLocalPath(
entry: ChannelPluginCatalogEntry,
entry: InstallablePluginCatalogEntry,
workspaceDir: string | undefined,
allowLocal: boolean,
): string | null {
@ -64,6 +75,31 @@ function resolveLocalPath(
return null;
}
function resolveExistingPath(
rawValue: string,
workspaceDir: string | undefined,
allowLocal: boolean,
): string | null {
if (!allowLocal) {
return null;
}
const raw = rawValue.trim();
if (!raw) {
return null;
}
const candidates = new Set<string>();
candidates.add(path.resolve(process.cwd(), raw));
if (workspaceDir && workspaceDir !== process.cwd()) {
candidates.add(path.resolve(workspaceDir, raw));
}
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawConfig {
const existing = cfg.plugins?.load?.paths ?? [];
const merged = Array.from(new Set([...existing, pluginPath]));
@ -80,65 +116,100 @@ function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawCon
}
async function promptInstallChoice(params: {
entry: ChannelPluginCatalogEntry;
localPath?: string | null;
defaultChoice: InstallChoice;
prompter: WizardPrompter;
}): Promise<InstallChoice> {
const { entry, localPath, prompter, defaultChoice } = params;
const localOptions: Array<{ value: InstallChoice; label: string; hint?: string }> = localPath
? [
{
value: "local",
label: "Use local plugin path",
hint: localPath,
},
]
: [];
const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [
{ value: "npm", label: `Download from npm (${entry.install.npmSpec})` },
...localOptions,
{ value: "skip", label: "Skip for now" },
];
const initialValue: InstallChoice =
defaultChoice === "local" && !localPath ? "npm" : defaultChoice;
return await prompter.select<InstallChoice>({
message: `Install ${entry.meta.label} plugin?`,
options,
initialValue,
});
workspaceDir?: string;
allowLocal: boolean;
expectedNpmSpec?: string;
}): Promise<string | null> {
const { prompter, workspaceDir, allowLocal, expectedNpmSpec } = params;
const message = allowLocal ? "npm package or local path" : "npm package";
const placeholder = allowLocal
? "@scope/plugin-name or extensions/plugin-name (leave blank to skip)"
: "@scope/plugin-name (leave blank to skip)";
while (true) {
const source = (
await prompter.text({
message,
placeholder,
})
).trim();
if (!source) {
return null;
}
const existingPath = resolveExistingPath(source, workspaceDir, allowLocal);
if (existingPath) {
return existingPath;
}
const looksLikePath = isLikelyLocalPath(source);
if (looksLikePath) {
await prompter.note(
allowLocal
? `Path not found: ${source}`
: "Local plugin paths are unavailable here. Enter an npm package.",
"Plugin install",
);
continue;
}
if (expectedNpmSpec && !matchesCatalogNpmSpec(source, expectedNpmSpec)) {
await prompter.note(
allowLocal
? `This flow installs ${expectedNpmSpec}. Enter that npm package or a local plugin path.`
: `This flow installs ${expectedNpmSpec}. Enter that npm package.`,
"Plugin install",
);
continue;
}
return source;
}
}
function resolveInstallDefaultChoice(params: {
cfg: OpenClawConfig;
entry: ChannelPluginCatalogEntry;
localPath?: string | null;
bundledLocalPath?: string | null;
}): InstallChoice {
const { cfg, entry, localPath, bundledLocalPath } = params;
if (bundledLocalPath) {
return "local";
function isLikelyLocalPath(source: string): boolean {
const trimmed = source.trim();
if (!trimmed) {
return false;
}
const updateChannel = cfg.update?.channel;
if (updateChannel === "dev") {
return localPath ? "local" : "npm";
if (trimmed.startsWith(".") || trimmed.startsWith("/") || trimmed.startsWith("~")) {
return true;
}
if (updateChannel === "stable" || updateChannel === "beta") {
return "npm";
if (trimmed.includes("\\")) {
return true;
}
const entryDefault = entry.install.defaultChoice;
if (entryDefault === "local") {
return localPath ? "local" : "npm";
if (trimmed.startsWith("@")) {
return false;
}
if (entryDefault === "npm") {
return "npm";
return trimmed.includes("/");
}
function parseNpmPackageName(spec: string): string {
const trimmed = spec.trim();
if (!trimmed) {
return trimmed;
}
return localPath ? "local" : "npm";
if (trimmed.startsWith("@")) {
const slashIndex = trimmed.indexOf("/");
if (slashIndex === -1) {
return trimmed;
}
const versionIndex = trimmed.indexOf("@", slashIndex + 1);
return versionIndex === -1 ? trimmed : trimmed.slice(0, versionIndex);
}
const versionIndex = trimmed.indexOf("@");
return versionIndex === -1 ? trimmed : trimmed.slice(0, versionIndex);
}
function matchesCatalogNpmSpec(input: string, expectedSpec: string): boolean {
return parseNpmPackageName(input) === parseNpmPackageName(expectedSpec);
}
export async function ensureOnboardingPluginInstalled(params: {
cfg: OpenClawConfig;
entry: ChannelPluginCatalogEntry;
entry: InstallablePluginCatalogEntry;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir?: string;
@ -155,31 +226,29 @@ export async function ensureOnboardingPluginInstalled(params: {
findBundledPluginSourceInMap({ bundled: bundledSources, lookup }),
})?.bundledSource.localPath ?? null;
const localPath = bundledLocalPath ?? resolveLocalPath(entry, workspaceDir, allowLocal);
const defaultChoice = resolveInstallDefaultChoice({
cfg: next,
entry,
localPath,
bundledLocalPath,
});
const choice = await promptInstallChoice({
entry,
localPath,
defaultChoice,
const source = await promptInstallChoice({
prompter,
workspaceDir,
allowLocal,
expectedNpmSpec: entry.install.npmSpec,
});
if (choice === "skip") {
if (!source) {
return { cfg: next, installed: false };
}
if (choice === "local" && localPath) {
next = addPluginLoadPath(next, localPath);
if (isLikelyLocalPath(source)) {
await prompter.note(
[`Using existing local plugin at ${source}.`, "No download needed."].join("\n"),
"Plugin install",
);
next = addPluginLoadPath(next, source);
next = enablePluginInConfig(next, entry.id).config;
return { cfg: next, installed: true };
return { cfg: next, installed: true, pluginId: entry.id };
}
const result = await installPluginFromNpmSpec({
spec: entry.install.npmSpec,
spec: source,
logger: {
info: (msg) => runtime.log?.(msg),
warn: (msg) => runtime.log?.(msg),
@ -191,18 +260,15 @@ export async function ensureOnboardingPluginInstalled(params: {
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "npm",
spec: entry.install.npmSpec,
spec: source,
installPath: result.targetDir,
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
});
return { cfg: next, installed: true };
return { cfg: next, installed: true, pluginId: result.pluginId };
}
await prompter.note(
`Failed to install ${entry.install.npmSpec}: ${result.error}`,
"Plugin install",
);
await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install");
if (localPath) {
const fallback = await prompter.confirm({
@ -210,9 +276,13 @@ export async function ensureOnboardingPluginInstalled(params: {
initialValue: true,
});
if (fallback) {
await prompter.note(
[`Using existing local plugin at ${localPath}.`, "No download needed."].join("\n"),
"Plugin install",
);
next = addPluginLoadPath(next, localPath);
next = enablePluginInConfig(next, entry.id).config;
return { cfg: next, installed: true };
return { cfg: next, installed: true, pluginId: entry.id };
}
}
@ -220,10 +290,74 @@ export async function ensureOnboardingPluginInstalled(params: {
return { cfg: next, installed: false };
}
export async function ensureGenericOnboardingPluginInstalled(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir?: string;
}): Promise<InstallResult> {
const { prompter, runtime, workspaceDir } = params;
let next = params.cfg;
const allowLocal = hasGitWorkspace(workspaceDir);
const source = await promptInstallChoice({
prompter,
workspaceDir,
allowLocal,
});
if (!source) {
return { cfg: next, installed: false };
}
if (isLikelyLocalPath(source)) {
const manifestRes = loadPluginManifest(source, false);
if (!manifestRes.ok) {
await prompter.note(
`Failed to load plugin from ${source}: ${manifestRes.error}`,
"Plugin install",
);
return { cfg: next, installed: false };
}
await prompter.note(
[`Using existing local plugin at ${source}.`, "No download needed."].join("\n"),
"Plugin install",
);
next = addPluginLoadPath(next, source);
next = enablePluginInConfig(next, manifestRes.manifest.id).config;
return { cfg: next, installed: true, pluginId: manifestRes.manifest.id };
}
const result = await installPluginFromNpmSpec({
spec: source,
logger: {
info: (msg) => runtime.log?.(msg),
warn: (msg) => runtime.log?.(msg),
},
});
if (result.ok) {
next = enablePluginInConfig(next, result.pluginId).config;
next = recordPluginInstall(next, {
pluginId: result.pluginId,
source: "npm",
spec: source,
installPath: result.targetDir,
version: result.version,
...buildNpmResolutionInstallFields(result.npmResolution),
});
return { cfg: next, installed: true, pluginId: result.pluginId };
}
await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install");
runtime.error?.(`Plugin install failed: ${result.error}`);
return { cfg: next, installed: false };
}
export function reloadOnboardingPluginRegistry(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
workspaceDir?: string;
suppressOpenAllowlistWarning?: boolean;
}): void {
clearPluginDiscoveryCache();
const workspaceDir =
@ -234,5 +368,6 @@ export function reloadOnboardingPluginRegistry(params: {
workspaceDir,
cache: false,
logger: createPluginLoaderLogger(log),
suppressOpenAllowlistWarning: params.suppressOpenAllowlistWarning,
});
}

View File

@ -0,0 +1,78 @@
import type { WizardPrompter } from "../wizard/prompts.js";
export type ProviderManagementIntent = "switch-active" | "configure-provider";
export type ProviderManagementOption<T extends string = string> = {
value: T;
label: string;
hint?: string;
};
type PromptProviderManagementIntentParams = {
prompter: WizardPrompter;
message: string;
includeSkipOption: boolean;
configuredCount: number;
configureValue: string;
switchValue: string;
skipValue: string;
configureLabel: string;
configureHint?: string;
switchLabel: string;
switchHint?: string;
skipLabel?: string;
skipHint?: string;
};
export async function promptProviderManagementIntent(
params: PromptProviderManagementIntentParams,
): Promise<string> {
if (params.configuredCount <= 1) {
return "switch-active";
}
return await params.prompter.select<string>({
message: params.message,
options: [
{
value: params.configureValue,
label: params.configureLabel,
hint: params.configureHint,
},
{
value: params.switchValue,
label: params.switchLabel,
hint: params.switchHint,
},
...(params.includeSkipOption
? [
{
value: params.skipValue,
label: params.skipLabel ?? "Skip for now",
hint: params.skipHint,
},
]
: []),
],
initialValue: params.configureValue,
});
}
export function buildProviderSelectionOptions<T extends string>(params: {
intent: ProviderManagementIntent;
options: Array<ProviderManagementOption<T>>;
activeValue?: string;
activeSuffix?: string;
hiddenValues?: Iterable<string>;
}): Array<ProviderManagementOption<T>> {
const hiddenValues = new Set(params.hiddenValues ?? []);
return params.options
.filter((option) => !hiddenValues.has(option.value))
.map((option) =>
option.value === params.activeValue
? {
...option,
label: `${option.label}${params.activeSuffix ?? " [Active]"}`.trim(),
}
: option,
);
}

View File

@ -22,6 +22,7 @@ async function writePluginFixture(params: {
id: string;
schema: Record<string, unknown>;
channels?: string[];
manifest?: Record<string, unknown>;
}) {
await mkdirSafe(params.dir);
await fs.writeFile(
@ -32,6 +33,7 @@ async function writePluginFixture(params: {
const manifest: Record<string, unknown> = {
id: params.id,
configSchema: params.schema,
...params.manifest,
};
if (params.channels) {
manifest.channels = params.channels;
@ -60,6 +62,10 @@ describe("config plugin validation", () => {
CLAWDBOT_STATE_DIR: undefined,
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "10000",
}) satisfies NodeJS.ProcessEnv;
let capabilityProviderDir = "";
let capabilityConsumerDir = "";
let capabilityConflictADir = "";
let capabilityConflictBDir = "";
const validateInSuite = (raw: unknown) =>
validateConfigObjectWithPlugins(raw, { env: suiteEnv() });
@ -104,6 +110,43 @@ describe("config plugin validation", () => {
channels: ["bluebubbles"],
schema: { type: "object" },
});
capabilityProviderDir = path.join(suiteHome, "capability-provider");
await writePluginFixture({
dir: capabilityProviderDir,
id: "capability-provider",
schema: { type: "object" },
manifest: {
provides: ["providers.embedding.fixture"],
},
});
capabilityConsumerDir = path.join(suiteHome, "capability-consumer");
await writePluginFixture({
dir: capabilityConsumerDir,
id: "capability-consumer",
schema: { type: "object" },
manifest: {
requires: ["providers.embedding.fixture"],
},
});
capabilityConflictADir = path.join(suiteHome, "capability-conflict-a");
await writePluginFixture({
dir: capabilityConflictADir,
id: "capability-conflict-a",
schema: { type: "object" },
manifest: {
provides: ["memory.backend.fixtureA"],
},
});
capabilityConflictBDir = path.join(suiteHome, "capability-conflict-b");
await writePluginFixture({
dir: capabilityConflictBDir,
id: "capability-conflict-b",
schema: { type: "object" },
manifest: {
provides: ["memory.backend.fixtureB"],
conflicts: ["memory.backend.*"],
},
});
voiceCallSchemaPluginDir = path.join(suiteHome, "voice-call-schema-plugin");
const voiceCallManifestPath = path.join(
process.cwd(),
@ -236,6 +279,84 @@ describe("config plugin validation", () => {
}
});
it("warns when a plugin is missing a declared required capability", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [capabilityConsumerDir] },
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.warnings).toContainEqual({
path: "plugins.entries.capability-consumer",
message:
"plugin capability-consumer: missing required capability: providers.embedding.fixture",
});
}
});
it("does not warn when a declared required capability is present", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [capabilityProviderDir, capabilityConsumerDir] },
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(
res.warnings.some((warning) => warning.message.includes("missing required capability")),
).toBe(false);
}
});
it("errors when a plugin declares a conflicting capability pattern", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [capabilityConflictADir, capabilityConflictBDir] },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "plugins.entries.capability-conflict-b",
message:
"plugin capability-conflict-b: conflicting capability present: memory.backend.* (capability-conflict-a)",
});
}
});
it("routes missing slot selection diagnostics to the slot config path", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
slots: { memory: "missing-memory-backend" },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "plugins.slots.memory",
message: "plugin not found: missing-memory-backend",
});
expect(res.warnings).toContainEqual({
path: "plugins.slots.memory",
message:
"plugin: memory slot plugin not found or not marked as memory: missing-memory-backend",
});
}
});
it("surfaces allowed enum values for plugin config diagnostics", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },

View File

@ -1,15 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { validateConfigObject } from "./config.js";
import { createBundledBraveSearchProvider } from "../../extensions/search-brave/src/provider.js";
import { createBundledGeminiSearchProvider } from "../../extensions/search-gemini/src/provider.js";
import { createBundledGrokSearchProvider } from "../../extensions/search-grok/src/provider.js";
import { createBundledKimiSearchProvider } from "../../extensions/search-kimi/src/provider.js";
import { createBundledPerplexitySearchProvider } from "../../extensions/search-perplexity/src/provider.js";
import { validateConfigObject, validateConfigObjectWithPlugins } from "./config.js";
import { buildWebSearchProviderConfig } from "./test-helpers.js";
const loadOpenClawPlugins = vi.hoisted(() => vi.fn(() => ({ searchProviders: [] as unknown[] })));
vi.mock("../runtime.js", () => ({
defaultRuntime: { log: vi.fn(), error: vi.fn() },
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
getOAuthApiKey: vi.fn(async () => null),
getOAuthProviders: () => [],
}));
const { __testing } = await import("../agents/tools/web-search.js");
const { resolveSearchProvider } = __testing;
const bundledSearchProviders = [
{ pluginId: "search-brave", provider: createBundledBraveSearchProvider() },
{ pluginId: "search-gemini", provider: createBundledGeminiSearchProvider() },
{ pluginId: "search-grok", provider: createBundledGrokSearchProvider() },
{ pluginId: "search-kimi", provider: createBundledKimiSearchProvider() },
{ pluginId: "search-perplexity", provider: createBundledPerplexitySearchProvider() },
];
function resolveSearchProviderId(search: Record<string, unknown>) {
return resolveSearchProvider({
config: {
tools: {
web: {
search,
},
},
},
}).id;
}
describe("web search provider config", () => {
beforeEach(() => {
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: [] });
});
it("accepts custom plugin provider ids", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "searxng",
}),
);
expect(res.ok).toBe(true);
});
it("rejects unknown custom plugin provider ids during plugin-aware validation", () => {
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "brvae",
}),
);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((issue) => issue.path === "tools.web.search.provider")).toBe(true);
}
});
it("accepts registered custom plugin provider ids during plugin-aware validation", () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
provider: {
id: "searxng",
},
},
],
});
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "searxng",
}),
);
expect(res.ok).toBe(true);
});
it("surfaces plugin loading failures during plugin-aware validation", () => {
loadOpenClawPlugins.mockImplementation(() => {
throw new Error("plugin import failed");
});
const res = validateConfigObjectWithPlugins(
buildWebSearchProviderConfig({
provider: "searxng",
}),
);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(
res.issues.some(
(issue) =>
issue.path === "tools.web.search.provider" &&
issue.message.includes("plugin loading failed") &&
issue.message.includes("plugin import failed"),
),
).toBe(true);
}
});
it("rejects invalid custom plugin provider ids", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "SearXNG!",
}),
);
expect(res.ok).toBe(false);
});
it("accepts perplexity provider and config", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
@ -82,6 +200,8 @@ describe("web search provider auto-detection", () => {
const savedEnv = { ...process.env };
beforeEach(() => {
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: bundledSearchProviders });
delete process.env.BRAVE_API_KEY;
delete process.env.GEMINI_API_KEY;
delete process.env.KIMI_API_KEY;
@ -99,47 +219,47 @@ describe("web search provider auto-detection", () => {
});
it("falls back to brave when no keys available", () => {
expect(resolveSearchProvider({})).toBe("brave");
expect(resolveSearchProviderId({})).toBe("brave");
});
it("auto-detects brave when only BRAVE_API_KEY is set", () => {
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("brave");
expect(resolveSearchProviderId({})).toBe("brave");
});
it("auto-detects gemini when only GEMINI_API_KEY is set", () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("gemini");
expect(resolveSearchProviderId({})).toBe("gemini");
});
it("auto-detects kimi when only KIMI_API_KEY is set", () => {
process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("kimi");
expect(resolveSearchProviderId({})).toBe("kimi");
});
it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => {
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("perplexity");
expect(resolveSearchProviderId({})).toBe("perplexity");
});
it("auto-detects perplexity when only OPENROUTER_API_KEY is set", () => {
process.env.OPENROUTER_API_KEY = "sk-or-v1-test"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("perplexity");
expect(resolveSearchProviderId({})).toBe("perplexity");
});
it("auto-detects grok when only XAI_API_KEY is set", () => {
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("grok");
expect(resolveSearchProviderId({})).toBe("grok");
});
it("auto-detects kimi when only KIMI_API_KEY is set", () => {
process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("kimi");
expect(resolveSearchProviderId({})).toBe("kimi");
});
it("auto-detects kimi when only MOONSHOT_API_KEY is set", () => {
process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("kimi");
expect(resolveSearchProviderId({})).toBe("kimi");
});
it("follows alphabetical order — brave wins when multiple keys available", () => {
@ -147,29 +267,25 @@ describe("web search provider auto-detection", () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("brave");
expect(resolveSearchProviderId({})).toBe("brave");
});
it("gemini wins over grok, kimi, and perplexity when brave unavailable", () => {
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("gemini");
expect(resolveSearchProviderId({})).toBe("gemini");
});
it("grok wins over kimi and perplexity when brave and gemini unavailable", () => {
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
expect(resolveSearchProvider({})).toBe("grok");
expect(resolveSearchProviderId({})).toBe("grok");
});
it("explicit provider always wins regardless of keys", () => {
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
expect(
resolveSearchProvider({ provider: "gemini" } as unknown as Parameters<
typeof resolveSearchProvider
>[0]),
).toBe("gemini");
expect(resolveSearchProviderId({ provider: "gemini" })).toBe("gemini");
});
});

View File

@ -457,8 +457,8 @@ export type ToolsConfig = {
search?: {
/** Enable web search tool (default: true when API key is present). */
enabled?: boolean;
/** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */
provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity";
/** Search provider id registered through the plugin system. */
provider?: string & {};
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
apiKey?: SecretInput;
/** Default search results count (1-10). */

View File

@ -1,11 +1,17 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
import {
resolveCapabilitySlotConfigPath,
resolveCapabilitySlotSelection,
type CapabilitySlotId,
} from "../plugins/capability-slots.js";
import {
normalizePluginsConfig,
resolveEffectiveEnableState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import {
@ -33,6 +39,24 @@ type AllowedValuesCollection = {
hasValues: boolean;
};
function resolvePluginDiagnosticPath(diag: {
pluginId?: string;
message: string;
code?: string;
slot?: string;
}): string {
if (diag.code === "plugin_path_not_found") {
return "plugins.load.paths";
}
if (diag.slot) {
return resolveCapabilitySlotConfigPath(diag.slot as CapabilitySlotId);
}
if (diag.pluginId) {
return `plugins.entries.${diag.pluginId}`;
}
return "plugins";
}
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
if (!value || typeof value !== "object") {
return null;
@ -355,10 +379,36 @@ function validateConfigObjectWithPluginsBase(
});
for (const diag of registry.diagnostics) {
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
path = "plugins.load.paths";
const path = resolvePluginDiagnosticPath(diag);
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`;
if (diag.level === "error") {
issues.push({ path, message });
} else {
warnings.push({ path, message });
}
}
const capabilityRegistry = loadOpenClawPlugins({
config,
workspaceDir: workspaceDir ?? undefined,
cache: false,
mode: "validate",
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
});
for (const diag of capabilityRegistry.diagnostics) {
if (diag.message.startsWith("invalid config:")) {
continue;
}
if (!diag.code?.startsWith("capability_")) {
continue;
}
const path = resolvePluginDiagnosticPath(diag);
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`;
if (diag.level === "error") {
@ -388,8 +438,54 @@ function validateConfigObjectWithPluginsBase(
return info.normalizedPlugins;
};
const validateWebSearchProvider = () => {
const provider = resolveCapabilitySlotSelection(config, "providers.search");
if (typeof provider !== "string") {
return;
}
const normalizedProvider = provider.trim().toLowerCase();
if (!normalizedProvider) {
return;
}
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
try {
const pluginRegistry = loadOpenClawPlugins({
config,
workspaceDir: workspaceDir ?? undefined,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
cache: false,
});
const registered = pluginRegistry.searchProviders.some(
(entry) => entry.provider.id === normalizedProvider,
);
if (registered) {
return;
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
issues.push({
path: "tools.web.search.provider",
message: `could not validate web search provider "${provider}" because plugin loading failed: ${detail}`,
});
return;
}
issues.push({
path: "tools.web.search.provider",
message: `unknown web search provider: ${provider}`,
});
};
const allowedChannels = new Set<string>(["defaults", "modelByChannel", ...CHANNEL_IDS]);
validateWebSearchProvider();
if (config.channels && isRecord(config.channels)) {
for (const key of Object.keys(config.channels)) {
const trimmed = key.trim();
@ -549,6 +645,7 @@ function validateConfigObjectWithPluginsBase(
origin: record.origin,
config: normalizedPlugins,
rootConfig: config,
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
});
let enabled = enableState.enabled;
let reason = enableState.reason;

View File

@ -263,13 +263,8 @@ export const ToolsWebSearchSchema = z
.object({
enabled: z.boolean().optional(),
provider: z
.union([
z.literal("brave"),
z.literal("perplexity"),
z.literal("grok"),
z.literal("gemini"),
z.literal("kimi"),
])
.string()
.regex(/^[a-z][a-z0-9_-]*$/, "provider id")
.optional(),
apiKey: SecretInputSchema.optional().register(sensitive),
maxResults: z.number().int().positive().optional(),

View File

@ -28,6 +28,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
channels: [],
commands: [],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@ -10,6 +10,7 @@ export const registryState: { registry: PluginRegistry } = {
typedHooks: [],
channels: [],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpHandlers: [],
httpRoutes: [],

View File

@ -145,6 +145,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
},
],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@ -107,6 +107,12 @@ describe("plugin-sdk exports", () => {
"probeTelegram",
"probeIMessage",
"probeSignal",
"createBundledSearchProviderAdapter",
"createBundledBraveSearchProvider",
"createBundledGeminiSearchProvider",
"createBundledGrokSearchProvider",
"createBundledKimiSearchProvider",
"createBundledPerplexitySearchProvider",
];
for (const key of forbidden) {

View File

@ -3,6 +3,7 @@ import * as discordSdk from "openclaw/plugin-sdk/discord";
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
import * as lineSdk from "openclaw/plugin-sdk/line";
import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
import * as requestUrlSdk from "openclaw/plugin-sdk/request-url";
import * as signalSdk from "openclaw/plugin-sdk/signal";
import * as slackSdk from "openclaw/plugin-sdk/slack";
import * as telegramSdk from "openclaw/plugin-sdk/telegram";
@ -99,6 +100,28 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function");
});
it("exports shared utility subpaths", async () => {
expect(typeof requestUrlSdk.resolveRequestUrl).toBe("function");
const booleanParamSdk = await import("openclaw/plugin-sdk/boolean-param");
expect(typeof booleanParamSdk.readBooleanParam).toBe("function");
const groupAccessSdk = await import("openclaw/plugin-sdk/group-access");
expect(typeof groupAccessSdk.evaluateGroupRouteAccessForPolicy).toBe("function");
const toolSendSdk = await import("openclaw/plugin-sdk/tool-send");
expect(typeof toolSendSdk.extractToolSend).toBe("function");
const accountResolutionSdk = await import("openclaw/plugin-sdk/account-resolution");
expect(typeof accountResolutionSdk.resolveAccountWithDefaultFallback).toBe("function");
const allowFromSdk = await import("openclaw/plugin-sdk/allow-from");
expect(typeof allowFromSdk.isAllowedParsedChatSender).toBe("function");
const jsonStoreSdk = await import("openclaw/plugin-sdk/json-store");
expect(typeof jsonStoreSdk.readJsonFileWithFallback).toBe("function");
});
it("exports acpx helpers", async () => {
const acpxSdk = await import("openclaw/plugin-sdk/acpx");
expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function");

View File

@ -0,0 +1,266 @@
export { formatCliCommand } from "../cli/command-format.js";
export type { OpenClawConfig } from "../config/config.js";
export { normalizeResolvedSecretInputString } from "../config/types.secrets.js";
export { wrapWebContent } from "../security/external-content.js";
export { normalizeSecretInput } from "../utils/normalize-secret-input.js";
export type {
CacheEntry,
SearchProviderContext,
SearchProviderExecutionResult,
SearchProviderRequest,
SearchProviderPlugin,
SearchProviderRuntimeMetadataResolver,
SearchProviderSetupMetadata,
SearchProviderSuccessResult,
} from "../plugins/types.js";
export {
normalizeCacheKey,
readCache,
readResponseText,
writeCache,
} from "../agents/tools/web-shared.js";
export { withTrustedWebToolsEndpoint } from "../agents/tools/web-guarded-fetch.js";
export { resolveCitationRedirectUrl } from "../agents/tools/web-search-citation-redirect.js";
export type SearchProviderSetupParams = {
label: string;
hint: string;
envKeys: readonly string[];
placeholder: string;
signupUrl: string;
apiKeyConfigPath: string;
install?: SearchProviderSetupMetadata["install"];
autodetectPriority?: SearchProviderSetupMetadata["autodetectPriority"];
requestSchema?: SearchProviderSetupMetadata["requestSchema"];
resolveRequestSchema?: SearchProviderSetupMetadata["resolveRequestSchema"];
resolveRuntimeMetadata?: (params: {
search: Record<string, unknown> | undefined;
keyValue?: string;
keySource: "config" | "secretRef" | "env" | "missing";
fallbackEnvVar?: string;
}) => Record<string, unknown>;
readApiKeyValue?: (search: Record<string, unknown> | undefined) => unknown;
writeApiKeyValue?: (search: Record<string, unknown>, value: unknown) => void;
};
export type SearchProviderFilterSupport = {
country?: boolean;
language?: boolean;
freshness?: boolean;
date?: boolean;
domainFilter?: boolean;
};
const WEB_SEARCH_DOCS_URL = "https://docs.openclaw.ai/tools/web";
export function resolveSearchConfig<T>(search?: Record<string, unknown>): T {
return search as T;
}
export function resolveSearchProviderSectionConfig<T>(
search: Record<string, unknown> | undefined,
provider: string,
): T {
if (!search || typeof search !== "object") {
return {} as T;
}
const scoped = search[provider];
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
return {} as T;
}
return scoped as T;
}
export function createSearchProviderSetupMetadata(
params: SearchProviderSetupParams & { provider: string },
): SearchProviderSetupMetadata {
return {
hint: params.hint,
credentials: {
label: params.label,
hint: params.hint,
envKeys: params.envKeys,
placeholder: params.placeholder,
signupUrl: params.signupUrl,
apiKeyConfigPath: params.apiKeyConfigPath,
resolveRuntimeMetadata: params.resolveRuntimeMetadata,
readApiKeyValue:
params.readApiKeyValue ??
((search) => readSearchProviderApiKeyValue(search, params.provider)),
writeApiKeyValue:
params.writeApiKeyValue ??
((search, value) =>
writeSearchProviderApiKeyValue({ search, provider: params.provider, value })),
},
...(params.install ? { install: params.install } : {}),
...(params.autodetectPriority !== undefined
? { autodetectPriority: params.autodetectPriority }
: {}),
...(params.requestSchema ? { requestSchema: params.requestSchema } : {}),
...(params.resolveRequestSchema ? { resolveRequestSchema: params.resolveRequestSchema } : {}),
};
}
export function createSearchProviderErrorResult(
error: string,
message: string,
docs: string = WEB_SEARCH_DOCS_URL,
): { error: string; message: string; docs: string } {
return { error, message, docs };
}
export function createMissingSearchKeyPayload(
error: string,
message: string,
): { error: string; message: string; docs: string } {
return createSearchProviderErrorResult(error, message);
}
export function rejectUnsupportedSearchFilters(params: {
providerName: string;
request: Pick<
SearchProviderRequest,
"country" | "language" | "freshness" | "dateAfter" | "dateBefore" | "domainFilter"
>;
support: SearchProviderFilterSupport;
}): { error: string; message: string; docs: string } | undefined {
const provider = params.providerName;
if (params.request.country && params.support.country !== true) {
return createSearchProviderErrorResult(
"unsupported_country",
`country filtering is not supported by the ${provider} provider.`,
);
}
if (params.request.language && params.support.language !== true) {
return createSearchProviderErrorResult(
"unsupported_language",
`language filtering is not supported by the ${provider} provider.`,
);
}
if (params.request.freshness && params.support.freshness !== true) {
return createSearchProviderErrorResult(
"unsupported_freshness",
`freshness filtering is not supported by the ${provider} provider.`,
);
}
if ((params.request.dateAfter || params.request.dateBefore) && params.support.date !== true) {
return createSearchProviderErrorResult(
"unsupported_date_filter",
`date_after/date_before filtering is not supported by the ${provider} provider.`,
);
}
if (params.request.domainFilter?.length && params.support.domainFilter !== true) {
return createSearchProviderErrorResult(
"unsupported_domain_filter",
`domain_filter is not supported by the ${provider} provider.`,
);
}
return undefined;
}
export function resolveSiteName(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
return new URL(url).hostname;
} catch {
return undefined;
}
}
export async function throwWebSearchApiError(res: Response, providerLabel: string): Promise<never> {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
const detail = detailResult.text;
throw new Error(`${providerLabel} API error (${res.status}): ${detail || res.statusText}`);
}
export function buildSearchRequestCacheIdentity(params: {
query: string;
count: number;
country?: string;
language?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
domainFilter?: string[];
maxTokens?: number;
maxTokensPerPage?: number;
}): string {
return [
params.query,
params.count,
params.country || "default",
params.language || "default",
params.freshness || "default",
params.dateAfter || "default",
params.dateBefore || "default",
params.domainFilter?.join(",") || "default",
params.maxTokens || "default",
params.maxTokensPerPage || "default",
].join(":");
}
function isValidIsoDate(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false;
}
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return false;
}
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
);
}
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const PERPLEXITY_DATE_PATTERN = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
export function normalizeDateInputToIso(value: string): string | undefined {
const trimmed = value.trim();
if (ISO_DATE_PATTERN.test(trimmed)) {
return isValidIsoDate(trimmed) ? trimmed : undefined;
}
const match = trimmed.match(PERPLEXITY_DATE_PATTERN);
if (match) {
const [, month, day, year] = match;
const iso = `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
return isValidIsoDate(iso) ? iso : undefined;
}
return undefined;
}
function getScopedSearchConfig(
search: Record<string, unknown>,
provider: string,
): Record<string, unknown> | undefined {
const scoped = search[provider];
return typeof scoped === "object" && scoped !== null && !Array.isArray(scoped)
? (scoped as Record<string, unknown>)
: undefined;
}
export function readSearchProviderApiKeyValue(
search: Record<string, unknown> | undefined,
provider: string,
): unknown {
if (!search) {
return undefined;
}
return getScopedSearchConfig(search, provider)?.apiKey;
}
export function writeSearchProviderApiKeyValue(params: {
search: Record<string, unknown>;
provider: string;
value: unknown;
}): void {
const current = getScopedSearchConfig(params.search, params.provider);
if (current) {
current.apiKey = params.value;
return;
}
params.search[params.provider] = { apiKey: params.value };
}

115
src/plugins/capabilities.ts Normal file
View File

@ -0,0 +1,115 @@
import type { OpenClawConfig } from "../config/config.js";
export type PluginCapabilityKind = "search-provider";
export type PluginCapabilitySlotMode = "multi" | "exclusive";
export type CapabilitySlotId = "providers.search" | "memory.backend";
type CapabilityKindDefinition = {
capabilityPrefix: string;
slot: CapabilitySlotId;
slotMode: PluginCapabilitySlotMode;
};
type CapabilitySlotDefinition = {
configPath: string;
read: (config: OpenClawConfig | undefined) => string | null | undefined;
write: (config: OpenClawConfig, selectedId: string | null) => OpenClawConfig;
};
const DEFAULT_MEMORY_BACKEND = "memory-core";
function normalizeSelection(value: unknown): string | null | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (trimmed.toLowerCase() === "none") {
return null;
}
return trimmed;
}
const CAPABILITY_KIND_DEFINITIONS: Record<PluginCapabilityKind, CapabilityKindDefinition> = {
"search-provider": {
capabilityPrefix: "providers.search",
slot: "providers.search",
slotMode: "multi",
},
};
const CAPABILITY_SLOT_DEFINITIONS: Record<CapabilitySlotId, CapabilitySlotDefinition> = {
"providers.search": {
configPath: "tools.web.search.provider",
read: (config) => normalizeSelection(config?.tools?.web?.search?.provider),
write: (config, selectedId) => ({
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
provider: selectedId ?? undefined,
},
},
},
}),
},
"memory.backend": {
configPath: "plugins.slots.memory",
read: (config) => {
const configured = normalizeSelection(config?.plugins?.slots?.memory);
return configured === undefined ? DEFAULT_MEMORY_BACKEND : configured;
},
write: (config, selectedId) => ({
...config,
plugins: {
...config.plugins,
slots: {
...config.plugins?.slots,
memory: selectedId ?? "none",
},
},
}),
},
};
export function buildCapabilityName(kind: PluginCapabilityKind, id: string): string {
const definition = CAPABILITY_KIND_DEFINITIONS[kind];
return `${definition.capabilityPrefix}.${id}`;
}
export function resolveCapabilitySlotForKind(kind: PluginCapabilityKind): CapabilitySlotId {
return CAPABILITY_KIND_DEFINITIONS[kind].slot;
}
export function resolveCapabilitySlotModeForKind(
kind: PluginCapabilityKind,
): PluginCapabilitySlotMode {
return CAPABILITY_KIND_DEFINITIONS[kind].slotMode;
}
export function resolveCapabilitySlotConfigPath(slot: CapabilitySlotId): string {
return CAPABILITY_SLOT_DEFINITIONS[slot].configPath;
}
export function resolveCapabilitySlotSelection(
config: OpenClawConfig | undefined,
slot: CapabilitySlotId,
): string | null | undefined {
return CAPABILITY_SLOT_DEFINITIONS[slot].read(config);
}
export function applyCapabilitySlotSelection(params: {
config: OpenClawConfig;
slot: CapabilitySlotId;
selectedId: string | null;
}): OpenClawConfig {
const selectedId =
params.selectedId === null ? null : (normalizeSelection(params.selectedId) ?? undefined);
return CAPABILITY_SLOT_DEFINITIONS[params.slot].write(params.config, selectedId ?? null);
}

View File

@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
applyCapabilitySlotSelection,
resolveCapabilitySlotSelection,
} from "./capability-slots.js";
describe("capability slot selection", () => {
it("resolves the configured search provider from web search config", () => {
const config: OpenClawConfig = {
tools: { web: { search: { provider: "tavily" } } },
};
expect(resolveCapabilitySlotSelection(config, "providers.search")).toBe("tavily");
});
it("applies search slot selection through the web search provider field", () => {
const config: OpenClawConfig = {
tools: { web: { search: { provider: "brave" } } },
};
const next = applyCapabilitySlotSelection({
config,
slot: "providers.search",
selectedId: "tavily",
});
expect(next.tools?.web?.search?.provider).toBe("tavily");
});
it("resolves the effective memory backend selection with default fallback", () => {
expect(resolveCapabilitySlotSelection({}, "memory.backend")).toBe("memory-core");
});
it("applies memory backend selection through plugins.slots.memory", () => {
const next = applyCapabilitySlotSelection({
config: {},
slot: "memory.backend",
selectedId: "memory-alt",
});
expect(next.plugins?.slots?.memory).toBe("memory-alt");
});
it("supports disabling the memory backend slot", () => {
const next = applyCapabilitySlotSelection({
config: {},
slot: "memory.backend",
selectedId: null,
});
expect(next.plugins?.slots?.memory).toBe("none");
});
});

View File

@ -0,0 +1,10 @@
export {
applyCapabilitySlotSelection,
resolveCapabilitySlotConfigPath,
resolveCapabilitySlotForKind,
resolveCapabilitySlotModeForKind,
resolveCapabilitySlotSelection,
type CapabilitySlotId,
type PluginCapabilityKind,
type PluginCapabilitySlotMode,
} from "./capabilities.js";

View File

@ -114,6 +114,34 @@ describe("resolveEffectiveEnableState", () => {
});
expect(state).toEqual({ enabled: false, reason: "disabled in config" });
});
it("enables plugins marked defaultEnabledWhenBundled by default", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
const state = resolveEffectiveEnableState({
id: "search-brave",
origin: "bundled",
config: normalized,
rootConfig: {},
defaultEnabledWhenBundled: true,
});
expect(state).toEqual({ enabled: true });
});
it("applies defaultEnabledWhenBundled consistently across plugin ids", () => {
const normalized = normalizePluginsConfig({
enabled: true,
});
const state = resolveEffectiveEnableState({
id: "search-gemini",
origin: "bundled",
config: normalized,
rootConfig: {},
defaultEnabledWhenBundled: true,
});
expect(state).toEqual({ enabled: true });
});
});
describe("resolveEnableState", () => {

View File

@ -193,6 +193,7 @@ export function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
defaultEnabledWhenBundled = false,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
@ -217,7 +218,7 @@ export function resolveEnableState(
if (entry?.enabled === true) {
return { enabled: true };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
if (origin === "bundled" && (BUNDLED_ENABLED_BY_DEFAULT.has(id) || defaultEnabledWhenBundled)) {
return { enabled: true };
}
if (origin === "bundled") {
@ -250,8 +251,14 @@ export function resolveEffectiveEnableState(params: {
origin: PluginRecord["origin"];
config: NormalizedPluginsConfig;
rootConfig?: OpenClawConfig;
defaultEnabledWhenBundled?: boolean;
}): { enabled: boolean; reason?: string } {
const base = resolveEnableState(params.id, params.origin, params.config);
const base = resolveEnableState(
params.id,
params.origin,
params.config,
params.defaultEnabledWhenBundled,
);
if (
!base.enabled &&
base.reason === "bundled (disabled by default)" &&

View File

@ -540,6 +540,7 @@ function discoverFromPath(params: {
if (!fs.existsSync(resolved)) {
params.diagnostics.push({
level: "error",
code: "plugin_path_not_found",
message: `plugin path not found: ${resolved}`,
source: resolved,
});

View File

@ -20,6 +20,7 @@ export function createMockPluginRegistry(
cliRegistrars: [],
services: [],
providers: [],
searchProviders: [],
commands: [],
} as unknown as PluginRegistry;
}

View File

@ -8,12 +8,16 @@
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
import type { PluginRegistry } from "./registry.js";
import type {
PluginHookAfterProviderActivateEvent,
PluginHookAfterProviderConfigureEvent,
PluginHookAfterCompactionEvent,
PluginHookAfterToolCallEvent,
PluginHookAgentContext,
PluginHookAgentEndEvent,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeProviderConfigureEvent,
PluginHookBeforeProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildEvent,
@ -37,6 +41,7 @@ import type {
PluginHookSessionContext,
PluginHookSessionEndEvent,
PluginHookSessionStartEvent,
PluginHookSearchProviderContext,
PluginHookSubagentContext,
PluginHookSubagentDeliveryTargetEvent,
PluginHookSubagentDeliveryTargetResult,
@ -57,6 +62,8 @@ export type {
PluginHookAgentContext,
PluginHookBeforeAgentStartEvent,
PluginHookBeforeAgentStartResult,
PluginHookBeforeProviderConfigureEvent,
PluginHookBeforeProviderConfigureResult,
PluginHookBeforeModelResolveEvent,
PluginHookBeforeModelResolveResult,
PluginHookBeforePromptBuildEvent,
@ -84,6 +91,7 @@ export type {
PluginHookSessionContext,
PluginHookSessionStartEvent,
PluginHookSessionEndEvent,
PluginHookSearchProviderContext,
PluginHookSubagentContext,
PluginHookSubagentDeliveryTargetEvent,
PluginHookSubagentDeliveryTargetResult,
@ -94,6 +102,8 @@ export type {
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
PluginHookGatewayStopEvent,
PluginHookAfterProviderConfigureEvent,
PluginHookAfterProviderActivateEvent,
};
export type HookRunnerLogger = {
@ -181,6 +191,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return next;
};
const mergeBeforeProviderConfigure = (
acc: PluginHookBeforeProviderConfigureResult | undefined,
next: PluginHookBeforeProviderConfigureResult,
): PluginHookBeforeProviderConfigureResult => ({
note: concatOptionalTextSegments({
left: acc?.note,
right: next.note,
}),
});
const handleHookError = (params: {
hookName: PluginHookName;
pluginId: string;
@ -206,6 +226,15 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
): Promise<void> {
const hooks = getHooksForName(registry, hookName);
return runVoidHookRegistrations(hookName, hooks, event, ctx);
}
async function runVoidHookRegistrations<K extends PluginHookName>(
hookName: K,
hooks: PluginHookRegistration<K>[],
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
): Promise<void> {
if (hooks.length === 0) {
return;
}
@ -234,6 +263,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
): Promise<TResult | undefined> {
const hooks = getHooksForName(registry, hookName);
return runModifyingHookRegistrations(hookName, hooks, event, ctx, mergeResults);
}
async function runModifyingHookRegistrations<K extends PluginHookName, TResult>(
hookName: K,
hooks: PluginHookRegistration<K>[],
event: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[0],
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
): Promise<TResult | undefined> {
if (hooks.length === 0) {
return undefined;
}
@ -318,6 +357,32 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
);
}
async function runBeforeProviderConfigure(
event: PluginHookBeforeProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<PluginHookBeforeProviderConfigureResult | undefined> {
return runModifyingHook<"before_provider_configure", PluginHookBeforeProviderConfigureResult>(
"before_provider_configure",
event,
ctx,
mergeBeforeProviderConfigure,
);
}
async function runAfterProviderConfigure(
event: PluginHookAfterProviderConfigureEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
await runVoidHook("after_provider_configure", event, ctx);
}
async function runAfterProviderActivate(
event: PluginHookAfterProviderActivateEvent,
ctx: PluginHookSearchProviderContext,
): Promise<void> {
await runVoidHook("after_provider_activate", event, ctx);
}
/**
* Run agent_end hook.
* Allows plugins to analyze completed conversations.
@ -722,11 +787,24 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
return registry.typedHooks.filter((h) => h.hookName === hookName).length;
}
function hasProviderConfigureHooks(providerKind?: string): boolean {
void providerKind;
return hasHooks("before_provider_configure") || hasHooks("after_provider_configure");
}
function hasProviderActivationHooks(providerKind?: string): boolean {
void providerKind;
return hasHooks("after_provider_activate");
}
return {
// Agent hooks
runBeforeModelResolve,
runBeforePromptBuild,
runBeforeAgentStart,
runBeforeProviderConfigure,
runAfterProviderConfigure,
runAfterProviderActivate,
runLlmInput,
runLlmOutput,
runAgentEnd,
@ -755,6 +833,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
runGatewayStop,
// Utility
hasHooks,
hasProviderConfigureHooks,
hasProviderActivationHooks,
getHookCount,
};
}

View File

@ -97,6 +97,7 @@ function writePlugin(params: {
body: string;
dir?: string;
filename?: string;
manifest?: Record<string, unknown>;
}): TempPlugin {
const dir = params.dir ?? makeTempDir();
const filename = params.filename ?? `${params.id}.cjs`;
@ -106,7 +107,7 @@ function writePlugin(params: {
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify(
{
params.manifest ?? {
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
@ -1577,11 +1578,25 @@ describe("loadOpenClawPlugins", () => {
const options = {
cache: false,
logger: createWarningLogger(warnings),
=======
it("suppresses the open allowlist warning when requested", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "warn-open-allow-suppressed",
body: `module.exports = { id: "warn-open-allow-suppressed", register() {} };`,
});
const warnings: string[] = [];
loadOpenClawPlugins({
cache: false,
logger: createWarningLogger(warnings),
suppressOpenAllowlistWarning: true,
>>>>>>> bd6c276db1 (feat: extend pluggable web search onboarding)
config: {
plugins: {
load: { paths: [plugin.file] },
},
},
<<<<<<< HEAD
};
loadOpenClawPlugins(options);
@ -1590,6 +1605,28 @@ describe("loadOpenClawPlugins", () => {
expect(warnings.filter((msg) => msg.includes("plugins.allow is empty"))).toHaveLength(1);
});
it("suppresses the open allowlist warning when requested", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "warn-open-allow-suppressed",
body: `module.exports = { id: "warn-open-allow-suppressed", register() {} };`,
});
const warnings: string[] = [];
loadOpenClawPlugins({
cache: false,
logger: createWarningLogger(warnings),
suppressOpenAllowlistWarning: true,
config: {
plugins: {
load: { paths: [plugin.file] },
},
},
});
expect(
warnings.some((msg) => msg.includes("plugins.allow is empty") && msg.includes(plugin.id)),
).toBe(false);
});
it("does not auto-load workspace-discovered plugins unless explicitly trusted", () => {
useNoBundledPlugins();
const workspaceDir = makeTempDir();
@ -2107,4 +2144,196 @@ describe("loadOpenClawPlugins", () => {
);
expect(resolved).toBe(srcFile);
});
it("emits diagnostics for duplicate declared capabilities", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "search-one",
body: `module.exports = { id: "search-one", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "search-one",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["providers.search.shared"],
},
});
const second = writePlugin({
id: "search-two",
body: `module.exports = { id: "search-two", register(api) { api.registerSearchProvider({ id: "beta", name: "Beta", search: async () => ({ content: "beta" }) }); } };`,
manifest: {
id: "search-two",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["providers.search.shared"],
},
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
},
},
});
expect(
registry.diagnostics.filter((diag) =>
diag.message.includes("declared capability already provided by another plugin"),
),
).toEqual(expect.arrayContaining([expect.objectContaining({ pluginId: "search-two" })]));
expect(registry.searchProviders.map((entry) => entry.provider.id)).toEqual(["alpha"]);
expect(registry.plugins).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "search-two", status: "error" })]),
);
});
it("warns when a declared required capability is missing", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "memory-ui",
body: `module.exports = { id: "memory-ui", register() {} };`,
manifest: {
id: "memory-ui",
configSchema: EMPTY_PLUGIN_SCHEMA,
requires: ["memory.backend.*"],
},
});
const registry = loadRegistryFromSinglePlugin({ plugin });
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
pluginId: "memory-ui",
message: "missing required capability: memory.backend.*",
}),
]),
);
});
it("errors when a declared conflicting capability is present", () => {
useNoBundledPlugins();
const first = writePlugin({
id: "memory-a",
body: `module.exports = { id: "memory-a", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "memory-a",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["memory.backend.a"],
},
});
const second = writePlugin({
id: "memory-b",
body: `module.exports = { id: "memory-b", register(api) { api.registerSearchProvider({ id: "beta", name: "Beta", search: async () => ({ content: "beta" }) }); } };`,
manifest: {
id: "memory-b",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["memory.backend.b"],
conflicts: ["memory.backend.*"],
},
});
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
load: { paths: [first.file, second.file] },
},
},
});
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
pluginId: "memory-b",
message: "conflicting capability present: memory.backend.* (memory-a)",
}),
]),
);
expect(registry.searchProviders.map((entry) => entry.provider.id)).toEqual(["alpha"]);
expect(registry.plugins).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "memory-b", status: "error" })]),
);
});
it("errors when a declared capability is not registered at runtime", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "search-manifest-mismatch",
body: `module.exports = { id: "search-manifest-mismatch", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "search-manifest-mismatch",
configSchema: EMPTY_PLUGIN_SCHEMA,
provides: ["providers.search.beta"],
},
});
const registry = loadRegistryFromSinglePlugin({ plugin });
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
pluginId: "search-manifest-mismatch",
code: "capability_declared_not_registered",
capability: "providers.search.beta",
message: "declared capability was not registered at runtime: providers.search.beta",
}),
]),
);
});
it("warns when a runtime capability is not declared in the manifest", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "search-runtime-undeclared",
body: `module.exports = { id: "search-runtime-undeclared", register(api) { api.registerSearchProvider({ id: "alpha", name: "Alpha", search: async () => ({ content: "alpha" }) }); } };`,
manifest: {
id: "search-runtime-undeclared",
configSchema: EMPTY_PLUGIN_SCHEMA,
},
});
const registry = loadRegistryFromSinglePlugin({ plugin });
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
pluginId: "search-runtime-undeclared",
code: "capability_registered_not_declared",
capability: "providers.search.alpha",
message: "runtime capability was not declared in manifest: providers.search.alpha",
}),
]),
);
});
it("emits a structured diagnostic when the configured memory slot is missing", () => {
useNoBundledPlugins();
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
slots: {
memory: "missing-memory-backend",
},
},
},
});
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
code: "capability_slot_selection_missing",
slot: "memory.backend",
capability: "missing-memory-backend",
message: "memory slot plugin not found or not marked as memory: missing-memory-backend",
}),
]),
);
});
});

View File

@ -47,6 +47,7 @@ export type PluginLoadOptions = {
runtimeOptions?: CreatePluginRuntimeOptions;
cache?: boolean;
mode?: "full" | "validate";
suppressOpenAllowlistWarning?: boolean;
};
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
@ -305,6 +306,11 @@ function createPluginRecord(params: {
hookNames: [],
channelIds: [],
providerIds: [],
searchProviderIds: [],
capabilityIds: [],
declaredCapabilities: [],
requiredCapabilities: [],
conflictingCapabilities: [],
gatewayMethods: [],
cliCommands: [],
services: [],
@ -317,6 +323,142 @@ function createPluginRecord(params: {
};
}
function capabilityPatternMatches(params: { pattern: string; capability: string }): boolean {
const pattern = params.pattern.trim();
const capability = params.capability.trim();
if (!pattern || !capability) {
return false;
}
if (pattern.endsWith(".*")) {
const prefix = pattern.slice(0, -2);
return capability === prefix || capability.startsWith(`${prefix}.`);
}
return capability === pattern;
}
function collectDeclaredCapabilities(plugin: PluginRecord): Set<string> {
return new Set([...plugin.declaredCapabilities, ...plugin.capabilityIds]);
}
function collectCapabilityIds(plugin: PluginRecord): Set<string> {
return new Set(plugin.capabilityIds);
}
function evaluateCapabilityRelationships(params: {
activePlugins: PluginRecord[];
candidatePlugin?: PluginRecord;
}): PluginDiagnostic[] {
const diagnostics: PluginDiagnostic[] = [];
const activeCapabilityOwners = new Map<string, string[]>();
for (const plugin of params.activePlugins) {
for (const capability of collectDeclaredCapabilities(plugin)) {
const owners = activeCapabilityOwners.get(capability) ?? [];
owners.push(plugin.id);
activeCapabilityOwners.set(capability, owners);
}
}
const pluginsToEvaluate = params.candidatePlugin
? [params.candidatePlugin]
: params.activePlugins;
for (const plugin of pluginsToEvaluate) {
const pluginCapabilities = collectDeclaredCapabilities(plugin);
for (const capability of pluginCapabilities) {
const owners = activeCapabilityOwners.get(capability) ?? [];
const conflictingOwners = params.candidatePlugin
? owners
: owners.filter((owner) => owner !== plugin.id);
if (conflictingOwners.length > 0) {
diagnostics.push({
level: "error",
pluginId: plugin.id,
source: plugin.source,
code: "capability_declared_duplicate",
capability,
slot: capability.includes(".") ? capability.split(".").slice(0, -1).join(".") : undefined,
message: `declared capability already provided by another plugin: ${capability} (${Array.from(new Set(conflictingOwners)).join(", ")})`,
});
}
}
for (const requirement of plugin.requiredCapabilities) {
const satisfied = Array.from(activeCapabilityOwners.keys()).some((capability) =>
capabilityPatternMatches({ pattern: requirement, capability }),
);
if (satisfied) {
continue;
}
diagnostics.push({
level: "warn",
pluginId: plugin.id,
source: plugin.source,
code: "capability_missing_requirement",
capability: requirement,
message: `missing required capability: ${requirement}`,
});
}
for (const conflict of plugin.conflictingCapabilities) {
const conflictingOwners = Array.from(activeCapabilityOwners.entries())
.filter(([capability]) => capabilityPatternMatches({ pattern: conflict, capability }))
.flatMap(([, owners]) => owners)
.filter((ownerId) => ownerId !== plugin.id);
if (conflictingOwners.length === 0) {
continue;
}
diagnostics.push({
level: "error",
pluginId: plugin.id,
source: plugin.source,
code: "capability_conflict_present",
capability: conflict,
message: `conflicting capability present: ${conflict} (${Array.from(new Set(conflictingOwners)).join(", ")})`,
});
}
}
return diagnostics;
}
function evaluateCapabilityDeclarationAlignment(plugin: PluginRecord): PluginDiagnostic[] {
const diagnostics: PluginDiagnostic[] = [];
const declaredCapabilities = new Set(plugin.declaredCapabilities);
const runtimeCapabilities = collectCapabilityIds(plugin);
for (const capability of declaredCapabilities) {
if (runtimeCapabilities.has(capability)) {
continue;
}
diagnostics.push({
level: "error",
pluginId: plugin.id,
source: plugin.source,
code: "capability_declared_not_registered",
capability,
slot: capability.includes(".") ? capability.split(".").slice(0, -1).join(".") : undefined,
message: `declared capability was not registered at runtime: ${capability}`,
});
}
for (const capability of runtimeCapabilities) {
if (declaredCapabilities.has(capability)) {
continue;
}
diagnostics.push({
level: "warn",
pluginId: plugin.id,
source: plugin.source,
code: "capability_registered_not_declared",
capability,
slot: capability.includes(".") ? capability.split(".").slice(0, -1).join(".") : undefined,
message: `runtime capability was not declared in manifest: ${capability}`,
});
}
return diagnostics;
}
function recordPluginError(params: {
logger: PluginLogger;
registry: PluginRegistry;
@ -459,7 +601,11 @@ function warnWhenAllowlistIsOpen(params: {
allow: string[];
warningCacheKey: string;
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
suppress?: boolean;
}) {
if (params.suppress) {
return;
}
if (!params.pluginsEnabled) {
return;
}
@ -611,6 +757,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
source: plugin.source,
origin: plugin.origin,
})),
suppress: options.suppressOpenAllowlistWarning === true,
});
const provenance = buildProvenanceIndex({
config: cfg,
@ -680,6 +827,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
origin: candidate.origin,
config: normalized,
rootConfig: cfg,
defaultEnabledWhenBundled: manifestRecord.defaultEnabledWhenBundled,
});
const entry = normalized.entries[pluginId];
const record = createPluginRecord({
@ -696,6 +844,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
record.defaultEnabledWhenBundled = manifestRecord.defaultEnabledWhenBundled;
record.declaredCapabilities = [...manifestRecord.provides];
record.requiredCapabilities = [...manifestRecord.requires];
record.conflictingCapabilities = [...manifestRecord.conflicts];
const pushPluginLoadError = (message: string) => {
record.status = "error";
record.error = message;
@ -736,6 +888,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
const preflightCapabilityDiagnostics = evaluateCapabilityRelationships({
activePlugins: registry.plugins.filter((plugin) => plugin.status === "loaded"),
candidatePlugin: record,
});
if (preflightCapabilityDiagnostics.some((diag) => diag.level === "error")) {
record.status = "error";
record.error =
preflightCapabilityDiagnostics.find((diag) => diag.level === "error")?.message ??
"plugin capability relationship error";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
pushDiagnostics(registry.diagnostics, preflightCapabilityDiagnostics);
continue;
}
if (!manifestRecord.configSchema) {
pushPluginLoadError("missing config schema");
continue;
@ -864,6 +1031,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
message: "plugin register returned a promise; async registration is ignored",
});
}
pushDiagnostics(registry.diagnostics, evaluateCapabilityDeclarationAlignment(record));
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
} catch (err) {
@ -884,10 +1052,20 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (typeof memorySlot === "string" && !memorySlotMatched) {
registry.diagnostics.push({
level: "warn",
code: "capability_slot_selection_missing",
slot: "memory.backend",
capability: memorySlot,
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
});
}
pushDiagnostics(
registry.diagnostics,
evaluateCapabilityRelationships({
activePlugins: registry.plugins.filter((plugin) => plugin.status === "loaded"),
}),
);
warnAboutUntrackedLoadedPlugins({
registry,
provenance,

View File

@ -6,6 +6,7 @@ import {
clearPluginManifestRegistryCache,
loadPluginManifestRegistry,
} from "./manifest-registry.js";
import type { OpenClawPackageManifest } from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
@ -36,12 +37,14 @@ function createPluginCandidate(params: {
rootDir: string;
sourceName?: string;
origin: "bundled" | "global" | "workspace" | "config";
packageManifest?: OpenClawPackageManifest;
}): PluginCandidate {
return {
idHint: params.idHint,
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
rootDir: params.rootDir,
origin: params.origin,
packageManifest: params.packageManifest,
};
}
@ -215,6 +218,37 @@ describe("loadPluginManifestRegistry", () => {
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
});
it("surfaces package install metadata on manifest records", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "tavily-search",
name: "Tavily Search",
provides: ["providers.search.tavily"],
configSchema: { type: "object" },
});
const registry = loadRegistry([
createPluginCandidate({
idHint: "tavily-search",
rootDir: dir,
origin: "bundled",
packageManifest: {
install: {
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
},
},
}),
]);
expect(registry.plugins[0]?.install).toEqual({
npmSpec: "@openclaw/tavily-search",
localPath: "extensions/tavily-search",
defaultChoice: "local",
});
});
it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => {
const dir = makeTempDir();
mkdirSafe(path.join(dir, "sub"));

View File

@ -2,7 +2,7 @@ import fs from "node:fs";
import type { OpenClawConfig } from "../config/config.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import { loadPluginManifest, type PluginManifest, type PluginPackageInstall } from "./manifest.js";
import { safeRealpathSync } from "./path-safety.js";
import { resolvePluginCacheInputs } from "./roots.js";
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
@ -29,6 +29,9 @@ export type PluginManifestRecord = {
channels: string[];
providers: string[];
skills: string[];
provides: string[];
requires: string[];
conflicts: string[];
origin: PluginOrigin;
workspaceDir?: string;
rootDir: string;
@ -37,6 +40,8 @@ export type PluginManifestRecord = {
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
defaultEnabledWhenBundled?: boolean;
install?: PluginPackageInstall;
};
export type PluginManifestRegistry = {
@ -124,6 +129,9 @@ function buildRecord(params: {
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
skills: params.manifest.skills ?? [],
provides: params.manifest.provides ?? [],
requires: params.manifest.requires ?? [],
conflicts: params.manifest.conflicts ?? [],
origin: params.candidate.origin,
workspaceDir: params.candidate.workspaceDir,
rootDir: params.candidate.rootDir,
@ -132,6 +140,8 @@ function buildRecord(params: {
schemaCacheKey: params.schemaCacheKey,
configSchema: params.configSchema,
configUiHints: params.manifest.uiHints,
defaultEnabledWhenBundled: params.manifest.defaultEnabledWhenBundled,
install: params.candidate.packageManifest?.install,
};
}

View File

@ -15,9 +15,13 @@ export type PluginManifest = {
channels?: string[];
providers?: string[];
skills?: string[];
provides?: string[];
requires?: string[];
conflicts?: string[];
name?: string;
description?: string;
version?: string;
defaultEnabledWhenBundled?: boolean;
uiHints?: Record<string, PluginConfigUiHint>;
};
@ -94,12 +98,18 @@ export function loadPluginManifest(
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
const skills = normalizeStringList(raw.skills);
const provides = normalizeStringList(raw.provides);
const requires = normalizeStringList(raw.requires);
const conflicts = normalizeStringList(raw.conflicts);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
const defaultEnabledWhenBundled =
typeof raw.defaultEnabledWhenBundled === "boolean" ? raw.defaultEnabledWhenBundled : undefined;
return {
ok: true,
manifest: {
@ -109,9 +119,13 @@ export function loadPluginManifest(
channels,
providers,
skills,
provides,
requires,
conflicts,
name,
description,
version,
defaultEnabledWhenBundled,
uiHints,
},
manifestPath,

View File

@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { createPluginRegistry, type PluginRecord } from "./registry.js";
function createRecord(id: string): PluginRecord {
return {
id,
name: id,
source: `/tmp/${id}.ts`,
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
searchProviderIds: [],
capabilityIds: [],
declaredCapabilities: [],
requiredCapabilities: [],
conflictingCapabilities: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
};
}
describe("search provider registration", () => {
it("rejects duplicate provider ids case-insensitively and tracks plugin ids", () => {
const { registry, createApi } = createPluginRegistry({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
runtime: {} as never,
});
const firstApi = createApi(createRecord("first-plugin"), { config: {} });
const secondApi = createApi(createRecord("second-plugin"), { config: {} });
firstApi.registerSearchProvider({
id: "Tavily",
name: "Tavily",
search: async () => ({ content: "ok" }),
});
secondApi.registerSearchProvider({
id: "tavily",
name: "Duplicate Tavily",
search: async () => ({ content: "duplicate" }),
});
expect(registry.searchProviders).toHaveLength(1);
expect(registry.searchProviders[0]?.provider.id).toBe("tavily");
expect(registry.searchProviders[0]?.provider.pluginId).toBe("first-plugin");
expect(registry.capabilities).toEqual(
expect.arrayContaining([
expect.objectContaining({
pluginId: "first-plugin",
kind: "search-provider",
capability: "providers.search.tavily",
id: "tavily",
slot: "providers.search",
slotMode: "multi",
}),
]),
);
expect(registry.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
pluginId: "second-plugin",
message: "search provider already registered: tavily (first-plugin)",
}),
]),
);
});
});

View File

@ -10,6 +10,13 @@ import type {
import { registerInternalHook } from "../hooks/internal-hooks.js";
import type { HookEntry } from "../hooks/types.js";
import { resolveUserPath } from "../utils.js";
import {
buildCapabilityName,
resolveCapabilitySlotForKind,
resolveCapabilitySlotModeForKind,
type PluginCapabilityKind,
type PluginCapabilitySlotMode,
} from "./capabilities.js";
import { registerPluginCommand } from "./commands.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
@ -42,6 +49,7 @@ import type {
PluginHookName,
PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration,
SearchProviderPlugin,
} from "./types.js";
export type PluginToolRegistration = {
@ -81,6 +89,23 @@ export type PluginProviderRegistration = {
source: string;
};
export type PluginSearchProviderRegistration = {
pluginId: string;
provider: SearchProviderPlugin;
source: string;
};
export type PluginCapabilityRegistration<T = unknown> = {
pluginId: string;
kind: PluginCapabilityKind;
capability: string;
id: string;
slot: string;
slotMode: PluginCapabilitySlotMode;
value: T;
source: string;
};
export type PluginHookRegistration = {
pluginId: string;
entry: HookEntry;
@ -116,6 +141,11 @@ export type PluginRecord = {
hookNames: string[];
channelIds: string[];
providerIds: string[];
searchProviderIds: string[];
capabilityIds: string[];
declaredCapabilities: string[];
requiredCapabilities: string[];
conflictingCapabilities: string[];
gatewayMethods: string[];
cliCommands: string[];
services: string[];
@ -125,6 +155,7 @@ export type PluginRecord = {
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
defaultEnabledWhenBundled?: boolean;
};
export type PluginRegistry = {
@ -134,6 +165,8 @@ export type PluginRegistry = {
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
searchProviders: PluginSearchProviderRegistration[];
capabilities: PluginCapabilityRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
@ -174,6 +207,8 @@ export function createEmptyPluginRegistry(): PluginRegistry {
typedHooks: [],
channels: [],
providers: [],
searchProviders: [],
capabilities: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
@ -191,6 +226,60 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registry.diagnostics.push(diag);
};
const registerCapability = <T>(params: {
record: PluginRecord;
kind: PluginCapabilityKind;
id: string;
value: T;
slotMode?: PluginCapabilitySlotMode;
duplicateMessage: string;
}): PluginCapabilityRegistration<T> | undefined => {
const slotMode = params.slotMode ?? resolveCapabilitySlotModeForKind(params.kind);
const capability = buildCapabilityName(params.kind, params.id);
const slot = resolveCapabilitySlotForKind(params.kind);
const existing = registry.capabilities.find((entry) => entry.capability === capability);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: params.record.id,
source: params.record.source,
code: "capability_declared_duplicate",
capability,
slot,
message: params.duplicateMessage,
});
return undefined;
}
if (slotMode === "exclusive") {
const existingSlotOwner = registry.capabilities.find((entry) => entry.slot === slot);
if (existingSlotOwner) {
pushDiagnostic({
level: "error",
pluginId: params.record.id,
source: params.record.source,
code: "capability_slot_conflict",
capability,
slot,
message: `exclusive capability slot already registered: ${slot} (${existingSlotOwner.pluginId})`,
});
return undefined;
}
}
const registration: PluginCapabilityRegistration<T> = {
pluginId: params.record.id,
kind: params.kind,
capability,
id: params.id,
slot,
slotMode,
value: params.value,
source: params.record.source,
};
params.record.capabilityIds.push(capability);
registry.capabilities.push(registration);
return registration;
};
const registerTool = (
record: PluginRecord,
tool: AnyAgentTool | OpenClawPluginToolFactory,
@ -467,6 +556,51 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerSearchProvider = (record: PluginRecord, provider: SearchProviderPlugin) => {
const id = typeof provider?.id === "string" ? provider.id.trim().toLowerCase() : "";
if (!id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "search provider registration missing id",
});
return;
}
const existing = registry.searchProviders.find((entry) => entry.provider.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `search provider already registered: ${id} (${existing.pluginId})`,
});
return;
}
const normalizedProvider = {
...provider,
id,
pluginId: record.id,
};
const registeredCapability = registerCapability({
record,
kind: "search-provider",
id,
value: normalizedProvider,
duplicateMessage: `search provider already registered: ${id} (${registry.capabilities.find((entry) => entry.capability === buildCapabilityName("search-provider", id))?.pluginId ?? "unknown"})`,
});
if (!registeredCapability) {
return;
}
record.searchProviderIds.push(id);
registry.searchProviders.push({
pluginId: record.id,
provider: normalizedProvider,
source: record.source,
});
};
const registerCli = (
record: PluginRecord,
registrar: OpenClawPluginCliRegistrar,
@ -607,6 +741,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerHttpRoute: (params) => registerHttpRoute(record, params),
registerChannel: (registration) => registerChannel(record, registration),
registerProvider: (provider) => registerProvider(record, provider),
registerSearchProvider: (provider) => registerSearchProvider(record, provider),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
@ -625,6 +760,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerTool,
registerChannel,
registerProvider,
registerSearchProvider,
registerGatewayMethod,
registerCli,
registerService,

View File

@ -1,5 +1,9 @@
import type { OpenClawConfig } from "../config/config.js";
import type { PluginSlotsConfig } from "../config/types.plugins.js";
import {
applyCapabilitySlotSelection,
resolveCapabilitySlotSelection,
} from "./capability-slots.js";
import type { PluginKind } from "./types.js";
export type PluginSlotKey = keyof PluginSlotsConfig;
@ -50,12 +54,10 @@ export function applyExclusiveSlotSelection(params: {
const warnings: string[] = [];
const pluginsConfig = params.config.plugins ?? {};
const prevSlot = pluginsConfig.slots?.[slotKey];
const slots = {
...pluginsConfig.slots,
[slotKey]: params.selectedId,
};
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
const inferredPrevSlot =
slotKey === "memory"
? resolveCapabilitySlotSelection(params.config, "memory.backend")
: (prevSlot ?? defaultSlotIdForKey(slotKey));
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
warnings.push(
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
@ -95,12 +97,29 @@ export function applyExclusiveSlotSelection(params: {
return { config: params.config, warnings: [], changed: false };
}
const baseConfig =
slotKey === "memory"
? applyCapabilitySlotSelection({
config: params.config,
slot: "memory.backend",
selectedId: params.selectedId,
})
: {
...params.config,
plugins: {
...pluginsConfig,
slots: {
...pluginsConfig.slots,
[slotKey]: params.selectedId,
},
},
};
return {
config: {
...params.config,
...baseConfig,
plugins: {
...pluginsConfig,
slots,
...baseConfig.plugins,
entries,
},
},

View File

@ -239,6 +239,117 @@ export type OpenClawPluginGatewayMethod = {
handler: GatewayRequestHandler;
};
export type SearchProviderRequest = {
query: string;
count: number;
country?: string;
language?: string;
search_lang?: string;
ui_lang?: string;
freshness?: string;
dateAfter?: string;
dateBefore?: string;
domainFilter?: string[];
maxTokens?: number;
maxTokensPerPage?: number;
providerConfig?: Record<string, unknown>;
};
export type SearchProviderResultItem = {
url: string;
title?: string;
description?: string;
published?: string;
};
export type SearchProviderCitation =
| string
| {
url: string;
title?: string;
};
export type SearchProviderSuccessResult = {
error?: undefined;
message?: undefined;
results?: SearchProviderResultItem[];
citations?: SearchProviderCitation[];
content?: string;
tookMs?: number;
};
export type SearchProviderErrorResult = {
error: string;
message?: string;
docs?: string;
tookMs?: number;
};
export type SearchProviderExecutionResult = SearchProviderSuccessResult | SearchProviderErrorResult;
export type SearchProviderContext = {
config: OpenClawConfig;
timeoutSeconds: number;
cacheTtlMs: number;
pluginConfig?: Record<string, unknown>;
};
export type SearchProviderCredentialMetadata = {
hint?: string;
envKeys?: readonly string[];
placeholder?: string;
signupUrl?: string;
apiKeyConfigPath?: string;
readApiKeyValue?: (search: Record<string, unknown> | undefined) => unknown;
writeApiKeyValue?: (search: Record<string, unknown>, value: unknown) => void;
};
export type SearchProviderInstallMetadata = {
npmSpec: string;
localPath?: string;
defaultChoice?: "npm" | "local";
};
export type SearchProviderRequestSchemaResolver = (params: {
config?: OpenClawConfig;
runtimeMetadata?: Record<string, unknown>;
}) => Record<string, unknown>;
export type SearchProviderSetupMetadata = {
hint?: string;
credentials?: SearchProviderCredentialMetadata;
install?: SearchProviderInstallMetadata;
autodetectPriority?: number;
requestSchema?: Record<string, unknown>;
resolveRequestSchema?: SearchProviderRequestSchemaResolver;
};
export type SearchProviderRuntimeMetadata = Record<string, unknown>;
export type SearchProviderRuntimeMetadataResolver = (params: {
search: Record<string, unknown> | undefined;
keyValue?: string;
keySource: "config" | "secretRef" | "env" | "missing";
fallbackEnvVar?: string;
}) => SearchProviderRuntimeMetadata;
export type SearchProviderPlugin = {
id: string;
name: string;
description?: string;
pluginId?: string;
pluginOwnedExecution?: boolean;
docsUrl?: string;
configFieldOrder?: string[];
setup?: SearchProviderSetupMetadata;
resolveRuntimeMetadata?: SearchProviderRuntimeMetadataResolver;
isAvailable?: (config?: OpenClawConfig) => boolean;
search: (
params: SearchProviderRequest,
ctx: SearchProviderContext,
) => Promise<SearchProviderExecutionResult>;
};
// =============================================================================
// Plugin Commands
// =============================================================================
@ -388,6 +499,7 @@ export type OpenClawPluginApi = {
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: OpenClawPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void;
registerSearchProvider: (provider: SearchProviderPlugin) => void;
/**
* Register a custom command that bypasses the LLM agent.
* Plugin commands are processed before built-in commands and before agent invocation.
@ -415,6 +527,17 @@ export type PluginDiagnostic = {
message: string;
pluginId?: string;
source?: string;
code?:
| "plugin_path_not_found"
| "capability_declared_duplicate"
| "capability_declared_not_registered"
| "capability_missing_requirement"
| "capability_conflict_present"
| "capability_slot_conflict"
| "capability_registered_not_declared"
| "capability_slot_selection_missing";
capability?: string;
slot?: string;
};
// ============================================================================
@ -425,6 +548,9 @@ export type PluginHookName =
| "before_model_resolve"
| "before_prompt_build"
| "before_agent_start"
| "before_provider_configure"
| "after_provider_configure"
| "after_provider_activate"
| "llm_input"
| "llm_output"
| "agent_end"
@ -451,6 +577,9 @@ export const PLUGIN_HOOK_NAMES = [
"before_model_resolve",
"before_prompt_build",
"before_agent_start",
"before_provider_configure",
"after_provider_configure",
"after_provider_activate",
"llm_input",
"llm_output",
"agent_end",
@ -590,6 +719,58 @@ export const stripPromptMutationFieldsFromLegacyHookResult = (
: undefined;
};
// search-provider hooks
export type PluginHookSearchProviderContext = {
workspaceDir?: string;
};
export type PluginHookProviderLifecycleContext = {
workspaceDir?: string;
};
export type PluginHookSearchProviderSource = "plugin";
export type PluginHookProviderKind = "search";
export type PluginHookBeforeProviderConfigureEvent = {
providerKind: PluginHookProviderKind;
slot: string;
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookBeforeProviderConfigureResult = {
note?: string;
};
export type PluginHookAfterProviderConfigureEvent = {
providerKind: PluginHookProviderKind;
slot: string;
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
intent: "switch-active" | "configure-provider";
activeProviderId?: string | null;
configured: boolean;
};
export type PluginHookAfterProviderActivateEvent = {
providerKind: PluginHookProviderKind;
slot: string;
providerId: string;
providerLabel: string;
providerSource: PluginHookSearchProviderSource;
pluginId?: string;
previousProviderId?: string | null;
intent: "switch-active" | "configure-provider";
};
// llm_input hook
export type PluginHookLlmInputEvent = {
runId: string;
@ -903,6 +1084,21 @@ export type PluginHookHandlerMap = {
event: PluginHookBeforeAgentStartEvent,
ctx: PluginHookAgentContext,
) => Promise<PluginHookBeforeAgentStartResult | void> | PluginHookBeforeAgentStartResult | void;
before_provider_configure: (
event: PluginHookBeforeProviderConfigureEvent,
ctx: PluginHookProviderLifecycleContext,
) =>
| Promise<PluginHookBeforeProviderConfigureResult | void>
| PluginHookBeforeProviderConfigureResult
| void;
after_provider_configure: (
event: PluginHookAfterProviderConfigureEvent,
ctx: PluginHookProviderLifecycleContext,
) => Promise<void> | void;
after_provider_activate: (
event: PluginHookAfterProviderActivateEvent,
ctx: PluginHookProviderLifecycleContext,
) => Promise<void> | void;
llm_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
llm_output: (
event: PluginHookLlmOutputEvent,

View File

@ -1,18 +1,109 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import * as secretResolve from "./resolve.js";
import { createResolverContext } from "./runtime-shared.js";
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
import { resolveRuntimeWebTools } from "./runtime-web-tools.js";
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
function createProviderCredentialMetadata(provider: ProviderUnderTest) {
const readApiKeyValue = (search: Record<string, unknown> | undefined) => {
if (!search) {
return undefined;
}
if (provider === "brave") {
return search.apiKey;
}
const scoped = search[provider];
return typeof scoped === "object" && scoped !== null && !Array.isArray(scoped)
? (scoped as Record<string, unknown>).apiKey
: undefined;
};
const writeApiKeyValue = (search: Record<string, unknown>, value: unknown) => {
if (provider === "brave") {
search.apiKey = value;
return;
}
const current = search[provider];
if (typeof current === "object" && current !== null && !Array.isArray(current)) {
(current as Record<string, unknown>).apiKey = value;
return;
}
search[provider] = { apiKey: value };
};
const envKeys = {
brave: ["BRAVE_API_KEY"],
gemini: ["GEMINI_API_KEY"],
grok: ["XAI_API_KEY"],
kimi: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
perplexity: ["PERPLEXITY_API_KEY"],
}[provider];
const apiKeyConfigPath = {
brave: "tools.web.search.apiKey",
gemini: "tools.web.search.gemini.apiKey",
grok: "tools.web.search.grok.apiKey",
kimi: "tools.web.search.kimi.apiKey",
perplexity: "tools.web.search.perplexity.apiKey",
}[provider];
return {
envKeys,
apiKeyConfigPath,
readApiKeyValue,
writeApiKeyValue,
};
}
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function withBundledSearchPluginsEnabled(config: OpenClawConfig): OpenClawConfig {
return {
...config,
plugins: {
...config.plugins,
enabled: true,
entries: {
...config.plugins?.entries,
"search-brave": {
...config.plugins?.entries?.["search-brave"],
enabled: true,
},
"search-gemini": {
...config.plugins?.entries?.["search-gemini"],
enabled: true,
},
"search-grok": {
...config.plugins?.entries?.["search-grok"],
enabled: true,
},
"search-kimi": {
...config.plugins?.entries?.["search-kimi"],
enabled: true,
},
"search-perplexity": {
...config.plugins?.entries?.["search-perplexity"],
enabled: true,
},
},
},
};
}
async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const sourceConfig = structuredClone(withBundledSearchPluginsEnabled(params.config));
const resolvedConfig = structuredClone(sourceConfig);
const context = createResolverContext({
sourceConfig,
env: params.env ?? {},
@ -83,6 +174,28 @@ function expectInactiveFirecrawlSecretRef(params: {
);
}
beforeEach(() => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: (["brave", "gemini", "grok", "kimi", "perplexity"] as const).map(
(provider) => ({
pluginId: `search-${provider}`,
provider: {
id: provider,
name: provider,
setup: {
credentials: createProviderCredentialMetadata(provider),
},
resolveRuntimeMetadata:
provider === "perplexity" ? () => ({ perplexityTransport: "search_api" }) : undefined,
search: async () => ({ content: "ok" }),
},
}),
),
plugins: [],
typedHooks: [],
});
});
describe("runtime web tools resolution", () => {
afterEach(() => {
vi.restoreAllMocks();

View File

@ -1,5 +1,11 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { loadOpenClawPlugins } from "../plugins/loader.js";
import type {
SearchProviderCredentialMetadata,
SearchProviderPlugin,
SearchProviderSetupMetadata,
} from "../plugins/types.js";
import { normalizeSecretInput } from "../utils/normalize-secret-input.js";
import { secretRefKey } from "./ref-contract.js";
import { resolveSecretRefValues } from "./resolve.js";
@ -10,13 +16,7 @@ import {
type SecretDefaults,
} from "./runtime-shared.js";
const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number];
type WebSearchProvider = string;
type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret
type RuntimeWebProviderSource = "configured" | "auto-detect" | "none";
@ -82,16 +82,49 @@ function normalizeProvider(value: unknown): WebSearchProvider | undefined {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (
normalized === "brave" ||
normalized === "gemini" ||
normalized === "grok" ||
normalized === "kimi" ||
normalized === "perplexity"
) {
return normalized;
return normalized || undefined;
}
type RegisteredSearchProviderRuntimeSupport = {
setup?: SearchProviderSetupMetadata;
resolveRuntimeMetadata?: SearchProviderPlugin["resolveRuntimeMetadata"];
};
function resolveProviderSetupMetadata(
setup?: SearchProviderSetupMetadata,
): SearchProviderSetupMetadata | undefined {
return setup;
}
function resolveProviderCredentialMetadata(
setup?: SearchProviderSetupMetadata,
): SearchProviderCredentialMetadata | undefined {
return setup?.credentials;
}
function resolveRegisteredSearchProviderMetadata(
config: OpenClawConfig,
): Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport> {
try {
const registry = loadOpenClawPlugins({
config,
cache: false,
suppressOpenAllowlistWarning: true,
});
return new Map(
registry.searchProviders
.filter((entry) => normalizeProvider(entry.provider.id) !== undefined)
.map((entry) => [
entry.provider.id,
{
setup: resolveProviderSetupMetadata(entry.provider.setup),
resolveRuntimeMetadata: entry.provider.resolveRuntimeMetadata,
},
]),
);
} catch {
return new Map();
}
return undefined;
}
function readNonEmptyEnvValue(
@ -225,60 +258,6 @@ async function resolveSecretInputWithEnvFallback(params: {
};
}
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): "direct" | "openrouter" | undefined {
if (!apiKey) {
return undefined;
}
const normalized = apiKey.toLowerCase();
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
function resolvePerplexityRuntimeTransport(params: {
keyValue?: string;
keySource: SecretResolutionSource;
fallbackEnvVar?: string;
configValue: unknown;
}): "search_api" | "chat_completions" | undefined {
const config = isRecord(params.configValue) ? params.configValue : undefined;
const configuredBaseUrl = typeof config?.baseUrl === "string" ? config.baseUrl.trim() : "";
const configuredModel = typeof config?.model === "string" ? config.model.trim() : "";
const baseUrl = (() => {
if (configuredBaseUrl) {
return configuredBaseUrl;
}
if (params.keySource === "env") {
if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (params.fallbackEnvVar === "OPENROUTER_API_KEY") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
}
if ((params.keySource === "config" || params.keySource === "secretRef") && params.keyValue) {
const inferred = inferPerplexityBaseUrlFromApiKey(params.keyValue);
return inferred === "openrouter" ? DEFAULT_PERPLEXITY_BASE_URL : PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
})();
const hasLegacyOverride = Boolean(configuredBaseUrl || configuredModel);
const direct = (() => {
try {
return new URL(baseUrl).hostname.toLowerCase() === "api.perplexity.ai";
} catch {
return false;
}
})();
return hasLegacyOverride || !direct ? "chat_completions" : "search_api";
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (isRecord(current)) {
@ -292,17 +271,16 @@ function ensureObject(target: Record<string, unknown>, key: string): Record<stri
function setResolvedWebSearchApiKey(params: {
resolvedConfig: OpenClawConfig;
provider: WebSearchProvider;
metadata: RegisteredSearchProviderRuntimeSupport;
value: string;
}): void {
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
const web = ensureObject(tools, "web");
const search = ensureObject(web, "search");
if (params.provider === "brave") {
search.apiKey = params.value;
return;
}
const providerConfig = ensureObject(search, params.provider);
providerConfig.apiKey = params.value;
resolveProviderCredentialMetadata(params.metadata.setup)?.writeApiKeyValue?.(
search,
params.value,
);
}
function setResolvedFirecrawlApiKey(params: {
@ -316,34 +294,33 @@ function setResolvedFirecrawlApiKey(params: {
firecrawl.apiKey = params.value;
}
function envVarsForProvider(provider: WebSearchProvider): string[] {
if (provider === "brave") {
return ["BRAVE_API_KEY"];
}
if (provider === "gemini") {
return ["GEMINI_API_KEY"];
}
if (provider === "grok") {
return ["XAI_API_KEY"];
}
if (provider === "kimi") {
return ["KIMI_API_KEY", "MOONSHOT_API_KEY"];
}
return ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"];
function envVarsForProvider(
metadataByProvider: Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport>,
provider: WebSearchProvider,
): string[] {
return [
...(resolveProviderCredentialMetadata(metadataByProvider.get(provider)?.setup)?.envKeys ?? []),
];
}
function resolveProviderKeyValue(
metadataByProvider: Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport>,
search: Record<string, unknown>,
provider: WebSearchProvider,
): unknown {
if (provider === "brave") {
return search.apiKey;
}
const scoped = search[provider];
if (!isRecord(scoped)) {
return undefined;
}
return scoped.apiKey;
return resolveProviderCredentialMetadata(
metadataByProvider.get(provider)?.setup,
)?.readApiKeyValue?.(search);
}
function providerConfigPath(
metadataByProvider: Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport>,
provider: WebSearchProvider,
): string {
return (
resolveProviderCredentialMetadata(metadataByProvider.get(provider)?.setup)?.apiKeyConfigPath ??
"tools.web.search.provider"
);
}
function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean {
@ -366,6 +343,7 @@ export async function resolveRuntimeWebTools(params: {
const tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined;
const web = isRecord(tools?.web) ? tools.web : undefined;
const search = isRecord(web?.search) ? web.search : undefined;
const searchProviderMetadata = resolveRegisteredSearchProviderMetadata(params.sourceConfig);
const searchMetadata: RuntimeWebSearchMetadata = {
providerSource: "none",
@ -376,8 +354,12 @@ export async function resolveRuntimeWebTools(params: {
const rawProvider =
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
const configuredProvider = normalizeProvider(rawProvider);
const knownProviders = [...searchProviderMetadata.keys()];
const hasConfiguredSelection = Boolean(
configuredProvider && searchProviderMetadata.has(configuredProvider),
);
if (rawProvider && !configuredProvider) {
if (rawProvider && (!configuredProvider || !searchProviderMetadata.has(configuredProvider))) {
const diagnostic: RuntimeWebDiagnostic = {
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
message: `tools.web.search.provider is "${rawProvider}". Falling back to auto-detect precedence.`,
@ -392,13 +374,18 @@ export async function resolveRuntimeWebTools(params: {
});
}
if (configuredProvider) {
if (hasConfiguredSelection && configuredProvider) {
searchMetadata.providerConfigured = configuredProvider;
searchMetadata.providerSource = "configured";
}
if (searchEnabled && search) {
const candidates = configuredProvider ? [configuredProvider] : [...WEB_SEARCH_PROVIDERS];
const candidates =
hasConfiguredSelection && configuredProvider
? [configuredProvider]
: knownProviders.filter((provider) =>
Boolean(resolveProviderCredentialMetadata(searchProviderMetadata.get(provider)?.setup)),
);
const unresolvedWithoutFallback: Array<{
provider: WebSearchProvider;
path: string;
@ -409,16 +396,15 @@ export async function resolveRuntimeWebTools(params: {
let selectedResolution: SecretResolutionResult | undefined;
for (const provider of candidates) {
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
const path = providerConfigPath(searchProviderMetadata, provider);
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
const resolution = await resolveSecretInputWithEnvFallback({
sourceConfig: params.sourceConfig,
context: params.context,
defaults,
value,
path,
envVars: envVarsForProvider(provider),
envVars: envVarsForProvider(searchProviderMetadata, provider),
});
if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) {
@ -446,13 +432,15 @@ export async function resolveRuntimeWebTools(params: {
});
}
if (configuredProvider) {
if (hasConfiguredSelection) {
selectedProvider = provider;
selectedResolution = resolution;
if (resolution.value) {
const metadata = searchProviderMetadata.get(provider);
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
metadata: metadata ?? {},
value: resolution.value,
});
}
@ -462,9 +450,11 @@ export async function resolveRuntimeWebTools(params: {
if (resolution.value) {
selectedProvider = provider;
selectedResolution = resolution;
const metadata = searchProviderMetadata.get(provider);
setResolvedWebSearchApiKey({
resolvedConfig: params.resolvedConfig,
provider,
metadata: metadata ?? {},
value: resolution.value,
});
break;
@ -487,7 +477,7 @@ export async function resolveRuntimeWebTools(params: {
throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`);
};
if (configuredProvider) {
if (hasConfiguredSelection) {
const unresolved = unresolvedWithoutFallback[0];
if (unresolved) {
failUnresolvedSearchNoFallback(unresolved);
@ -511,28 +501,34 @@ export async function resolveRuntimeWebTools(params: {
if (selectedProvider) {
searchMetadata.selectedProvider = selectedProvider;
searchMetadata.selectedProviderKeySource = selectedResolution?.source;
if (!configuredProvider) {
if (!hasConfiguredSelection) {
searchMetadata.providerSource = "auto-detect";
}
if (selectedProvider === "perplexity") {
searchMetadata.perplexityTransport = resolvePerplexityRuntimeTransport({
const runtimeMetadata = searchProviderMetadata
.get(selectedProvider)
?.resolveRuntimeMetadata?.({
search,
keyValue: selectedResolution?.value,
keySource: selectedResolution?.source ?? "missing",
fallbackEnvVar: selectedResolution?.fallbackEnvVar,
configValue: search.perplexity,
});
const perplexityTransport =
runtimeMetadata && typeof runtimeMetadata.perplexityTransport === "string"
? runtimeMetadata.perplexityTransport
: undefined;
if (perplexityTransport === "search_api" || perplexityTransport === "chat_completions") {
searchMetadata.perplexityTransport = perplexityTransport;
}
}
}
if (searchEnabled && search && !configuredProvider && searchMetadata.selectedProvider) {
for (const provider of WEB_SEARCH_PROVIDERS) {
if (searchEnabled && search && !hasConfiguredSelection && searchMetadata.selectedProvider) {
for (const provider of knownProviders) {
if (provider === searchMetadata.selectedProvider) {
continue;
}
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
const path = providerConfigPath(searchProviderMetadata, provider);
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
@ -543,10 +539,9 @@ export async function resolveRuntimeWebTools(params: {
});
}
} else if (search && !searchEnabled) {
for (const provider of WEB_SEARCH_PROVIDERS) {
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
for (const provider of knownProviders) {
const path = providerConfigPath(searchProviderMetadata, provider);
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
@ -558,21 +553,20 @@ export async function resolveRuntimeWebTools(params: {
}
}
if (searchEnabled && search && configuredProvider) {
for (const provider of WEB_SEARCH_PROVIDERS) {
if (provider === configuredProvider) {
if (searchEnabled && search && hasConfiguredSelection && searchMetadata.providerConfigured) {
for (const provider of knownProviders) {
if (provider === searchMetadata.providerConfigured) {
continue;
}
const path =
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
const value = resolveProviderKeyValue(search, provider);
const path = providerConfigPath(searchProviderMetadata, provider);
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
if (!hasConfiguredSecretRef(value, defaults)) {
continue;
}
pushInactiveSurfaceWarning({
context: params.context,
path,
details: `tools.web.search.provider is "${configuredProvider}".`,
details: `tools.web.search.provider is "${searchMetadata.providerConfigured}".`,
});
}
}

View File

@ -19,6 +19,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
typedHooks: [],
channels: channels as unknown as PluginRegistry["channels"],
providers: [],
searchProviders: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import type { RuntimeEnv } from "../runtime.js";
@ -20,6 +20,9 @@ const gatewayServiceRestart = vi.hoisted(() =>
);
const gatewayServiceUninstall = vi.hoisted(() => vi.fn(async () => {}));
const gatewayServiceIsLoaded = vi.hoisted(() => vi.fn(async () => false));
const loadOpenClawPlugins = vi.hoisted(() =>
vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })),
);
const resolveGatewayInstallToken = vi.hoisted(() =>
vi.fn(async () => ({
token: undefined,
@ -88,6 +91,10 @@ vi.mock("../infra/control-ui-assets.js", () => ({
ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })),
}));
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins,
}));
vi.mock("../terminal/restore.js", () => ({
restoreTerminalState: vi.fn(),
}));
@ -118,6 +125,10 @@ function expectFirstOnboardingInstallPlanCallOmitsToken() {
}
describe("finalizeOnboardingWizard", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
runTui.mockClear();
probeGatewayReachable.mockClear();
@ -130,6 +141,8 @@ describe("finalizeOnboardingWizard", () => {
gatewayServiceRestart.mockResolvedValue({ outcome: "completed" });
gatewayServiceUninstall.mockReset();
resolveGatewayInstallToken.mockClear();
loadOpenClawPlugins.mockReset();
loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] });
isSystemdUserServiceAvailable.mockReset();
isSystemdUserServiceAvailable.mockResolvedValue(true);
});
@ -307,4 +320,151 @@ describe("finalizeOnboardingWizard", () => {
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
});
it("does not report plugin-provided web search providers as missing API keys", async () => {
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: undefined,
configUiHints: undefined,
},
],
});
const prompter = buildWizardPrompter({
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
const runtime = createRuntime();
await finalizeOnboardingWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {
tools: {
web: {
search: {
enabled: true,
provider: "tavily",
},
},
},
},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime,
});
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search");
expect(webSearchNote?.[0]).toContain("plugin-provided provider");
expect(webSearchNote?.[0]).not.toContain("no API key was found");
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp",
}),
);
});
it("describes the chosen provider as the active provider when multiple are configured", async () => {
vi.stubEnv("BRAVE_API_KEY", "BSA-test-key");
loadOpenClawPlugins.mockReturnValue({
searchProviders: [
{
pluginId: "tavily-search",
provider: {
id: "tavily",
name: "Tavily Search",
description: "Plugin search",
isAvailable: () => true,
search: async () => ({ content: "ok" }),
},
},
],
plugins: [
{
id: "tavily-search",
name: "Tavily Search",
description: "External Tavily plugin",
origin: "workspace",
source: "/tmp/tavily-search",
configJsonSchema: undefined,
configUiHints: undefined,
},
],
});
const prompter = buildWizardPrompter({
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
const runtime = createRuntime();
await finalizeOnboardingWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {
tools: {
web: {
search: {
enabled: true,
provider: "tavily",
},
},
},
},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime,
});
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
const webSearchNote = noteCalls.find((call) => call?.[1] === "Web search");
expect(webSearchNote?.[0]).toContain("Active provider: Tavily Search");
expect(webSearchNote?.[0]).toContain("plugin-provided provider");
});
});

View File

@ -26,6 +26,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { resolveCapabilitySlotSelection } from "../plugins/capability-slots.js";
import type { RuntimeEnv } from "../runtime.js";
import { restoreTerminalState } from "../terminal/restore.js";
import { runTui } from "../tui/tui.js";
@ -481,30 +482,72 @@ export async function finalizeOnboardingWizard(
);
}
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
const webSearchProvider = resolveCapabilitySlotSelection(nextConfig, "providers.search");
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
if (webSearchProvider) {
const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } =
await import("../commands/onboard-search.js");
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider);
const label = entry?.label ?? webSearchProvider;
const storedKey = resolveExistingKey(nextConfig, webSearchProvider);
const keyConfigured = hasExistingKey(nextConfig, webSearchProvider);
const envAvailable = entry ? hasKeyInEnv(entry) : false;
const {
resolveExistingKey,
hasExistingKey,
hasKeyInEnv,
resolveSearchProviderPickerEntry,
resolveSearchProviderPickerEntries,
} = await import("../commands/onboard-search.js");
const pickerEntry = await resolveSearchProviderPickerEntry(
nextConfig,
webSearchProvider,
options.workspaceDir,
);
const providerEntries = await resolveSearchProviderPickerEntries(
nextConfig,
options.workspaceDir,
);
const configuredProviderCount = providerEntries.filter((entry) => entry.configured).length;
const label = pickerEntry?.label ?? webSearchProvider;
const credentialMetadata = pickerEntry?.setup?.credentials;
const storedKey = credentialMetadata
? resolveExistingKey(nextConfig, credentialMetadata)
: undefined;
const keyConfigured = credentialMetadata
? hasExistingKey(nextConfig, credentialMetadata)
: false;
const envAvailable = credentialMetadata ? hasKeyInEnv(credentialMetadata) : false;
const hasKey = keyConfigured || envAvailable;
const keySource = storedKey
? "API key: stored in config."
: keyConfigured
? "API key: configured via secret reference."
: envAvailable
? `API key: provided via ${entry?.envKeys.join(" / ")} env var.`
? `API key: provided via ${credentialMetadata?.envKeys?.join(" / ")} env var.`
: undefined;
if (webSearchEnabled !== false && hasKey) {
if (!credentialMetadata) {
await prompter.note(
[
webSearchEnabled !== false
? "Web search is enabled through a plugin-provided provider."
: "Web search is configured through a plugin-provided provider but currently disabled.",
"",
`Active provider: ${label}`,
...(configuredProviderCount > 1
? [
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
]
: []),
"Plugin-managed providers may use plugin config or plugin-specific credentials instead of the built-in API key fields.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
} else if (webSearchEnabled !== false && hasKey) {
await prompter.note(
[
"Web search is enabled, so your agent can look things up online when needed.",
"",
`Provider: ${label}`,
`Active provider: ${label}`,
...(configuredProviderCount > 1
? [
"Multiple web search providers are configured; the others remain available to switch to later via configure.",
]
: []),
...(keySource ? [keySource] : []),
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
@ -517,7 +560,7 @@ export async function finalizeOnboardingWizard(
"web_search will not work until a key is added.",
` ${formatCliCommand("openclaw configure --section web")}`,
"",
`Get your key at: ${entry?.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`,
`Get your key at: ${credentialMetadata.signupUrl ?? "https://docs.openclaw.ai/tools/web"}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
@ -536,15 +579,22 @@ export async function finalizeOnboardingWizard(
} else {
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
// an explicit provider. Runtime auto-detects these, so avoid saying "skipped".
const { SEARCH_PROVIDER_OPTIONS, hasExistingKey, hasKeyInEnv } =
const { resolveSearchProviderPickerEntries, hasExistingKey, hasKeyInEnv } =
await import("../commands/onboard-search.js");
const legacyDetected = SEARCH_PROVIDER_OPTIONS.find(
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
const providerEntries = await resolveSearchProviderPickerEntries(
nextConfig,
options.workspaceDir,
);
if (legacyDetected) {
const detectedEntry = providerEntries.find(
(entry) =>
Boolean(entry.setup?.credentials) &&
(hasExistingKey(nextConfig, entry.setup!.credentials!) ||
hasKeyInEnv(entry.setup!.credentials!)),
);
if (detectedEntry) {
await prompter.note(
[
`Web search is available via ${legacyDetected.label} (auto-detected).`,
`Web search is available via ${detectedEntry.label} (auto-detected).`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",

View File

@ -531,6 +531,7 @@ export async function runOnboardingWizard(
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
quickstartDefaults: flow === "quickstart",
secretInputMode: opts.secretInputMode,
workspaceDir,
});
}

View File

@ -10,7 +10,11 @@ const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const ciWorkers = isWindows ? 2 : 3;
const pluginSdkSubpaths = [
"account-id",
"account-resolution",
"allow-from",
"boolean-param",
"core",
"web-search",
"compat",
"telegram",
"discord",
@ -29,7 +33,9 @@ const pluginSdkSubpaths = [
"feishu",
"google-gemini-cli-auth",
"googlechat",
"group-access",
"irc",
"json-store",
"llm-task",
"lobster",
"matrix",
@ -47,11 +53,13 @@ const pluginSdkSubpaths = [
"test-utils",
"thread-ownership",
"tlon",
"tool-send",
"twitch",
"voice-call",
"zalo",
"zalouser",
"keyed-async-queue",
"request-url",
] as const;
export default defineConfig({