Compare commits
23 Commits
main
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9d290554 | ||
|
|
c87b8b549b | ||
|
|
ab26781b13 | ||
|
|
ead2968bd6 | ||
|
|
d96601a8a2 | ||
|
|
74b5c2e875 | ||
|
|
98c5c04608 | ||
|
|
f4ea5221df | ||
|
|
8e5b535d48 | ||
|
|
80206bf20a | ||
|
|
04769d7fe2 | ||
|
|
e2b7c4c6a3 | ||
|
|
685f6c5132 | ||
|
|
7603c30377 | ||
|
|
aecb2fc62d | ||
|
|
5d0012471c | ||
|
|
9cffd72953 | ||
|
|
0997deb0e9 | ||
|
|
034798b101 | ||
|
|
8542194901 | ||
|
|
667cc46f01 | ||
|
|
3396e21d79 | ||
|
|
d7f5a6d308 |
@ -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):
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
@ -43,6 +43,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerSearchProvider() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerCommand() {},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
9
extensions/search-brave/openclaw.plugin.json
Normal file
9
extensions/search-brave/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "search-brave",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.brave"]
|
||||
}
|
||||
12
extensions/search-brave/package.json
Normal file
12
extensions/search-brave/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
extensions/search-brave/src/index.ts
Normal file
13
extensions/search-brave/src/index.ts
Normal 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;
|
||||
606
extensions/search-brave/src/provider.ts
Normal file
606
extensions/search-brave/src/provider.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
9
extensions/search-gemini/openclaw.plugin.json
Normal file
9
extensions/search-gemini/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "search-gemini",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.gemini"]
|
||||
}
|
||||
12
extensions/search-gemini/package.json
Normal file
12
extensions/search-gemini/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
extensions/search-gemini/src/index.ts
Normal file
13
extensions/search-gemini/src/index.ts
Normal 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;
|
||||
261
extensions/search-gemini/src/provider.ts
Normal file
261
extensions/search-gemini/src/provider.ts
Normal 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;
|
||||
9
extensions/search-grok/openclaw.plugin.json
Normal file
9
extensions/search-grok/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "search-grok",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.grok"]
|
||||
}
|
||||
12
extensions/search-grok/package.json
Normal file
12
extensions/search-grok/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
extensions/search-grok/src/index.ts
Normal file
13
extensions/search-grok/src/index.ts
Normal 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;
|
||||
272
extensions/search-grok/src/provider.ts
Normal file
272
extensions/search-grok/src/provider.ts
Normal 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;
|
||||
9
extensions/search-kimi/openclaw.plugin.json
Normal file
9
extensions/search-kimi/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "search-kimi",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.kimi"]
|
||||
}
|
||||
12
extensions/search-kimi/package.json
Normal file
12
extensions/search-kimi/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
extensions/search-kimi/src/index.ts
Normal file
13
extensions/search-kimi/src/index.ts
Normal 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;
|
||||
323
extensions/search-kimi/src/provider.ts
Normal file
323
extensions/search-kimi/src/provider.ts
Normal 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;
|
||||
9
extensions/search-perplexity/openclaw.plugin.json
Normal file
9
extensions/search-perplexity/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "search-perplexity",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.perplexity"]
|
||||
}
|
||||
12
extensions/search-perplexity/package.json
Normal file
12
extensions/search-perplexity/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
extensions/search-perplexity/src/index.ts
Normal file
13
extensions/search-perplexity/src/index.ts
Normal 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;
|
||||
662
extensions/search-perplexity/src/provider.ts
Normal file
662
extensions/search-perplexity/src/provider.ts
Normal 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;
|
||||
@ -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 =
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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";
|
||||
|
||||
138
extensions/tavily-search/index.test.ts
Normal file
138
extensions/tavily-search/index.test.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
197
extensions/tavily-search/index.ts
Normal file
197
extensions/tavily-search/index.ts
Normal 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;
|
||||
34
extensions/tavily-search/openclaw.plugin.json
Normal file
34
extensions/tavily-search/openclaw.plugin.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
16
extensions/tavily-search/package.json
Normal file
16
extensions/tavily-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
35
package.json
35
package.json
@ -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
12
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
66
scripts/check-plugin-import-boundaries.test.ts
Normal file
66
scripts/check-plugin-import-boundaries.test.ts
Normal 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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
289
scripts/check-plugin-import-boundaries.ts
Normal file
289
scripts/check-plugin-import-boundaries.ts
Normal 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);
|
||||
10034
scripts/plugin-import-boundaries.baseline.json
Normal file
10034
scripts/plugin-import-boundaries.baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
|
||||
@ -43,6 +43,7 @@ export function resolvePluginSkillDirs(params: {
|
||||
origin: record.origin,
|
||||
config: normalizedPlugins,
|
||||
rootConfig: params.config,
|
||||
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
|
||||
});
|
||||
if (!enableState.enabled) {
|
||||
continue;
|
||||
|
||||
@ -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
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -82,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
commands: [],
|
||||
channels,
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
78
src/commands/provider-management.ts
Normal file
78
src/commands/provider-management.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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" }] },
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -28,6 +28,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
channels: [],
|
||||
commands: [],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@ -10,6 +10,7 @@ export const registryState: { registry: PluginRegistry } = {
|
||||
typedHooks: [],
|
||||
channels: [],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
httpRoutes: [],
|
||||
|
||||
@ -145,6 +145,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
},
|
||||
],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@ -107,6 +107,12 @@ describe("plugin-sdk exports", () => {
|
||||
"probeTelegram",
|
||||
"probeIMessage",
|
||||
"probeSignal",
|
||||
"createBundledSearchProviderAdapter",
|
||||
"createBundledBraveSearchProvider",
|
||||
"createBundledGeminiSearchProvider",
|
||||
"createBundledGrokSearchProvider",
|
||||
"createBundledKimiSearchProvider",
|
||||
"createBundledPerplexitySearchProvider",
|
||||
];
|
||||
|
||||
for (const key of forbidden) {
|
||||
|
||||
@ -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");
|
||||
|
||||
266
src/plugin-sdk/web-search.ts
Normal file
266
src/plugin-sdk/web-search.ts
Normal 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
115
src/plugins/capabilities.ts
Normal 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);
|
||||
}
|
||||
54
src/plugins/capability-slots.test.ts
Normal file
54
src/plugins/capability-slots.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
10
src/plugins/capability-slots.ts
Normal file
10
src/plugins/capability-slots.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export {
|
||||
applyCapabilitySlotSelection,
|
||||
resolveCapabilitySlotConfigPath,
|
||||
resolveCapabilitySlotForKind,
|
||||
resolveCapabilitySlotModeForKind,
|
||||
resolveCapabilitySlotSelection,
|
||||
type CapabilitySlotId,
|
||||
type PluginCapabilityKind,
|
||||
type PluginCapabilitySlotMode,
|
||||
} from "./capabilities.js";
|
||||
@ -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", () => {
|
||||
|
||||
@ -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)" &&
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -20,6 +20,7 @@ export function createMockPluginRegistry(
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
commands: [],
|
||||
} as unknown as PluginRegistry;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
82
src/plugins/registry.search-provider.test.ts
Normal file
82
src/plugins/registry.search-provider.test.ts
Normal 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)",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
typedHooks: [],
|
||||
channels: channels as unknown as PluginRegistry["channels"],
|
||||
providers: [],
|
||||
searchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -531,6 +531,7 @@ export async function runOnboardingWizard(
|
||||
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
|
||||
quickstartDefaults: flow === "quickstart",
|
||||
secretInputMode: opts.secretInputMode,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user