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`
|
- Coverage gate: `pnpm test:coverage`
|
||||||
- E2E suite: `pnpm test:e2e`
|
- 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):
|
When debugging real providers/models (requires real creds):
|
||||||
|
|
||||||
|
|||||||
@ -201,7 +201,7 @@ Notes:
|
|||||||
|
|
||||||
- Where to host SDK types: separate package or core export?
|
- Where to host SDK types: separate package or core export?
|
||||||
- Runtime type distribution: in SDK (types only) or in core?
|
- 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?
|
- Do we allow limited direct core imports for in-repo plugins during transition?
|
||||||
|
|
||||||
## Success criteria
|
## Success criteria
|
||||||
|
|||||||
@ -28,7 +28,7 @@ For local PR land/gate checks, run:
|
|||||||
- `pnpm test`
|
- `pnpm test`
|
||||||
- `pnpm check:docs`
|
- `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`
|
- `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
|
- New and migrated bundled plugins should use channel or extension-specific
|
||||||
subpaths; use `core` for generic surfaces and `compat` only when broader
|
subpaths; use `core` for generic surfaces and `compat` only when broader
|
||||||
shared helpers are required.
|
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
|
## Read-only channel inspection
|
||||||
|
|
||||||
|
|||||||
@ -80,6 +80,18 @@ describe("diffs plugin registration", () => {
|
|||||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||||
registeredHttpRouteHandler = params.handler;
|
registeredHttpRouteHandler = params.handler;
|
||||||
},
|
},
|
||||||
|
registerChannel() {},
|
||||||
|
registerGatewayMethod() {},
|
||||||
|
registerCli() {},
|
||||||
|
registerService() {},
|
||||||
|
registerProvider() {},
|
||||||
|
registerSearchProvider() {},
|
||||||
|
registerCommand() {},
|
||||||
|
registerContextEngine() {},
|
||||||
|
resolvePath(input: string) {
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
on() {},
|
||||||
});
|
});
|
||||||
|
|
||||||
plugin.register?.(api as unknown as OpenClawPluginApi);
|
plugin.register?.(api as unknown as OpenClawPluginApi);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||||
import {
|
import {
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readStringArrayParam,
|
readStringArrayParam,
|
||||||
@ -8,7 +9,6 @@ import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-a
|
|||||||
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
|
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
|
||||||
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
|
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
|
||||||
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.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 { resolveDiscordChannelId } from "../targets.js";
|
||||||
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
|
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Guild, User } from "@buape/carbon";
|
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 type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js";
|
||||||
import {
|
import {
|
||||||
buildChannelKeyCandidates,
|
buildChannelKeyCandidates,
|
||||||
@ -6,7 +7,6 @@ import {
|
|||||||
resolveChannelMatchConfig,
|
resolveChannelMatchConfig,
|
||||||
type ChannelMatchSource,
|
type ChannelMatchSource,
|
||||||
} from "../../../../src/channels/channel-config.js";
|
} from "../../../../src/channels/channel-config.js";
|
||||||
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
|
|
||||||
import { formatDiscordUserTag } from "./format.js";
|
import { formatDiscordUserTag } from "./format.js";
|
||||||
|
|
||||||
export type DiscordAllowList = {
|
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 { createRunStateMachine } from "../../../../src/channels/run-state-machine.js";
|
||||||
import { danger } from "../../../../src/globals.js";
|
import { danger } from "../../../../src/globals.js";
|
||||||
import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts";
|
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 { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
|
||||||
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
|
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
|
||||||
import { processDiscordMessage } from "./message-handler.process.js";
|
import { processDiscordMessage } from "./message-handler.process.js";
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||||
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
|
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
|
||||||
import { resolveStateDir } from "../../../../src/config/paths.js";
|
import { resolveStateDir } from "../../../../src/config/paths.js";
|
||||||
import { withFileLock } from "../../../../src/infra/file-lock.js";
|
import { withFileLock } from "../../../../src/infra/file-lock.js";
|
||||||
import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.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";
|
import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js";
|
||||||
|
|
||||||
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
|
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 };
|
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,7 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
|||||||
registerCli() {},
|
registerCli() {},
|
||||||
registerService() {},
|
registerService() {},
|
||||||
registerProvider() {},
|
registerProvider() {},
|
||||||
|
registerSearchProvider() {},
|
||||||
registerHook() {},
|
registerHook() {},
|
||||||
registerHttpRoute() {},
|
registerHttpRoute() {},
|
||||||
registerCommand() {},
|
registerCommand() {},
|
||||||
|
|||||||
@ -31,6 +31,17 @@ function createApi(params: {
|
|||||||
writeConfigFile: (next: Record<string, unknown>) => params.writeConfig(next),
|
writeConfigFile: (next: Record<string, unknown>) => params.writeConfig(next),
|
||||||
},
|
},
|
||||||
} as OpenClawPluginApi["runtime"],
|
} as OpenClawPluginApi["runtime"],
|
||||||
|
logger: { info() {}, warn() {}, error() {} },
|
||||||
|
registerTool() {},
|
||||||
|
registerHook() {},
|
||||||
|
registerHttpRoute() {},
|
||||||
|
registerChannel() {},
|
||||||
|
registerGatewayMethod() {},
|
||||||
|
registerCli() {},
|
||||||
|
registerService() {},
|
||||||
|
registerProvider() {},
|
||||||
|
registerSearchProvider() {},
|
||||||
|
registerContextEngine() {},
|
||||||
registerCommand: params.registerCommand,
|
registerCommand: params.registerCommand,
|
||||||
}) as OpenClawPluginApi;
|
}) 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";
|
import { normalizeE164 } from "../../../src/utils.js";
|
||||||
|
|
||||||
export type SignalSender =
|
export type SignalSender =
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
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 { normalizeHostname } from "../../../../src/infra/net/hostname.js";
|
||||||
import type { FetchLike } from "../../../../src/media/fetch.js";
|
import type { FetchLike } from "../../../../src/media/fetch.js";
|
||||||
import { fetchRemoteMedia } from "../../../../src/media/fetch.js";
|
import { fetchRemoteMedia } from "../../../../src/media/fetch.js";
|
||||||
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||||
import { resolveRequestUrl } from "../../../../src/plugin-sdk/request-url.js";
|
|
||||||
import type { SlackAttachment, SlackFile } from "../types.js";
|
import type { SlackAttachment, SlackFile } from "../types.js";
|
||||||
|
|
||||||
function isSlackHostname(hostname: string): boolean {
|
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: {
|
export function isSlackChannelAllowedByPolicy(params: {
|
||||||
groupPolicy: "open" | "disabled" | "allowlist";
|
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 type { OpenClawConfig } from "../../../src/config/config.js";
|
||||||
import {
|
import {
|
||||||
coerceSecretRef,
|
coerceSecretRef,
|
||||||
@ -6,7 +7,6 @@ import {
|
|||||||
} from "../../../src/config/types.secrets.js";
|
} from "../../../src/config/types.secrets.js";
|
||||||
import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js";
|
import type { TelegramAccountConfig } from "../../../src/config/types.telegram.js";
|
||||||
import { tryReadSecretFileSync } from "../../../src/infra/secret-file.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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||||
import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js";
|
import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import util from "node:util";
|
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 { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js";
|
||||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||||
import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js";
|
import type { TelegramAccountConfig, TelegramActionConfig } from "../../../src/config/types.js";
|
||||||
import { isTruthyEnvValue } from "../../../src/infra/env.js";
|
import { isTruthyEnvValue } from "../../../src/infra/env.js";
|
||||||
import { createSubsystemLogger } from "../../../src/logging/subsystem.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 { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||||
import {
|
import {
|
||||||
listBoundAccountIds,
|
listBoundAccountIds,
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||||
|
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||||
import {
|
import {
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readStringArrayParam,
|
readStringArrayParam,
|
||||||
@ -15,8 +17,6 @@ import type {
|
|||||||
ChannelMessageActionName,
|
ChannelMessageActionName,
|
||||||
} from "../../../src/channels/plugins/types.js";
|
} from "../../../src/channels/plugins/types.js";
|
||||||
import type { TelegramActionConfig } from "../../../src/config/types.telegram.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 { resolveTelegramPollVisibility } from "../../../src/poll-params.js";
|
||||||
import {
|
import {
|
||||||
createTelegramActionGate,
|
createTelegramActionGate,
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||||
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
import type { ChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
||||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../src/config/runtime-group-policy.js";
|
||||||
@ -7,7 +8,6 @@ import type {
|
|||||||
TelegramGroupConfig,
|
TelegramGroupConfig,
|
||||||
TelegramTopicConfig,
|
TelegramTopicConfig,
|
||||||
} from "../../../src/config/types.js";
|
} from "../../../src/config/types.js";
|
||||||
import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
|
|
||||||
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
|
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
|
||||||
import { firstDefined } 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",
|
"types": "./dist/plugin-sdk/core.d.ts",
|
||||||
"default": "./dist/plugin-sdk/core.js"
|
"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": {
|
"./plugin-sdk/compat": {
|
||||||
"types": "./dist/plugin-sdk/compat.d.ts",
|
"types": "./dist/plugin-sdk/compat.d.ts",
|
||||||
"default": "./dist/plugin-sdk/compat.js"
|
"default": "./dist/plugin-sdk/compat.js"
|
||||||
@ -208,10 +212,38 @@
|
|||||||
"types": "./dist/plugin-sdk/account-id.d.ts",
|
"types": "./dist/plugin-sdk/account-id.d.ts",
|
||||||
"default": "./dist/plugin-sdk/account-id.js"
|
"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": {
|
"./plugin-sdk/keyed-async-queue": {
|
||||||
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
|
"types": "./dist/plugin-sdk/keyed-async-queue.d.ts",
|
||||||
"default": "./dist/plugin-sdk/keyed-async-queue.js"
|
"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"
|
"./cli-entry": "./openclaw.mjs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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: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: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'",
|
"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:agent:ingress-owner": "node scripts/check-ingress-agent-owner-context.mjs",
|
||||||
"lint:all": "pnpm lint && pnpm lint:swift",
|
"lint:all": "pnpm lint && pnpm lint:swift",
|
||||||
"lint:auth:no-pairing-store-group": "node scripts/check-no-pairing-store-group-auth.mjs",
|
"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": "pnpm dlx markdownlint-cli2",
|
||||||
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
|
||||||
"lint:fix": "oxlint --type-aware --fix && pnpm format",
|
"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-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: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)",
|
"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/sglang: {}
|
||||||
|
|
||||||
|
extensions/search-brave: {}
|
||||||
|
|
||||||
|
extensions/search-gemini: {}
|
||||||
|
|
||||||
|
extensions/search-grok: {}
|
||||||
|
|
||||||
|
extensions/search-kimi: {}
|
||||||
|
|
||||||
|
extensions/search-perplexity: {}
|
||||||
|
|
||||||
extensions/signal: {}
|
extensions/signal: {}
|
||||||
|
|
||||||
extensions/slack: {}
|
extensions/slack: {}
|
||||||
@ -466,6 +476,8 @@ importers:
|
|||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
|
|
||||||
|
extensions/tavily-search: {}
|
||||||
|
|
||||||
extensions/telegram: {}
|
extensions/telegram: {}
|
||||||
|
|
||||||
extensions/tlon:
|
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 { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||||
import { normalizeProviderId } from "./model-selection.js";
|
import { normalizeProviderId } from "./model-selection.js";
|
||||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||||
|
import { discoverAuthStorage, discoverModels } from "./pi-model-discovery-runtime.js";
|
||||||
|
|
||||||
type ModelEntry = { id: string; contextWindow?: number };
|
type ModelEntry = { id: string; contextWindow?: number };
|
||||||
type ModelRegistryLike = {
|
type ModelRegistryLike = {
|
||||||
@ -157,8 +158,6 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { discoverAuthStorage, discoverModels } =
|
|
||||||
await import("./pi-model-discovery-runtime.js");
|
|
||||||
const agentDir = resolveOpenClawAgentDir();
|
const agentDir = resolveOpenClawAgentDir();
|
||||||
const authStorage = discoverAuthStorage(agentDir);
|
const authStorage = discoverAuthStorage(agentDir);
|
||||||
const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike;
|
const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike;
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export function resolvePluginSkillDirs(params: {
|
|||||||
origin: record.origin,
|
origin: record.origin,
|
||||||
config: normalizedPlugins,
|
config: normalizedPlugins,
|
||||||
rootConfig: params.config,
|
rootConfig: params.config,
|
||||||
|
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
|
||||||
});
|
});
|
||||||
if (!enableState.enabled) {
|
if (!enableState.enabled) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -1,174 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { withEnv } from "../../test-utils/env.js";
|
|
||||||
import { __testing } from "./web-search.js";
|
import { __testing } from "./web-search.js";
|
||||||
|
|
||||||
const {
|
const { normalizeToIsoDate } = __testing;
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("web_search date normalization", () => {
|
describe("web_search date normalization", () => {
|
||||||
it("accepts ISO format", () => {
|
it("accepts ISO format", () => {
|
||||||
@ -186,285 +19,4 @@ describe("web_search date normalization", () => {
|
|||||||
expect(normalizeToIsoDate("2024/01/15")).toBeUndefined();
|
expect(normalizeToIsoDate("2024/01/15")).toBeUndefined();
|
||||||
expect(normalizeToIsoDate("invalid")).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 { EnvHttpProxyAgent } from "undici";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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 { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
|
||||||
import { __testing as webSearchTesting } from "./web-search.js";
|
import { __testing as webSearchTesting } from "./web-search.js";
|
||||||
import { createWebFetchTool, createWebSearchTool } from "./web-tools.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) {
|
function installMockFetch(payload: unknown) {
|
||||||
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
const mockFetch = vi.fn((_input?: unknown, _init?: unknown) =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
@ -169,8 +234,8 @@ describe("web tools defaults", () => {
|
|||||||
expect(tool?.name).toBe("web_search");
|
expect(tool?.name).toBe("web_search");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers runtime-selected web_search provider over local provider config", async () => {
|
it("uses the configured web_search provider from config", async () => {
|
||||||
const mockFetch = installMockFetch(createProviderSuccessPayload("gemini"));
|
const mockFetch = installMockFetch(createProviderSuccessPayload("brave"));
|
||||||
const tool = createWebSearchTool({
|
const tool = createWebSearchTool({
|
||||||
config: {
|
config: {
|
||||||
tools: {
|
tools: {
|
||||||
@ -186,20 +251,380 @@ describe("web tools defaults", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
sandboxed: true,
|
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(mockFetch).toHaveBeenCalled();
|
||||||
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("generativelanguage.googleapis.com");
|
expect(String(mockFetch.mock.calls[0]?.[0])).toContain("api.search.brave.com");
|
||||||
expect((result?.details as { provider?: string } | undefined)?.provider).toBe("gemini");
|
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();
|
vi.unstubAllEnvs();
|
||||||
global.fetch = priorFetch;
|
global.fetch = priorFetch;
|
||||||
webSearchTesting.SEARCH_CACHE.clear();
|
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 () => {
|
it("uses Perplexity Search API when PERPLEXITY_API_KEY is set", async () => {
|
||||||
@ -467,6 +897,11 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
|||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
global.fetch = priorFetch;
|
global.fetch = priorFetch;
|
||||||
webSearchTesting.SEARCH_CACHE.clear();
|
webSearchTesting.SEARCH_CACHE.clear();
|
||||||
|
bundledBraveTesting.clearSearchProviderCaches();
|
||||||
|
bundledPerplexityTesting.clearSearchProviderCaches();
|
||||||
|
bundledGrokTesting.clearSearchProviderCaches();
|
||||||
|
bundledGeminiTesting.clearSearchProviderCaches();
|
||||||
|
bundledKimiTesting.clearSearchProviderCaches();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes OPENROUTER_API_KEY through chat completions", async () => {
|
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 { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||||
import { lookupContextTokens } from "../../agents/context.js";
|
import { lookupContextTokens } from "../../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||||
@ -365,7 +366,6 @@ export async function createModelSelectionState(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
|
if (sessionEntry && sessionStore && sessionKey && sessionEntry.authProfileOverride) {
|
||||||
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.runtime.js");
|
|
||||||
const store = ensureAuthProfileStore(undefined, {
|
const store = ensureAuthProfileStore(undefined, {
|
||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -82,6 +82,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
|||||||
commands: [],
|
commands: [],
|
||||||
channels,
|
channels,
|
||||||
providers: [],
|
providers: [],
|
||||||
|
searchProviders: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
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";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
@ -20,6 +20,20 @@ const mocks = vi.hoisted(() => ({
|
|||||||
summarizeExistingConfig: vi.fn(),
|
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", () => ({
|
vi.mock("@clack/prompts", () => ({
|
||||||
intro: mocks.clackIntro,
|
intro: mocks.clackIntro,
|
||||||
outro: mocks.clackOutro,
|
outro: mocks.clackOutro,
|
||||||
@ -43,6 +57,20 @@ vi.mock("../wizard/clack-prompter.js", () => ({
|
|||||||
createClackPrompter: mocks.createClackPrompter,
|
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", () => ({
|
vi.mock("../terminal/note.js", () => ({
|
||||||
note: mocks.note,
|
note: mocks.note,
|
||||||
}));
|
}));
|
||||||
@ -99,6 +127,501 @@ import { WizardCancelledError } from "../wizard/prompts.js";
|
|||||||
import { runConfigureWizard } from "./configure.wizard.js";
|
import { runConfigureWizard } from "./configure.wizard.js";
|
||||||
|
|
||||||
describe("runConfigureWizard", () => {
|
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 () => {
|
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
||||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||||
exists: false,
|
exists: false,
|
||||||
@ -158,4 +681,137 @@ describe("runConfigureWizard", () => {
|
|||||||
|
|
||||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
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 { resolveUserPath } from "../utils.js";
|
||||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||||
import { resolveOnboardingSecretInputString } from "../wizard/onboarding.secret-input.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 { removeChannelConfigWizard } from "./configure.channels.js";
|
||||||
import { maybeInstallDaemon } from "./configure.daemon.js";
|
import { maybeInstallDaemon } from "./configure.daemon.js";
|
||||||
import { promptAuthConfig } from "./configure.gateway-auth.js";
|
import { promptAuthConfig } from "./configure.gateway-auth.js";
|
||||||
@ -163,41 +163,40 @@ async function promptChannelMode(runtime: RuntimeEnv): Promise<ChannelsWizardMod
|
|||||||
async function promptWebToolsConfig(
|
async function promptWebToolsConfig(
|
||||||
nextConfig: OpenClawConfig,
|
nextConfig: OpenClawConfig,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
|
workspaceDir?: string,
|
||||||
): Promise<OpenClawConfig> {
|
): Promise<OpenClawConfig> {
|
||||||
const existingSearch = nextConfig.tools?.web?.search;
|
const existingSearch = nextConfig.tools?.web?.search;
|
||||||
const existingFetch = nextConfig.tools?.web?.fetch;
|
const existingFetch = nextConfig.tools?.web?.fetch;
|
||||||
const {
|
const prompter: WizardPrompter = {
|
||||||
SEARCH_PROVIDER_OPTIONS,
|
intro: async () => {},
|
||||||
resolveExistingKey,
|
outro: async () => {},
|
||||||
hasExistingKey,
|
note: async (message: string, title?: string) => {
|
||||||
applySearchKey,
|
note(message, title);
|
||||||
hasKeyInEnv,
|
},
|
||||||
} = await import("./onboard-search.js");
|
select: async <T>(params: Parameters<WizardPrompter["select"]>[0]) =>
|
||||||
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
|
guardCancel(await select<T>(params as never), runtime),
|
||||||
|
multiselect: async <T>() => [] as T[],
|
||||||
const hasKeyForProvider = (provider: string): boolean => {
|
text: async (params: Parameters<WizardPrompter["text"]>[0]) =>
|
||||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
|
guardCancel(
|
||||||
if (!entry) {
|
await text({
|
||||||
return false;
|
...params,
|
||||||
}
|
validate: params.validate
|
||||||
return hasExistingKey(nextConfig, provider as SP) || hasKeyInEnv(entry);
|
? (value: string | undefined) => params.validate?.(value ?? "")
|
||||||
|
: undefined,
|
||||||
|
} as never),
|
||||||
|
runtime,
|
||||||
|
),
|
||||||
|
confirm: async (params) => guardCancel(await confirm(params), runtime),
|
||||||
|
progress: () => ({ update: () => {}, stop: () => {} }),
|
||||||
};
|
};
|
||||||
|
const { resolveSearchProviderPickerEntries, promptSearchProviderFlow } =
|
||||||
const existingProvider: string = (() => {
|
await import("./onboard-search.js");
|
||||||
const stored = existingSearch?.provider;
|
const providerEntries = await resolveSearchProviderPickerEntries(nextConfig, workspaceDir);
|
||||||
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
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"Web search lets your agent look things up online using the `web_search` tool.",
|
"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",
|
"Docs: https://docs.openclaw.ai/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"Web search",
|
||||||
@ -206,8 +205,7 @@ async function promptWebToolsConfig(
|
|||||||
const enableSearch = guardCancel(
|
const enableSearch = guardCancel(
|
||||||
await confirm({
|
await confirm({
|
||||||
message: "Enable web_search?",
|
message: "Enable web_search?",
|
||||||
initialValue:
|
initialValue: existingSearch?.enabled ?? providerEntries.some((entry) => entry.configured),
|
||||||
existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)),
|
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
);
|
);
|
||||||
@ -218,63 +216,28 @@ async function promptWebToolsConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (enableSearch) {
|
if (enableSearch) {
|
||||||
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
nextConfig = {
|
||||||
const configured = hasKeyForProvider(entry.value);
|
...nextConfig,
|
||||||
return {
|
tools: {
|
||||||
value: entry.value,
|
...nextConfig.tools,
|
||||||
label: entry.label,
|
web: {
|
||||||
hint: configured ? `${entry.hint} · configured` : entry.hint,
|
...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",
|
||||||
});
|
});
|
||||||
|
nextConfig = applied;
|
||||||
const providerChoice = guardCancel(
|
nextSearch = { ...applied.tools?.web?.search };
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enableFetch = guardCancel(
|
const enableFetch = guardCancel(
|
||||||
@ -527,7 +490,7 @@ export async function runConfigureWizard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selected.includes("web")) {
|
if (selected.includes("web")) {
|
||||||
nextConfig = await promptWebToolsConfig(nextConfig, runtime);
|
nextConfig = await promptWebToolsConfig(nextConfig, runtime, workspaceDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.includes("gateway")) {
|
if (selected.includes("gateway")) {
|
||||||
@ -580,7 +543,7 @@ export async function runConfigureWizard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (choice === "web") {
|
if (choice === "web") {
|
||||||
nextConfig = await promptWebToolsConfig(nextConfig, runtime);
|
nextConfig = await promptWebToolsConfig(nextConfig, runtime, workspaceDir);
|
||||||
await persistConfig();
|
await persistConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||||
|
import { validateConfigObjectWithPlugins } from "../config/config.js";
|
||||||
import * as noteModule from "../terminal/note.js";
|
import * as noteModule from "../terminal/note.js";
|
||||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||||
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.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,
|
run: loadAndMaybeMigrateDoctorConfig,
|
||||||
});
|
});
|
||||||
return noteSpy.mock.calls
|
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]));
|
.map((call) => String(call[0]));
|
||||||
} finally {
|
} finally {
|
||||||
noteSpy.mockRestore();
|
noteSpy.mockRestore();
|
||||||
@ -126,6 +127,56 @@ describe("doctor config flow", () => {
|
|||||||
).toBe(true);
|
).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 () => {
|
it("does not warn on mutable Zalouser group entries when dangerous name matching is enabled", async () => {
|
||||||
const doctorWarnings = await collectDoctorWarnings({
|
const doctorWarnings = await collectDoctorWarnings({
|
||||||
channels: {
|
channels: {
|
||||||
@ -140,7 +191,6 @@ describe("doctor config flow", () => {
|
|||||||
|
|
||||||
expect(doctorWarnings.some((line) => line.includes("channels.zalouser.groups"))).toBe(false);
|
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 () => {
|
it("warns when imessage group allowlist is empty even if allowFrom is set", async () => {
|
||||||
const doctorWarnings = await collectDoctorWarnings({
|
const doctorWarnings = await collectDoctorWarnings({
|
||||||
channels: {
|
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 () => {
|
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
|
||||||
const result = await runDoctorConfigWithInput({
|
const result = await runDoctorConfigWithInput({
|
||||||
repair: true,
|
repair: true,
|
||||||
|
|||||||
@ -16,7 +16,12 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gatewa
|
|||||||
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||||
import { listRouteBindings } from "../config/bindings.js";
|
import { listRouteBindings } from "../config/bindings.js";
|
||||||
import type { OpenClawConfig } from "../config/config.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 { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.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>;
|
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 {
|
function normalizeBindingChannelKey(raw?: string | null): string {
|
||||||
const normalized = normalizeChatChannelId(raw);
|
const normalized = normalizeChatChannelId(raw);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
@ -1824,6 +2004,22 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
|||||||
if (safeBinProfileRepair.warnings.length > 0) {
|
if (safeBinProfileRepair.warnings.length > 0) {
|
||||||
note(safeBinProfileRepair.warnings.join("\n"), "Doctor warnings");
|
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 {
|
} else {
|
||||||
const hits = scanTelegramAllowFromUsernameEntries(candidate);
|
const hits = scanTelegramAllowFromUsernameEntries(candidate);
|
||||||
if (hits.length > 0) {
|
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", () => ({
|
vi.mock("../../plugins/install.js", () => ({
|
||||||
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
|
installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args),
|
||||||
}));
|
}));
|
||||||
|
const loadPluginManifest = vi.fn();
|
||||||
|
vi.mock("../../plugins/manifest.js", () => ({
|
||||||
|
loadPluginManifest: (...args: unknown[]) => loadPluginManifest(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
const resolveBundledPluginSources = vi.fn();
|
const resolveBundledPluginSources = vi.fn();
|
||||||
vi.mock("../../plugins/bundled-sources.js", () => ({
|
vi.mock("../../plugins/bundled-sources.js", () => ({
|
||||||
@ -61,6 +65,7 @@ import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
|||||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||||
import { makePrompter, makeRuntime } from "./__tests__/test-utils.js";
|
import { makePrompter, makeRuntime } from "./__tests__/test-utils.js";
|
||||||
import {
|
import {
|
||||||
|
ensureGenericOnboardingPluginInstalled,
|
||||||
ensureOnboardingPluginInstalled,
|
ensureOnboardingPluginInstalled,
|
||||||
reloadOnboardingPluginRegistry,
|
reloadOnboardingPluginRegistry,
|
||||||
} from "./plugin-install.js";
|
} from "./plugin-install.js";
|
||||||
@ -84,6 +89,7 @@ const baseEntry: ChannelPluginCatalogEntry = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
resolveBundledPluginSources.mockReturnValue(new Map());
|
resolveBundledPluginSources.mockReturnValue(new Map());
|
||||||
|
loadPluginManifest.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockRepoLocalPathExists() {
|
function mockRepoLocalPathExists() {
|
||||||
@ -93,12 +99,18 @@ function mockRepoLocalPathExists() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runInitialValueForChannel(channel: "dev" | "beta") {
|
async function runPromptShapeForChannel(channel: "dev" | "beta") {
|
||||||
const runtime = makeRuntime();
|
const runtime = makeRuntime();
|
||||||
const select = vi.fn((async <T extends string>() => "skip" as T) as WizardPrompter["select"]);
|
const text = vi.fn(async () => "");
|
||||||
const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] });
|
const prompter = makePrompter({
|
||||||
|
text: text as unknown as WizardPrompter["text"],
|
||||||
|
});
|
||||||
const cfg: OpenClawConfig = { update: { channel } };
|
const cfg: OpenClawConfig = { update: { channel } };
|
||||||
mockRepoLocalPathExists();
|
mockRepoLocalPathExists();
|
||||||
|
installPluginFromNpmSpec.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
error: "nope",
|
||||||
|
});
|
||||||
|
|
||||||
await ensureOnboardingPluginInstalled({
|
await ensureOnboardingPluginInstalled({
|
||||||
cfg,
|
cfg,
|
||||||
@ -107,8 +119,8 @@ async function runInitialValueForChannel(channel: "dev" | "beta") {
|
|||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const call = select.mock.calls[0];
|
const call = text.mock.calls[0];
|
||||||
return call?.[0]?.initialValue;
|
return call?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectPluginLoadedFromLocalPath(
|
function expectPluginLoadedFromLocalPath(
|
||||||
@ -123,7 +135,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
it("installs from npm and enables the plugin", async () => {
|
it("installs from npm and enables the plugin", async () => {
|
||||||
const runtime = makeRuntime();
|
const runtime = makeRuntime();
|
||||||
const prompter = makePrompter({
|
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"] } };
|
const cfg: OpenClawConfig = { plugins: { allow: ["other"] } };
|
||||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
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 runtime = makeRuntime();
|
||||||
const prompter = makePrompter({
|
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 = {};
|
const cfg: OpenClawConfig = {};
|
||||||
mockRepoLocalPathExists();
|
mockRepoLocalPathExists();
|
||||||
@ -169,22 +210,41 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
|
|
||||||
expectPluginLoadedFromLocalPath(result);
|
expectPluginLoadedFromLocalPath(result);
|
||||||
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
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 () => {
|
it("uses a generic placeholder without prefilled local value on dev channel", async () => {
|
||||||
expect(await runInitialValueForChannel("dev")).toBe("local");
|
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 () => {
|
it("uses the same generic placeholder without prefilled npm value on beta channel", async () => {
|
||||||
expect(await runInitialValueForChannel("beta")).toBe("npm");
|
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 () => {
|
it("defaults to bundled local path on beta channel when available", async () => {
|
||||||
const runtime = makeRuntime();
|
const runtime = makeRuntime();
|
||||||
const select = vi.fn((async <T extends string>() => "skip" as T) as WizardPrompter["select"]);
|
const text = vi.fn(async () => "");
|
||||||
const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] });
|
const prompter = makePrompter({
|
||||||
|
text: text as unknown as WizardPrompter["text"],
|
||||||
|
});
|
||||||
const cfg: OpenClawConfig = { update: { channel: "beta" } };
|
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(
|
resolveBundledPluginSources.mockReturnValue(
|
||||||
new Map([
|
new Map([
|
||||||
[
|
[
|
||||||
@ -205,15 +265,10 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(select).toHaveBeenCalledWith(
|
expect(text).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
initialValue: "local",
|
message: "npm package or local path",
|
||||||
options: expect.arrayContaining([
|
placeholder: "@scope/plugin-name or extensions/plugin-name (leave blank to skip)",
|
||||||
expect.objectContaining({
|
|
||||||
value: "local",
|
|
||||||
hint: "/opt/openclaw/extensions/zalo",
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -223,7 +278,7 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
const note = vi.fn(async () => {});
|
const note = vi.fn(async () => {});
|
||||||
const confirm = vi.fn(async () => true);
|
const confirm = vi.fn(async () => true);
|
||||||
const prompter = makePrompter({
|
const prompter = makePrompter({
|
||||||
select: vi.fn(async () => "npm") as WizardPrompter["select"],
|
text: vi.fn(async () => "@openclaw/zalo") as WizardPrompter["text"],
|
||||||
note,
|
note,
|
||||||
confirm,
|
confirm,
|
||||||
});
|
});
|
||||||
@ -242,10 +297,153 @@ describe("ensureOnboardingPluginInstalled", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expectPluginLoadedFromLocalPath(result);
|
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();
|
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", () => {
|
it("clears discovery cache before reloading the onboarding plugin registry", () => {
|
||||||
const runtime = makeRuntime();
|
const runtime = makeRuntime();
|
||||||
const cfg: OpenClawConfig = {};
|
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 fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
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 { resolveBundledInstallPlanForCatalogEntry } from "../../cli/plugin-install-plan.js";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { createSubsystemLogger } from "../../logging/subsystem.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 { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js";
|
||||||
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||||
import { createPluginLoaderLogger } from "../../plugins/logger.js";
|
import { createPluginLoaderLogger } from "../../plugins/logger.js";
|
||||||
|
import { loadPluginManifest } from "../../plugins/manifest.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import type { WizardPrompter } from "../../wizard/prompts.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 = {
|
type InstallResult = {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
|
pluginId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasGitWorkspace(workspaceDir?: string): boolean {
|
function hasGitWorkspace(workspaceDir?: string): boolean {
|
||||||
@ -40,7 +51,7 @@ function hasGitWorkspace(workspaceDir?: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveLocalPath(
|
function resolveLocalPath(
|
||||||
entry: ChannelPluginCatalogEntry,
|
entry: InstallablePluginCatalogEntry,
|
||||||
workspaceDir: string | undefined,
|
workspaceDir: string | undefined,
|
||||||
allowLocal: boolean,
|
allowLocal: boolean,
|
||||||
): string | null {
|
): string | null {
|
||||||
@ -64,6 +75,31 @@ function resolveLocalPath(
|
|||||||
return null;
|
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 {
|
function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawConfig {
|
||||||
const existing = cfg.plugins?.load?.paths ?? [];
|
const existing = cfg.plugins?.load?.paths ?? [];
|
||||||
const merged = Array.from(new Set([...existing, pluginPath]));
|
const merged = Array.from(new Set([...existing, pluginPath]));
|
||||||
@ -80,65 +116,100 @@ function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawCon
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function promptInstallChoice(params: {
|
async function promptInstallChoice(params: {
|
||||||
entry: ChannelPluginCatalogEntry;
|
|
||||||
localPath?: string | null;
|
|
||||||
defaultChoice: InstallChoice;
|
|
||||||
prompter: WizardPrompter;
|
prompter: WizardPrompter;
|
||||||
}): Promise<InstallChoice> {
|
workspaceDir?: string;
|
||||||
const { entry, localPath, prompter, defaultChoice } = params;
|
allowLocal: boolean;
|
||||||
const localOptions: Array<{ value: InstallChoice; label: string; hint?: string }> = localPath
|
expectedNpmSpec?: string;
|
||||||
? [
|
}): Promise<string | null> {
|
||||||
{
|
const { prompter, workspaceDir, allowLocal, expectedNpmSpec } = params;
|
||||||
value: "local",
|
const message = allowLocal ? "npm package or local path" : "npm package";
|
||||||
label: "Use local plugin path",
|
const placeholder = allowLocal
|
||||||
hint: localPath,
|
? "@scope/plugin-name or extensions/plugin-name (leave blank to skip)"
|
||||||
},
|
: "@scope/plugin-name (leave blank to skip)";
|
||||||
]
|
|
||||||
: [];
|
while (true) {
|
||||||
const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [
|
const source = (
|
||||||
{ value: "npm", label: `Download from npm (${entry.install.npmSpec})` },
|
await prompter.text({
|
||||||
...localOptions,
|
message,
|
||||||
{ value: "skip", label: "Skip for now" },
|
placeholder,
|
||||||
];
|
})
|
||||||
const initialValue: InstallChoice =
|
).trim();
|
||||||
defaultChoice === "local" && !localPath ? "npm" : defaultChoice;
|
|
||||||
return await prompter.select<InstallChoice>({
|
if (!source) {
|
||||||
message: `Install ${entry.meta.label} plugin?`,
|
return null;
|
||||||
options,
|
}
|
||||||
initialValue,
|
|
||||||
});
|
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: {
|
function isLikelyLocalPath(source: string): boolean {
|
||||||
cfg: OpenClawConfig;
|
const trimmed = source.trim();
|
||||||
entry: ChannelPluginCatalogEntry;
|
if (!trimmed) {
|
||||||
localPath?: string | null;
|
return false;
|
||||||
bundledLocalPath?: string | null;
|
|
||||||
}): InstallChoice {
|
|
||||||
const { cfg, entry, localPath, bundledLocalPath } = params;
|
|
||||||
if (bundledLocalPath) {
|
|
||||||
return "local";
|
|
||||||
}
|
}
|
||||||
const updateChannel = cfg.update?.channel;
|
if (trimmed.startsWith(".") || trimmed.startsWith("/") || trimmed.startsWith("~")) {
|
||||||
if (updateChannel === "dev") {
|
return true;
|
||||||
return localPath ? "local" : "npm";
|
|
||||||
}
|
}
|
||||||
if (updateChannel === "stable" || updateChannel === "beta") {
|
if (trimmed.includes("\\")) {
|
||||||
return "npm";
|
return true;
|
||||||
}
|
}
|
||||||
const entryDefault = entry.install.defaultChoice;
|
if (trimmed.startsWith("@")) {
|
||||||
if (entryDefault === "local") {
|
return false;
|
||||||
return localPath ? "local" : "npm";
|
|
||||||
}
|
}
|
||||||
if (entryDefault === "npm") {
|
return trimmed.includes("/");
|
||||||
return "npm";
|
}
|
||||||
|
|
||||||
|
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: {
|
export async function ensureOnboardingPluginInstalled(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
entry: ChannelPluginCatalogEntry;
|
entry: InstallablePluginCatalogEntry;
|
||||||
prompter: WizardPrompter;
|
prompter: WizardPrompter;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
@ -155,31 +226,29 @@ export async function ensureOnboardingPluginInstalled(params: {
|
|||||||
findBundledPluginSourceInMap({ bundled: bundledSources, lookup }),
|
findBundledPluginSourceInMap({ bundled: bundledSources, lookup }),
|
||||||
})?.bundledSource.localPath ?? null;
|
})?.bundledSource.localPath ?? null;
|
||||||
const localPath = bundledLocalPath ?? resolveLocalPath(entry, workspaceDir, allowLocal);
|
const localPath = bundledLocalPath ?? resolveLocalPath(entry, workspaceDir, allowLocal);
|
||||||
const defaultChoice = resolveInstallDefaultChoice({
|
const source = await promptInstallChoice({
|
||||||
cfg: next,
|
|
||||||
entry,
|
|
||||||
localPath,
|
|
||||||
bundledLocalPath,
|
|
||||||
});
|
|
||||||
const choice = await promptInstallChoice({
|
|
||||||
entry,
|
|
||||||
localPath,
|
|
||||||
defaultChoice,
|
|
||||||
prompter,
|
prompter,
|
||||||
|
workspaceDir,
|
||||||
|
allowLocal,
|
||||||
|
expectedNpmSpec: entry.install.npmSpec,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (choice === "skip") {
|
if (!source) {
|
||||||
return { cfg: next, installed: false };
|
return { cfg: next, installed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (choice === "local" && localPath) {
|
if (isLikelyLocalPath(source)) {
|
||||||
next = addPluginLoadPath(next, localPath);
|
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;
|
next = enablePluginInConfig(next, entry.id).config;
|
||||||
return { cfg: next, installed: true };
|
return { cfg: next, installed: true, pluginId: entry.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await installPluginFromNpmSpec({
|
const result = await installPluginFromNpmSpec({
|
||||||
spec: entry.install.npmSpec,
|
spec: source,
|
||||||
logger: {
|
logger: {
|
||||||
info: (msg) => runtime.log?.(msg),
|
info: (msg) => runtime.log?.(msg),
|
||||||
warn: (msg) => runtime.log?.(msg),
|
warn: (msg) => runtime.log?.(msg),
|
||||||
@ -191,18 +260,15 @@ export async function ensureOnboardingPluginInstalled(params: {
|
|||||||
next = recordPluginInstall(next, {
|
next = recordPluginInstall(next, {
|
||||||
pluginId: result.pluginId,
|
pluginId: result.pluginId,
|
||||||
source: "npm",
|
source: "npm",
|
||||||
spec: entry.install.npmSpec,
|
spec: source,
|
||||||
installPath: result.targetDir,
|
installPath: result.targetDir,
|
||||||
version: result.version,
|
version: result.version,
|
||||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||||
});
|
});
|
||||||
return { cfg: next, installed: true };
|
return { cfg: next, installed: true, pluginId: result.pluginId };
|
||||||
}
|
}
|
||||||
|
|
||||||
await prompter.note(
|
await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install");
|
||||||
`Failed to install ${entry.install.npmSpec}: ${result.error}`,
|
|
||||||
"Plugin install",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (localPath) {
|
if (localPath) {
|
||||||
const fallback = await prompter.confirm({
|
const fallback = await prompter.confirm({
|
||||||
@ -210,9 +276,13 @@ export async function ensureOnboardingPluginInstalled(params: {
|
|||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
|
await prompter.note(
|
||||||
|
[`Using existing local plugin at ${localPath}.`, "No download needed."].join("\n"),
|
||||||
|
"Plugin install",
|
||||||
|
);
|
||||||
next = addPluginLoadPath(next, localPath);
|
next = addPluginLoadPath(next, localPath);
|
||||||
next = enablePluginInConfig(next, entry.id).config;
|
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 };
|
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: {
|
export function reloadOnboardingPluginRegistry(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
|
suppressOpenAllowlistWarning?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
clearPluginDiscoveryCache();
|
clearPluginDiscoveryCache();
|
||||||
const workspaceDir =
|
const workspaceDir =
|
||||||
@ -234,5 +368,6 @@ export function reloadOnboardingPluginRegistry(params: {
|
|||||||
workspaceDir,
|
workspaceDir,
|
||||||
cache: false,
|
cache: false,
|
||||||
logger: createPluginLoaderLogger(log),
|
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;
|
id: string;
|
||||||
schema: Record<string, unknown>;
|
schema: Record<string, unknown>;
|
||||||
channels?: string[];
|
channels?: string[];
|
||||||
|
manifest?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
await mkdirSafe(params.dir);
|
await mkdirSafe(params.dir);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
@ -32,6 +33,7 @@ async function writePluginFixture(params: {
|
|||||||
const manifest: Record<string, unknown> = {
|
const manifest: Record<string, unknown> = {
|
||||||
id: params.id,
|
id: params.id,
|
||||||
configSchema: params.schema,
|
configSchema: params.schema,
|
||||||
|
...params.manifest,
|
||||||
};
|
};
|
||||||
if (params.channels) {
|
if (params.channels) {
|
||||||
manifest.channels = params.channels;
|
manifest.channels = params.channels;
|
||||||
@ -60,6 +62,10 @@ describe("config plugin validation", () => {
|
|||||||
CLAWDBOT_STATE_DIR: undefined,
|
CLAWDBOT_STATE_DIR: undefined,
|
||||||
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "10000",
|
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: "10000",
|
||||||
}) satisfies NodeJS.ProcessEnv;
|
}) satisfies NodeJS.ProcessEnv;
|
||||||
|
let capabilityProviderDir = "";
|
||||||
|
let capabilityConsumerDir = "";
|
||||||
|
let capabilityConflictADir = "";
|
||||||
|
let capabilityConflictBDir = "";
|
||||||
|
|
||||||
const validateInSuite = (raw: unknown) =>
|
const validateInSuite = (raw: unknown) =>
|
||||||
validateConfigObjectWithPlugins(raw, { env: suiteEnv() });
|
validateConfigObjectWithPlugins(raw, { env: suiteEnv() });
|
||||||
@ -104,6 +110,43 @@ describe("config plugin validation", () => {
|
|||||||
channels: ["bluebubbles"],
|
channels: ["bluebubbles"],
|
||||||
schema: { type: "object" },
|
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");
|
voiceCallSchemaPluginDir = path.join(suiteHome, "voice-call-schema-plugin");
|
||||||
const voiceCallManifestPath = path.join(
|
const voiceCallManifestPath = path.join(
|
||||||
process.cwd(),
|
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 () => {
|
it("surfaces allowed enum values for plugin config diagnostics", async () => {
|
||||||
const res = validateInSuite({
|
const res = validateInSuite({
|
||||||
agents: { list: [{ id: "pi" }] },
|
agents: { list: [{ id: "pi" }] },
|
||||||
|
|||||||
@ -1,15 +1,133 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
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";
|
import { buildWebSearchProviderConfig } from "./test-helpers.js";
|
||||||
|
|
||||||
|
const loadOpenClawPlugins = vi.hoisted(() => vi.fn(() => ({ searchProviders: [] as unknown[] })));
|
||||||
|
|
||||||
vi.mock("../runtime.js", () => ({
|
vi.mock("../runtime.js", () => ({
|
||||||
defaultRuntime: { log: vi.fn(), error: vi.fn() },
|
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 { __testing } = await import("../agents/tools/web-search.js");
|
||||||
const { resolveSearchProvider } = __testing;
|
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", () => {
|
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", () => {
|
it("accepts perplexity provider and config", () => {
|
||||||
const res = validateConfigObject(
|
const res = validateConfigObject(
|
||||||
buildWebSearchProviderConfig({
|
buildWebSearchProviderConfig({
|
||||||
@ -82,6 +200,8 @@ describe("web search provider auto-detection", () => {
|
|||||||
const savedEnv = { ...process.env };
|
const savedEnv = { ...process.env };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
loadOpenClawPlugins.mockReset();
|
||||||
|
loadOpenClawPlugins.mockReturnValue({ searchProviders: bundledSearchProviders });
|
||||||
delete process.env.BRAVE_API_KEY;
|
delete process.env.BRAVE_API_KEY;
|
||||||
delete process.env.GEMINI_API_KEY;
|
delete process.env.GEMINI_API_KEY;
|
||||||
delete process.env.KIMI_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", () => {
|
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", () => {
|
it("auto-detects brave when only BRAVE_API_KEY is set", () => {
|
||||||
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
|
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", () => {
|
it("auto-detects gemini when only GEMINI_API_KEY is set", () => {
|
||||||
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
|
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", () => {
|
it("auto-detects kimi when only KIMI_API_KEY is set", () => {
|
||||||
process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret
|
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", () => {
|
it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => {
|
||||||
process.env.PERPLEXITY_API_KEY = "test-perplexity-key"; // pragma: allowlist secret
|
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", () => {
|
it("auto-detects perplexity when only OPENROUTER_API_KEY is set", () => {
|
||||||
process.env.OPENROUTER_API_KEY = "sk-or-v1-test"; // pragma: allowlist secret
|
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", () => {
|
it("auto-detects grok when only XAI_API_KEY is set", () => {
|
||||||
process.env.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
|
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", () => {
|
it("auto-detects kimi when only KIMI_API_KEY is set", () => {
|
||||||
process.env.KIMI_API_KEY = "test-kimi-key"; // pragma: allowlist secret
|
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", () => {
|
it("auto-detects kimi when only MOONSHOT_API_KEY is set", () => {
|
||||||
process.env.MOONSHOT_API_KEY = "test-moonshot-key"; // pragma: allowlist secret
|
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", () => {
|
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.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
|
||||||
process.env.PERPLEXITY_API_KEY = "test-perplexity-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
|
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", () => {
|
it("gemini wins over grok, kimi, and perplexity when brave unavailable", () => {
|
||||||
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
|
process.env.GEMINI_API_KEY = "test-gemini-key"; // pragma: allowlist secret
|
||||||
process.env.PERPLEXITY_API_KEY = "test-perplexity-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
|
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", () => {
|
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.XAI_API_KEY = "test-xai-key"; // pragma: allowlist secret
|
||||||
process.env.KIMI_API_KEY = "test-kimi-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
|
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", () => {
|
it("explicit provider always wins regardless of keys", () => {
|
||||||
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
|
process.env.BRAVE_API_KEY = "test-brave-key"; // pragma: allowlist secret
|
||||||
expect(
|
expect(resolveSearchProviderId({ provider: "gemini" })).toBe("gemini");
|
||||||
resolveSearchProvider({ provider: "gemini" } as unknown as Parameters<
|
|
||||||
typeof resolveSearchProvider
|
|
||||||
>[0]),
|
|
||||||
).toBe("gemini");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -457,8 +457,8 @@ export type ToolsConfig = {
|
|||||||
search?: {
|
search?: {
|
||||||
/** Enable web search tool (default: true when API key is present). */
|
/** Enable web search tool (default: true when API key is present). */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */
|
/** Search provider id registered through the plugin system. */
|
||||||
provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity";
|
provider?: string & {};
|
||||||
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
|
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
|
||||||
apiKey?: SecretInput;
|
apiKey?: SecretInput;
|
||||||
/** Default search results count (1-10). */
|
/** Default search results count (1-10). */
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
|
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
|
||||||
|
import {
|
||||||
|
resolveCapabilitySlotConfigPath,
|
||||||
|
resolveCapabilitySlotSelection,
|
||||||
|
type CapabilitySlotId,
|
||||||
|
} from "../plugins/capability-slots.js";
|
||||||
import {
|
import {
|
||||||
normalizePluginsConfig,
|
normalizePluginsConfig,
|
||||||
resolveEffectiveEnableState,
|
resolveEffectiveEnableState,
|
||||||
resolveMemorySlotDecision,
|
resolveMemorySlotDecision,
|
||||||
} from "../plugins/config-state.js";
|
} from "../plugins/config-state.js";
|
||||||
|
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||||
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
||||||
import {
|
import {
|
||||||
@ -33,6 +39,24 @@ type AllowedValuesCollection = {
|
|||||||
hasValues: boolean;
|
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 {
|
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return null;
|
return null;
|
||||||
@ -355,10 +379,36 @@ function validateConfigObjectWithPluginsBase(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const diag of registry.diagnostics) {
|
for (const diag of registry.diagnostics) {
|
||||||
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
|
const path = resolvePluginDiagnosticPath(diag);
|
||||||
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
|
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
|
||||||
path = "plugins.load.paths";
|
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 pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
|
||||||
const message = `${pluginLabel}: ${diag.message}`;
|
const message = `${pluginLabel}: ${diag.message}`;
|
||||||
if (diag.level === "error") {
|
if (diag.level === "error") {
|
||||||
@ -388,8 +438,54 @@ function validateConfigObjectWithPluginsBase(
|
|||||||
return info.normalizedPlugins;
|
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]);
|
const allowedChannels = new Set<string>(["defaults", "modelByChannel", ...CHANNEL_IDS]);
|
||||||
|
|
||||||
|
validateWebSearchProvider();
|
||||||
|
|
||||||
if (config.channels && isRecord(config.channels)) {
|
if (config.channels && isRecord(config.channels)) {
|
||||||
for (const key of Object.keys(config.channels)) {
|
for (const key of Object.keys(config.channels)) {
|
||||||
const trimmed = key.trim();
|
const trimmed = key.trim();
|
||||||
@ -549,6 +645,7 @@ function validateConfigObjectWithPluginsBase(
|
|||||||
origin: record.origin,
|
origin: record.origin,
|
||||||
config: normalizedPlugins,
|
config: normalizedPlugins,
|
||||||
rootConfig: config,
|
rootConfig: config,
|
||||||
|
defaultEnabledWhenBundled: record.defaultEnabledWhenBundled,
|
||||||
});
|
});
|
||||||
let enabled = enableState.enabled;
|
let enabled = enableState.enabled;
|
||||||
let reason = enableState.reason;
|
let reason = enableState.reason;
|
||||||
|
|||||||
@ -263,13 +263,8 @@ export const ToolsWebSearchSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
provider: z
|
provider: z
|
||||||
.union([
|
.string()
|
||||||
z.literal("brave"),
|
.regex(/^[a-z][a-z0-9_-]*$/, "provider id")
|
||||||
z.literal("perplexity"),
|
|
||||||
z.literal("grok"),
|
|
||||||
z.literal("gemini"),
|
|
||||||
z.literal("kimi"),
|
|
||||||
])
|
|
||||||
.optional(),
|
.optional(),
|
||||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||||
maxResults: z.number().int().positive().optional(),
|
maxResults: z.number().int().positive().optional(),
|
||||||
|
|||||||
@ -28,6 +28,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
|||||||
channels: [],
|
channels: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
searchProviders: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const registryState: { registry: PluginRegistry } = {
|
|||||||
typedHooks: [],
|
typedHooks: [],
|
||||||
channels: [],
|
channels: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
searchProviders: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpHandlers: [],
|
httpHandlers: [],
|
||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
|
|||||||
@ -145,6 +145,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
searchProviders: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
|
|||||||
@ -107,6 +107,12 @@ describe("plugin-sdk exports", () => {
|
|||||||
"probeTelegram",
|
"probeTelegram",
|
||||||
"probeIMessage",
|
"probeIMessage",
|
||||||
"probeSignal",
|
"probeSignal",
|
||||||
|
"createBundledSearchProviderAdapter",
|
||||||
|
"createBundledBraveSearchProvider",
|
||||||
|
"createBundledGeminiSearchProvider",
|
||||||
|
"createBundledGrokSearchProvider",
|
||||||
|
"createBundledKimiSearchProvider",
|
||||||
|
"createBundledPerplexitySearchProvider",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of forbidden) {
|
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 imessageSdk from "openclaw/plugin-sdk/imessage";
|
||||||
import * as lineSdk from "openclaw/plugin-sdk/line";
|
import * as lineSdk from "openclaw/plugin-sdk/line";
|
||||||
import * as msteamsSdk from "openclaw/plugin-sdk/msteams";
|
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 signalSdk from "openclaw/plugin-sdk/signal";
|
||||||
import * as slackSdk from "openclaw/plugin-sdk/slack";
|
import * as slackSdk from "openclaw/plugin-sdk/slack";
|
||||||
import * as telegramSdk from "openclaw/plugin-sdk/telegram";
|
import * as telegramSdk from "openclaw/plugin-sdk/telegram";
|
||||||
@ -99,6 +100,28 @@ describe("plugin-sdk subpath exports", () => {
|
|||||||
expect(typeof msteamsSdk.loadOutboundMediaFromUrl).toBe("function");
|
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 () => {
|
it("exports acpx helpers", async () => {
|
||||||
const acpxSdk = await import("openclaw/plugin-sdk/acpx");
|
const acpxSdk = await import("openclaw/plugin-sdk/acpx");
|
||||||
expect(typeof acpxSdk.listKnownProviderAuthEnvVarNames).toBe("function");
|
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" });
|
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", () => {
|
describe("resolveEnableState", () => {
|
||||||
|
|||||||
@ -193,6 +193,7 @@ export function resolveEnableState(
|
|||||||
id: string,
|
id: string,
|
||||||
origin: PluginRecord["origin"],
|
origin: PluginRecord["origin"],
|
||||||
config: NormalizedPluginsConfig,
|
config: NormalizedPluginsConfig,
|
||||||
|
defaultEnabledWhenBundled = false,
|
||||||
): { enabled: boolean; reason?: string } {
|
): { enabled: boolean; reason?: string } {
|
||||||
if (!config.enabled) {
|
if (!config.enabled) {
|
||||||
return { enabled: false, reason: "plugins disabled" };
|
return { enabled: false, reason: "plugins disabled" };
|
||||||
@ -217,7 +218,7 @@ export function resolveEnableState(
|
|||||||
if (entry?.enabled === true) {
|
if (entry?.enabled === true) {
|
||||||
return { 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 };
|
return { enabled: true };
|
||||||
}
|
}
|
||||||
if (origin === "bundled") {
|
if (origin === "bundled") {
|
||||||
@ -250,8 +251,14 @@ export function resolveEffectiveEnableState(params: {
|
|||||||
origin: PluginRecord["origin"];
|
origin: PluginRecord["origin"];
|
||||||
config: NormalizedPluginsConfig;
|
config: NormalizedPluginsConfig;
|
||||||
rootConfig?: OpenClawConfig;
|
rootConfig?: OpenClawConfig;
|
||||||
|
defaultEnabledWhenBundled?: boolean;
|
||||||
}): { enabled: boolean; reason?: string } {
|
}): { 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 (
|
if (
|
||||||
!base.enabled &&
|
!base.enabled &&
|
||||||
base.reason === "bundled (disabled by default)" &&
|
base.reason === "bundled (disabled by default)" &&
|
||||||
|
|||||||
@ -540,6 +540,7 @@ function discoverFromPath(params: {
|
|||||||
if (!fs.existsSync(resolved)) {
|
if (!fs.existsSync(resolved)) {
|
||||||
params.diagnostics.push({
|
params.diagnostics.push({
|
||||||
level: "error",
|
level: "error",
|
||||||
|
code: "plugin_path_not_found",
|
||||||
message: `plugin path not found: ${resolved}`,
|
message: `plugin path not found: ${resolved}`,
|
||||||
source: resolved,
|
source: resolved,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export function createMockPluginRegistry(
|
|||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
services: [],
|
services: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
searchProviders: [],
|
||||||
commands: [],
|
commands: [],
|
||||||
} as unknown as PluginRegistry;
|
} as unknown as PluginRegistry;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,16 @@
|
|||||||
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
|
import { concatOptionalTextSegments } from "../shared/text/join-segments.js";
|
||||||
import type { PluginRegistry } from "./registry.js";
|
import type { PluginRegistry } from "./registry.js";
|
||||||
import type {
|
import type {
|
||||||
|
PluginHookAfterProviderActivateEvent,
|
||||||
|
PluginHookAfterProviderConfigureEvent,
|
||||||
PluginHookAfterCompactionEvent,
|
PluginHookAfterCompactionEvent,
|
||||||
PluginHookAfterToolCallEvent,
|
PluginHookAfterToolCallEvent,
|
||||||
PluginHookAgentContext,
|
PluginHookAgentContext,
|
||||||
PluginHookAgentEndEvent,
|
PluginHookAgentEndEvent,
|
||||||
PluginHookBeforeAgentStartEvent,
|
PluginHookBeforeAgentStartEvent,
|
||||||
PluginHookBeforeAgentStartResult,
|
PluginHookBeforeAgentStartResult,
|
||||||
|
PluginHookBeforeProviderConfigureEvent,
|
||||||
|
PluginHookBeforeProviderConfigureResult,
|
||||||
PluginHookBeforeModelResolveEvent,
|
PluginHookBeforeModelResolveEvent,
|
||||||
PluginHookBeforeModelResolveResult,
|
PluginHookBeforeModelResolveResult,
|
||||||
PluginHookBeforePromptBuildEvent,
|
PluginHookBeforePromptBuildEvent,
|
||||||
@ -37,6 +41,7 @@ import type {
|
|||||||
PluginHookSessionContext,
|
PluginHookSessionContext,
|
||||||
PluginHookSessionEndEvent,
|
PluginHookSessionEndEvent,
|
||||||
PluginHookSessionStartEvent,
|
PluginHookSessionStartEvent,
|
||||||
|
PluginHookSearchProviderContext,
|
||||||
PluginHookSubagentContext,
|
PluginHookSubagentContext,
|
||||||
PluginHookSubagentDeliveryTargetEvent,
|
PluginHookSubagentDeliveryTargetEvent,
|
||||||
PluginHookSubagentDeliveryTargetResult,
|
PluginHookSubagentDeliveryTargetResult,
|
||||||
@ -57,6 +62,8 @@ export type {
|
|||||||
PluginHookAgentContext,
|
PluginHookAgentContext,
|
||||||
PluginHookBeforeAgentStartEvent,
|
PluginHookBeforeAgentStartEvent,
|
||||||
PluginHookBeforeAgentStartResult,
|
PluginHookBeforeAgentStartResult,
|
||||||
|
PluginHookBeforeProviderConfigureEvent,
|
||||||
|
PluginHookBeforeProviderConfigureResult,
|
||||||
PluginHookBeforeModelResolveEvent,
|
PluginHookBeforeModelResolveEvent,
|
||||||
PluginHookBeforeModelResolveResult,
|
PluginHookBeforeModelResolveResult,
|
||||||
PluginHookBeforePromptBuildEvent,
|
PluginHookBeforePromptBuildEvent,
|
||||||
@ -84,6 +91,7 @@ export type {
|
|||||||
PluginHookSessionContext,
|
PluginHookSessionContext,
|
||||||
PluginHookSessionStartEvent,
|
PluginHookSessionStartEvent,
|
||||||
PluginHookSessionEndEvent,
|
PluginHookSessionEndEvent,
|
||||||
|
PluginHookSearchProviderContext,
|
||||||
PluginHookSubagentContext,
|
PluginHookSubagentContext,
|
||||||
PluginHookSubagentDeliveryTargetEvent,
|
PluginHookSubagentDeliveryTargetEvent,
|
||||||
PluginHookSubagentDeliveryTargetResult,
|
PluginHookSubagentDeliveryTargetResult,
|
||||||
@ -94,6 +102,8 @@ export type {
|
|||||||
PluginHookGatewayContext,
|
PluginHookGatewayContext,
|
||||||
PluginHookGatewayStartEvent,
|
PluginHookGatewayStartEvent,
|
||||||
PluginHookGatewayStopEvent,
|
PluginHookGatewayStopEvent,
|
||||||
|
PluginHookAfterProviderConfigureEvent,
|
||||||
|
PluginHookAfterProviderActivateEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HookRunnerLogger = {
|
export type HookRunnerLogger = {
|
||||||
@ -181,6 +191,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeBeforeProviderConfigure = (
|
||||||
|
acc: PluginHookBeforeProviderConfigureResult | undefined,
|
||||||
|
next: PluginHookBeforeProviderConfigureResult,
|
||||||
|
): PluginHookBeforeProviderConfigureResult => ({
|
||||||
|
note: concatOptionalTextSegments({
|
||||||
|
left: acc?.note,
|
||||||
|
right: next.note,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const handleHookError = (params: {
|
const handleHookError = (params: {
|
||||||
hookName: PluginHookName;
|
hookName: PluginHookName;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@ -206,6 +226,15 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||||||
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
ctx: Parameters<NonNullable<PluginHookRegistration<K>["handler"]>>[1],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const hooks = getHooksForName(registry, hookName);
|
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) {
|
if (hooks.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -234,6 +263,16 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||||||
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
|
mergeResults?: (accumulated: TResult | undefined, next: TResult) => TResult,
|
||||||
): Promise<TResult | undefined> {
|
): Promise<TResult | undefined> {
|
||||||
const hooks = getHooksForName(registry, hookName);
|
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) {
|
if (hooks.length === 0) {
|
||||||
return undefined;
|
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.
|
* Run agent_end hook.
|
||||||
* Allows plugins to analyze completed conversations.
|
* 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;
|
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 {
|
return {
|
||||||
// Agent hooks
|
// Agent hooks
|
||||||
runBeforeModelResolve,
|
runBeforeModelResolve,
|
||||||
runBeforePromptBuild,
|
runBeforePromptBuild,
|
||||||
runBeforeAgentStart,
|
runBeforeAgentStart,
|
||||||
|
runBeforeProviderConfigure,
|
||||||
|
runAfterProviderConfigure,
|
||||||
|
runAfterProviderActivate,
|
||||||
runLlmInput,
|
runLlmInput,
|
||||||
runLlmOutput,
|
runLlmOutput,
|
||||||
runAgentEnd,
|
runAgentEnd,
|
||||||
@ -755,6 +833,8 @@ export function createHookRunner(registry: PluginRegistry, options: HookRunnerOp
|
|||||||
runGatewayStop,
|
runGatewayStop,
|
||||||
// Utility
|
// Utility
|
||||||
hasHooks,
|
hasHooks,
|
||||||
|
hasProviderConfigureHooks,
|
||||||
|
hasProviderActivationHooks,
|
||||||
getHookCount,
|
getHookCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,7 @@ function writePlugin(params: {
|
|||||||
body: string;
|
body: string;
|
||||||
dir?: string;
|
dir?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
|
manifest?: Record<string, unknown>;
|
||||||
}): TempPlugin {
|
}): TempPlugin {
|
||||||
const dir = params.dir ?? makeTempDir();
|
const dir = params.dir ?? makeTempDir();
|
||||||
const filename = params.filename ?? `${params.id}.cjs`;
|
const filename = params.filename ?? `${params.id}.cjs`;
|
||||||
@ -106,7 +107,7 @@ function writePlugin(params: {
|
|||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(dir, "openclaw.plugin.json"),
|
path.join(dir, "openclaw.plugin.json"),
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
params.manifest ?? {
|
||||||
id: params.id,
|
id: params.id,
|
||||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||||
},
|
},
|
||||||
@ -1577,11 +1578,25 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
const options = {
|
const options = {
|
||||||
cache: false,
|
cache: false,
|
||||||
logger: createWarningLogger(warnings),
|
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: {
|
config: {
|
||||||
plugins: {
|
plugins: {
|
||||||
load: { paths: [plugin.file] },
|
load: { paths: [plugin.file] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
};
|
};
|
||||||
|
|
||||||
loadOpenClawPlugins(options);
|
loadOpenClawPlugins(options);
|
||||||
@ -1590,6 +1605,28 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
expect(warnings.filter((msg) => msg.includes("plugins.allow is empty"))).toHaveLength(1);
|
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", () => {
|
it("does not auto-load workspace-discovered plugins unless explicitly trusted", () => {
|
||||||
useNoBundledPlugins();
|
useNoBundledPlugins();
|
||||||
const workspaceDir = makeTempDir();
|
const workspaceDir = makeTempDir();
|
||||||
@ -2107,4 +2144,196 @@ describe("loadOpenClawPlugins", () => {
|
|||||||
);
|
);
|
||||||
expect(resolved).toBe(srcFile);
|
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;
|
runtimeOptions?: CreatePluginRuntimeOptions;
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
mode?: "full" | "validate";
|
mode?: "full" | "validate";
|
||||||
|
suppressOpenAllowlistWarning?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
|
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
|
||||||
@ -305,6 +306,11 @@ function createPluginRecord(params: {
|
|||||||
hookNames: [],
|
hookNames: [],
|
||||||
channelIds: [],
|
channelIds: [],
|
||||||
providerIds: [],
|
providerIds: [],
|
||||||
|
searchProviderIds: [],
|
||||||
|
capabilityIds: [],
|
||||||
|
declaredCapabilities: [],
|
||||||
|
requiredCapabilities: [],
|
||||||
|
conflictingCapabilities: [],
|
||||||
gatewayMethods: [],
|
gatewayMethods: [],
|
||||||
cliCommands: [],
|
cliCommands: [],
|
||||||
services: [],
|
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: {
|
function recordPluginError(params: {
|
||||||
logger: PluginLogger;
|
logger: PluginLogger;
|
||||||
registry: PluginRegistry;
|
registry: PluginRegistry;
|
||||||
@ -459,7 +601,11 @@ function warnWhenAllowlistIsOpen(params: {
|
|||||||
allow: string[];
|
allow: string[];
|
||||||
warningCacheKey: string;
|
warningCacheKey: string;
|
||||||
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
|
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
|
||||||
|
suppress?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
if (params.suppress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!params.pluginsEnabled) {
|
if (!params.pluginsEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -611,6 +757,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
source: plugin.source,
|
source: plugin.source,
|
||||||
origin: plugin.origin,
|
origin: plugin.origin,
|
||||||
})),
|
})),
|
||||||
|
suppress: options.suppressOpenAllowlistWarning === true,
|
||||||
});
|
});
|
||||||
const provenance = buildProvenanceIndex({
|
const provenance = buildProvenanceIndex({
|
||||||
config: cfg,
|
config: cfg,
|
||||||
@ -680,6 +827,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
origin: candidate.origin,
|
origin: candidate.origin,
|
||||||
config: normalized,
|
config: normalized,
|
||||||
rootConfig: cfg,
|
rootConfig: cfg,
|
||||||
|
defaultEnabledWhenBundled: manifestRecord.defaultEnabledWhenBundled,
|
||||||
});
|
});
|
||||||
const entry = normalized.entries[pluginId];
|
const entry = normalized.entries[pluginId];
|
||||||
const record = createPluginRecord({
|
const record = createPluginRecord({
|
||||||
@ -696,6 +844,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
record.kind = manifestRecord.kind;
|
record.kind = manifestRecord.kind;
|
||||||
record.configUiHints = manifestRecord.configUiHints;
|
record.configUiHints = manifestRecord.configUiHints;
|
||||||
record.configJsonSchema = manifestRecord.configSchema;
|
record.configJsonSchema = manifestRecord.configSchema;
|
||||||
|
record.defaultEnabledWhenBundled = manifestRecord.defaultEnabledWhenBundled;
|
||||||
|
record.declaredCapabilities = [...manifestRecord.provides];
|
||||||
|
record.requiredCapabilities = [...manifestRecord.requires];
|
||||||
|
record.conflictingCapabilities = [...manifestRecord.conflicts];
|
||||||
const pushPluginLoadError = (message: string) => {
|
const pushPluginLoadError = (message: string) => {
|
||||||
record.status = "error";
|
record.status = "error";
|
||||||
record.error = message;
|
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) {
|
if (!manifestRecord.configSchema) {
|
||||||
pushPluginLoadError("missing config schema");
|
pushPluginLoadError("missing config schema");
|
||||||
continue;
|
continue;
|
||||||
@ -864,6 +1031,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
message: "plugin register returned a promise; async registration is ignored",
|
message: "plugin register returned a promise; async registration is ignored",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
pushDiagnostics(registry.diagnostics, evaluateCapabilityDeclarationAlignment(record));
|
||||||
registry.plugins.push(record);
|
registry.plugins.push(record);
|
||||||
seenIds.set(pluginId, candidate.origin);
|
seenIds.set(pluginId, candidate.origin);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -884,10 +1052,20 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
if (typeof memorySlot === "string" && !memorySlotMatched) {
|
if (typeof memorySlot === "string" && !memorySlotMatched) {
|
||||||
registry.diagnostics.push({
|
registry.diagnostics.push({
|
||||||
level: "warn",
|
level: "warn",
|
||||||
|
code: "capability_slot_selection_missing",
|
||||||
|
slot: "memory.backend",
|
||||||
|
capability: memorySlot,
|
||||||
message: `memory slot plugin not found or not marked as memory: ${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({
|
warnAboutUntrackedLoadedPlugins({
|
||||||
registry,
|
registry,
|
||||||
provenance,
|
provenance,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
clearPluginManifestRegistryCache,
|
clearPluginManifestRegistryCache,
|
||||||
loadPluginManifestRegistry,
|
loadPluginManifestRegistry,
|
||||||
} from "./manifest-registry.js";
|
} from "./manifest-registry.js";
|
||||||
|
import type { OpenClawPackageManifest } from "./manifest.js";
|
||||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@ -36,12 +37,14 @@ function createPluginCandidate(params: {
|
|||||||
rootDir: string;
|
rootDir: string;
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
origin: "bundled" | "global" | "workspace" | "config";
|
origin: "bundled" | "global" | "workspace" | "config";
|
||||||
|
packageManifest?: OpenClawPackageManifest;
|
||||||
}): PluginCandidate {
|
}): PluginCandidate {
|
||||||
return {
|
return {
|
||||||
idHint: params.idHint,
|
idHint: params.idHint,
|
||||||
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
|
source: path.join(params.rootDir, params.sourceName ?? "index.ts"),
|
||||||
rootDir: params.rootDir,
|
rootDir: params.rootDir,
|
||||||
origin: params.origin,
|
origin: params.origin,
|
||||||
|
packageManifest: params.packageManifest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,6 +218,37 @@ describe("loadPluginManifestRegistry", () => {
|
|||||||
expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(0);
|
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)", () => {
|
it("prefers higher-precedence origins for the same physical directory (config > workspace > global > bundled)", () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
mkdirSafe(path.join(dir, "sub"));
|
mkdirSafe(path.join(dir, "sub"));
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
|
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
|
||||||
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.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 { safeRealpathSync } from "./path-safety.js";
|
||||||
import { resolvePluginCacheInputs } from "./roots.js";
|
import { resolvePluginCacheInputs } from "./roots.js";
|
||||||
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
|
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
|
||||||
@ -29,6 +29,9 @@ export type PluginManifestRecord = {
|
|||||||
channels: string[];
|
channels: string[];
|
||||||
providers: string[];
|
providers: string[];
|
||||||
skills: string[];
|
skills: string[];
|
||||||
|
provides: string[];
|
||||||
|
requires: string[];
|
||||||
|
conflicts: string[];
|
||||||
origin: PluginOrigin;
|
origin: PluginOrigin;
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
@ -37,6 +40,8 @@ export type PluginManifestRecord = {
|
|||||||
schemaCacheKey?: string;
|
schemaCacheKey?: string;
|
||||||
configSchema?: Record<string, unknown>;
|
configSchema?: Record<string, unknown>;
|
||||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||||
|
defaultEnabledWhenBundled?: boolean;
|
||||||
|
install?: PluginPackageInstall;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginManifestRegistry = {
|
export type PluginManifestRegistry = {
|
||||||
@ -124,6 +129,9 @@ function buildRecord(params: {
|
|||||||
channels: params.manifest.channels ?? [],
|
channels: params.manifest.channels ?? [],
|
||||||
providers: params.manifest.providers ?? [],
|
providers: params.manifest.providers ?? [],
|
||||||
skills: params.manifest.skills ?? [],
|
skills: params.manifest.skills ?? [],
|
||||||
|
provides: params.manifest.provides ?? [],
|
||||||
|
requires: params.manifest.requires ?? [],
|
||||||
|
conflicts: params.manifest.conflicts ?? [],
|
||||||
origin: params.candidate.origin,
|
origin: params.candidate.origin,
|
||||||
workspaceDir: params.candidate.workspaceDir,
|
workspaceDir: params.candidate.workspaceDir,
|
||||||
rootDir: params.candidate.rootDir,
|
rootDir: params.candidate.rootDir,
|
||||||
@ -132,6 +140,8 @@ function buildRecord(params: {
|
|||||||
schemaCacheKey: params.schemaCacheKey,
|
schemaCacheKey: params.schemaCacheKey,
|
||||||
configSchema: params.configSchema,
|
configSchema: params.configSchema,
|
||||||
configUiHints: params.manifest.uiHints,
|
configUiHints: params.manifest.uiHints,
|
||||||
|
defaultEnabledWhenBundled: params.manifest.defaultEnabledWhenBundled,
|
||||||
|
install: params.candidate.packageManifest?.install,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,13 @@ export type PluginManifest = {
|
|||||||
channels?: string[];
|
channels?: string[];
|
||||||
providers?: string[];
|
providers?: string[];
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
provides?: string[];
|
||||||
|
requires?: string[];
|
||||||
|
conflicts?: string[];
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
|
defaultEnabledWhenBundled?: boolean;
|
||||||
uiHints?: Record<string, PluginConfigUiHint>;
|
uiHints?: Record<string, PluginConfigUiHint>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -94,12 +98,18 @@ export function loadPluginManifest(
|
|||||||
const channels = normalizeStringList(raw.channels);
|
const channels = normalizeStringList(raw.channels);
|
||||||
const providers = normalizeStringList(raw.providers);
|
const providers = normalizeStringList(raw.providers);
|
||||||
const skills = normalizeStringList(raw.skills);
|
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;
|
let uiHints: Record<string, PluginConfigUiHint> | undefined;
|
||||||
if (isRecord(raw.uiHints)) {
|
if (isRecord(raw.uiHints)) {
|
||||||
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
|
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultEnabledWhenBundled =
|
||||||
|
typeof raw.defaultEnabledWhenBundled === "boolean" ? raw.defaultEnabledWhenBundled : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
manifest: {
|
manifest: {
|
||||||
@ -109,9 +119,13 @@ export function loadPluginManifest(
|
|||||||
channels,
|
channels,
|
||||||
providers,
|
providers,
|
||||||
skills,
|
skills,
|
||||||
|
provides,
|
||||||
|
requires,
|
||||||
|
conflicts,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
version,
|
version,
|
||||||
|
defaultEnabledWhenBundled,
|
||||||
uiHints,
|
uiHints,
|
||||||
},
|
},
|
||||||
manifestPath,
|
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 { registerInternalHook } from "../hooks/internal-hooks.js";
|
||||||
import type { HookEntry } from "../hooks/types.js";
|
import type { HookEntry } from "../hooks/types.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
import {
|
||||||
|
buildCapabilityName,
|
||||||
|
resolveCapabilitySlotForKind,
|
||||||
|
resolveCapabilitySlotModeForKind,
|
||||||
|
type PluginCapabilityKind,
|
||||||
|
type PluginCapabilitySlotMode,
|
||||||
|
} from "./capabilities.js";
|
||||||
import { registerPluginCommand } from "./commands.js";
|
import { registerPluginCommand } from "./commands.js";
|
||||||
import { normalizePluginHttpPath } from "./http-path.js";
|
import { normalizePluginHttpPath } from "./http-path.js";
|
||||||
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
||||||
@ -42,6 +49,7 @@ import type {
|
|||||||
PluginHookName,
|
PluginHookName,
|
||||||
PluginHookHandlerMap,
|
PluginHookHandlerMap,
|
||||||
PluginHookRegistration as TypedPluginHookRegistration,
|
PluginHookRegistration as TypedPluginHookRegistration,
|
||||||
|
SearchProviderPlugin,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export type PluginToolRegistration = {
|
export type PluginToolRegistration = {
|
||||||
@ -81,6 +89,23 @@ export type PluginProviderRegistration = {
|
|||||||
source: string;
|
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 = {
|
export type PluginHookRegistration = {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
entry: HookEntry;
|
entry: HookEntry;
|
||||||
@ -116,6 +141,11 @@ export type PluginRecord = {
|
|||||||
hookNames: string[];
|
hookNames: string[];
|
||||||
channelIds: string[];
|
channelIds: string[];
|
||||||
providerIds: string[];
|
providerIds: string[];
|
||||||
|
searchProviderIds: string[];
|
||||||
|
capabilityIds: string[];
|
||||||
|
declaredCapabilities: string[];
|
||||||
|
requiredCapabilities: string[];
|
||||||
|
conflictingCapabilities: string[];
|
||||||
gatewayMethods: string[];
|
gatewayMethods: string[];
|
||||||
cliCommands: string[];
|
cliCommands: string[];
|
||||||
services: string[];
|
services: string[];
|
||||||
@ -125,6 +155,7 @@ export type PluginRecord = {
|
|||||||
configSchema: boolean;
|
configSchema: boolean;
|
||||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||||
configJsonSchema?: Record<string, unknown>;
|
configJsonSchema?: Record<string, unknown>;
|
||||||
|
defaultEnabledWhenBundled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginRegistry = {
|
export type PluginRegistry = {
|
||||||
@ -134,6 +165,8 @@ export type PluginRegistry = {
|
|||||||
typedHooks: TypedPluginHookRegistration[];
|
typedHooks: TypedPluginHookRegistration[];
|
||||||
channels: PluginChannelRegistration[];
|
channels: PluginChannelRegistration[];
|
||||||
providers: PluginProviderRegistration[];
|
providers: PluginProviderRegistration[];
|
||||||
|
searchProviders: PluginSearchProviderRegistration[];
|
||||||
|
capabilities: PluginCapabilityRegistration[];
|
||||||
gatewayHandlers: GatewayRequestHandlers;
|
gatewayHandlers: GatewayRequestHandlers;
|
||||||
httpRoutes: PluginHttpRouteRegistration[];
|
httpRoutes: PluginHttpRouteRegistration[];
|
||||||
cliRegistrars: PluginCliRegistration[];
|
cliRegistrars: PluginCliRegistration[];
|
||||||
@ -174,6 +207,8 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
|||||||
typedHooks: [],
|
typedHooks: [],
|
||||||
channels: [],
|
channels: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
searchProviders: [],
|
||||||
|
capabilities: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
cliRegistrars: [],
|
||||||
@ -191,6 +226,60 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
registry.diagnostics.push(diag);
|
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 = (
|
const registerTool = (
|
||||||
record: PluginRecord,
|
record: PluginRecord,
|
||||||
tool: AnyAgentTool | OpenClawPluginToolFactory,
|
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 = (
|
const registerCli = (
|
||||||
record: PluginRecord,
|
record: PluginRecord,
|
||||||
registrar: OpenClawPluginCliRegistrar,
|
registrar: OpenClawPluginCliRegistrar,
|
||||||
@ -607,6 +741,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
registerHttpRoute: (params) => registerHttpRoute(record, params),
|
registerHttpRoute: (params) => registerHttpRoute(record, params),
|
||||||
registerChannel: (registration) => registerChannel(record, registration),
|
registerChannel: (registration) => registerChannel(record, registration),
|
||||||
registerProvider: (provider) => registerProvider(record, provider),
|
registerProvider: (provider) => registerProvider(record, provider),
|
||||||
|
registerSearchProvider: (provider) => registerSearchProvider(record, provider),
|
||||||
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
|
||||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||||
registerService: (service) => registerService(record, service),
|
registerService: (service) => registerService(record, service),
|
||||||
@ -625,6 +760,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||||||
registerTool,
|
registerTool,
|
||||||
registerChannel,
|
registerChannel,
|
||||||
registerProvider,
|
registerProvider,
|
||||||
|
registerSearchProvider,
|
||||||
registerGatewayMethod,
|
registerGatewayMethod,
|
||||||
registerCli,
|
registerCli,
|
||||||
registerService,
|
registerService,
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type { PluginSlotsConfig } from "../config/types.plugins.js";
|
import type { PluginSlotsConfig } from "../config/types.plugins.js";
|
||||||
|
import {
|
||||||
|
applyCapabilitySlotSelection,
|
||||||
|
resolveCapabilitySlotSelection,
|
||||||
|
} from "./capability-slots.js";
|
||||||
import type { PluginKind } from "./types.js";
|
import type { PluginKind } from "./types.js";
|
||||||
|
|
||||||
export type PluginSlotKey = keyof PluginSlotsConfig;
|
export type PluginSlotKey = keyof PluginSlotsConfig;
|
||||||
@ -50,12 +54,10 @@ export function applyExclusiveSlotSelection(params: {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const pluginsConfig = params.config.plugins ?? {};
|
const pluginsConfig = params.config.plugins ?? {};
|
||||||
const prevSlot = pluginsConfig.slots?.[slotKey];
|
const prevSlot = pluginsConfig.slots?.[slotKey];
|
||||||
const slots = {
|
const inferredPrevSlot =
|
||||||
...pluginsConfig.slots,
|
slotKey === "memory"
|
||||||
[slotKey]: params.selectedId,
|
? resolveCapabilitySlotSelection(params.config, "memory.backend")
|
||||||
};
|
: (prevSlot ?? defaultSlotIdForKey(slotKey));
|
||||||
|
|
||||||
const inferredPrevSlot = prevSlot ?? defaultSlotIdForKey(slotKey);
|
|
||||||
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
|
if (inferredPrevSlot && inferredPrevSlot !== params.selectedId) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
|
`Exclusive slot "${slotKey}" switched from "${inferredPrevSlot}" to "${params.selectedId}".`,
|
||||||
@ -95,12 +97,29 @@ export function applyExclusiveSlotSelection(params: {
|
|||||||
return { config: params.config, warnings: [], changed: false };
|
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 {
|
return {
|
||||||
config: {
|
config: {
|
||||||
...params.config,
|
...baseConfig,
|
||||||
plugins: {
|
plugins: {
|
||||||
...pluginsConfig,
|
...baseConfig.plugins,
|
||||||
slots,
|
|
||||||
entries,
|
entries,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -239,6 +239,117 @@ export type OpenClawPluginGatewayMethod = {
|
|||||||
handler: GatewayRequestHandler;
|
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
|
// Plugin Commands
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@ -388,6 +499,7 @@ export type OpenClawPluginApi = {
|
|||||||
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
|
||||||
registerService: (service: OpenClawPluginService) => void;
|
registerService: (service: OpenClawPluginService) => void;
|
||||||
registerProvider: (provider: ProviderPlugin) => void;
|
registerProvider: (provider: ProviderPlugin) => void;
|
||||||
|
registerSearchProvider: (provider: SearchProviderPlugin) => void;
|
||||||
/**
|
/**
|
||||||
* Register a custom command that bypasses the LLM agent.
|
* Register a custom command that bypasses the LLM agent.
|
||||||
* Plugin commands are processed before built-in commands and before agent invocation.
|
* Plugin commands are processed before built-in commands and before agent invocation.
|
||||||
@ -415,6 +527,17 @@ export type PluginDiagnostic = {
|
|||||||
message: string;
|
message: string;
|
||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
source?: 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_model_resolve"
|
||||||
| "before_prompt_build"
|
| "before_prompt_build"
|
||||||
| "before_agent_start"
|
| "before_agent_start"
|
||||||
|
| "before_provider_configure"
|
||||||
|
| "after_provider_configure"
|
||||||
|
| "after_provider_activate"
|
||||||
| "llm_input"
|
| "llm_input"
|
||||||
| "llm_output"
|
| "llm_output"
|
||||||
| "agent_end"
|
| "agent_end"
|
||||||
@ -451,6 +577,9 @@ export const PLUGIN_HOOK_NAMES = [
|
|||||||
"before_model_resolve",
|
"before_model_resolve",
|
||||||
"before_prompt_build",
|
"before_prompt_build",
|
||||||
"before_agent_start",
|
"before_agent_start",
|
||||||
|
"before_provider_configure",
|
||||||
|
"after_provider_configure",
|
||||||
|
"after_provider_activate",
|
||||||
"llm_input",
|
"llm_input",
|
||||||
"llm_output",
|
"llm_output",
|
||||||
"agent_end",
|
"agent_end",
|
||||||
@ -590,6 +719,58 @@ export const stripPromptMutationFieldsFromLegacyHookResult = (
|
|||||||
: undefined;
|
: 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
|
// llm_input hook
|
||||||
export type PluginHookLlmInputEvent = {
|
export type PluginHookLlmInputEvent = {
|
||||||
runId: string;
|
runId: string;
|
||||||
@ -903,6 +1084,21 @@ export type PluginHookHandlerMap = {
|
|||||||
event: PluginHookBeforeAgentStartEvent,
|
event: PluginHookBeforeAgentStartEvent,
|
||||||
ctx: PluginHookAgentContext,
|
ctx: PluginHookAgentContext,
|
||||||
) => Promise<PluginHookBeforeAgentStartResult | void> | PluginHookBeforeAgentStartResult | void;
|
) => 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_input: (event: PluginHookLlmInputEvent, ctx: PluginHookAgentContext) => Promise<void> | void;
|
||||||
llm_output: (
|
llm_output: (
|
||||||
event: PluginHookLlmOutputEvent,
|
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 type { OpenClawConfig } from "../config/config.js";
|
||||||
import * as secretResolve from "./resolve.js";
|
import * as secretResolve from "./resolve.js";
|
||||||
import { createResolverContext } from "./runtime-shared.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";
|
import { resolveRuntimeWebTools } from "./runtime-web-tools.js";
|
||||||
|
|
||||||
type ProviderUnderTest = "brave" | "gemini" | "grok" | "kimi" | "perplexity";
|
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 {
|
function asConfig(value: unknown): OpenClawConfig {
|
||||||
return value as 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 }) {
|
async function runRuntimeWebTools(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv }) {
|
||||||
const sourceConfig = structuredClone(params.config);
|
const sourceConfig = structuredClone(withBundledSearchPluginsEnabled(params.config));
|
||||||
const resolvedConfig = structuredClone(params.config);
|
const resolvedConfig = structuredClone(sourceConfig);
|
||||||
const context = createResolverContext({
|
const context = createResolverContext({
|
||||||
sourceConfig,
|
sourceConfig,
|
||||||
env: params.env ?? {},
|
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", () => {
|
describe("runtime web tools resolution", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { resolveSecretInputRef } from "../config/types.secrets.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 { normalizeSecretInput } from "../utils/normalize-secret-input.js";
|
||||||
import { secretRefKey } from "./ref-contract.js";
|
import { secretRefKey } from "./ref-contract.js";
|
||||||
import { resolveSecretRefValues } from "./resolve.js";
|
import { resolveSecretRefValues } from "./resolve.js";
|
||||||
@ -10,13 +16,7 @@ import {
|
|||||||
type SecretDefaults,
|
type SecretDefaults,
|
||||||
} from "./runtime-shared.js";
|
} from "./runtime-shared.js";
|
||||||
|
|
||||||
const WEB_SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
|
type WebSearchProvider = string;
|
||||||
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 SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret
|
type SecretResolutionSource = "config" | "secretRef" | "env" | "missing"; // pragma: allowlist secret
|
||||||
type RuntimeWebProviderSource = "configured" | "auto-detect" | "none";
|
type RuntimeWebProviderSource = "configured" | "auto-detect" | "none";
|
||||||
@ -82,16 +82,49 @@ function normalizeProvider(value: unknown): WebSearchProvider | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
if (
|
return normalized || undefined;
|
||||||
normalized === "brave" ||
|
}
|
||||||
normalized === "gemini" ||
|
|
||||||
normalized === "grok" ||
|
type RegisteredSearchProviderRuntimeSupport = {
|
||||||
normalized === "kimi" ||
|
setup?: SearchProviderSetupMetadata;
|
||||||
normalized === "perplexity"
|
resolveRuntimeMetadata?: SearchProviderPlugin["resolveRuntimeMetadata"];
|
||||||
) {
|
};
|
||||||
return normalized;
|
|
||||||
|
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(
|
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> {
|
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||||
const current = target[key];
|
const current = target[key];
|
||||||
if (isRecord(current)) {
|
if (isRecord(current)) {
|
||||||
@ -292,17 +271,16 @@ function ensureObject(target: Record<string, unknown>, key: string): Record<stri
|
|||||||
function setResolvedWebSearchApiKey(params: {
|
function setResolvedWebSearchApiKey(params: {
|
||||||
resolvedConfig: OpenClawConfig;
|
resolvedConfig: OpenClawConfig;
|
||||||
provider: WebSearchProvider;
|
provider: WebSearchProvider;
|
||||||
|
metadata: RegisteredSearchProviderRuntimeSupport;
|
||||||
value: string;
|
value: string;
|
||||||
}): void {
|
}): void {
|
||||||
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
|
const tools = ensureObject(params.resolvedConfig as Record<string, unknown>, "tools");
|
||||||
const web = ensureObject(tools, "web");
|
const web = ensureObject(tools, "web");
|
||||||
const search = ensureObject(web, "search");
|
const search = ensureObject(web, "search");
|
||||||
if (params.provider === "brave") {
|
resolveProviderCredentialMetadata(params.metadata.setup)?.writeApiKeyValue?.(
|
||||||
search.apiKey = params.value;
|
search,
|
||||||
return;
|
params.value,
|
||||||
}
|
);
|
||||||
const providerConfig = ensureObject(search, params.provider);
|
|
||||||
providerConfig.apiKey = params.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setResolvedFirecrawlApiKey(params: {
|
function setResolvedFirecrawlApiKey(params: {
|
||||||
@ -316,34 +294,33 @@ function setResolvedFirecrawlApiKey(params: {
|
|||||||
firecrawl.apiKey = params.value;
|
firecrawl.apiKey = params.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function envVarsForProvider(provider: WebSearchProvider): string[] {
|
function envVarsForProvider(
|
||||||
if (provider === "brave") {
|
metadataByProvider: Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport>,
|
||||||
return ["BRAVE_API_KEY"];
|
provider: WebSearchProvider,
|
||||||
}
|
): string[] {
|
||||||
if (provider === "gemini") {
|
return [
|
||||||
return ["GEMINI_API_KEY"];
|
...(resolveProviderCredentialMetadata(metadataByProvider.get(provider)?.setup)?.envKeys ?? []),
|
||||||
}
|
];
|
||||||
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 resolveProviderKeyValue(
|
function resolveProviderKeyValue(
|
||||||
|
metadataByProvider: Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport>,
|
||||||
search: Record<string, unknown>,
|
search: Record<string, unknown>,
|
||||||
provider: WebSearchProvider,
|
provider: WebSearchProvider,
|
||||||
): unknown {
|
): unknown {
|
||||||
if (provider === "brave") {
|
return resolveProviderCredentialMetadata(
|
||||||
return search.apiKey;
|
metadataByProvider.get(provider)?.setup,
|
||||||
}
|
)?.readApiKeyValue?.(search);
|
||||||
const scoped = search[provider];
|
}
|
||||||
if (!isRecord(scoped)) {
|
|
||||||
return undefined;
|
function providerConfigPath(
|
||||||
}
|
metadataByProvider: Map<WebSearchProvider, RegisteredSearchProviderRuntimeSupport>,
|
||||||
return scoped.apiKey;
|
provider: WebSearchProvider,
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
resolveProviderCredentialMetadata(metadataByProvider.get(provider)?.setup)?.apiKeyConfigPath ??
|
||||||
|
"tools.web.search.provider"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasConfiguredSecretRef(value: unknown, defaults: SecretDefaults | undefined): boolean {
|
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 tools = isRecord(params.sourceConfig.tools) ? params.sourceConfig.tools : undefined;
|
||||||
const web = isRecord(tools?.web) ? tools.web : undefined;
|
const web = isRecord(tools?.web) ? tools.web : undefined;
|
||||||
const search = isRecord(web?.search) ? web.search : undefined;
|
const search = isRecord(web?.search) ? web.search : undefined;
|
||||||
|
const searchProviderMetadata = resolveRegisteredSearchProviderMetadata(params.sourceConfig);
|
||||||
|
|
||||||
const searchMetadata: RuntimeWebSearchMetadata = {
|
const searchMetadata: RuntimeWebSearchMetadata = {
|
||||||
providerSource: "none",
|
providerSource: "none",
|
||||||
@ -376,8 +354,12 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
const rawProvider =
|
const rawProvider =
|
||||||
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
|
typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : "";
|
||||||
const configuredProvider = normalizeProvider(rawProvider);
|
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 = {
|
const diagnostic: RuntimeWebDiagnostic = {
|
||||||
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
|
code: "WEB_SEARCH_PROVIDER_INVALID_AUTODETECT",
|
||||||
message: `tools.web.search.provider is "${rawProvider}". Falling back to auto-detect precedence.`,
|
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.providerConfigured = configuredProvider;
|
||||||
searchMetadata.providerSource = "configured";
|
searchMetadata.providerSource = "configured";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchEnabled && search) {
|
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<{
|
const unresolvedWithoutFallback: Array<{
|
||||||
provider: WebSearchProvider;
|
provider: WebSearchProvider;
|
||||||
path: string;
|
path: string;
|
||||||
@ -409,16 +396,15 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
let selectedResolution: SecretResolutionResult | undefined;
|
let selectedResolution: SecretResolutionResult | undefined;
|
||||||
|
|
||||||
for (const provider of candidates) {
|
for (const provider of candidates) {
|
||||||
const path =
|
const path = providerConfigPath(searchProviderMetadata, provider);
|
||||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
|
||||||
const value = resolveProviderKeyValue(search, provider);
|
|
||||||
const resolution = await resolveSecretInputWithEnvFallback({
|
const resolution = await resolveSecretInputWithEnvFallback({
|
||||||
sourceConfig: params.sourceConfig,
|
sourceConfig: params.sourceConfig,
|
||||||
context: params.context,
|
context: params.context,
|
||||||
defaults,
|
defaults,
|
||||||
value,
|
value,
|
||||||
path,
|
path,
|
||||||
envVars: envVarsForProvider(provider),
|
envVars: envVarsForProvider(searchProviderMetadata, provider),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) {
|
if (resolution.secretRefConfigured && resolution.fallbackUsedAfterRefFailure) {
|
||||||
@ -446,13 +432,15 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configuredProvider) {
|
if (hasConfiguredSelection) {
|
||||||
selectedProvider = provider;
|
selectedProvider = provider;
|
||||||
selectedResolution = resolution;
|
selectedResolution = resolution;
|
||||||
if (resolution.value) {
|
if (resolution.value) {
|
||||||
|
const metadata = searchProviderMetadata.get(provider);
|
||||||
setResolvedWebSearchApiKey({
|
setResolvedWebSearchApiKey({
|
||||||
resolvedConfig: params.resolvedConfig,
|
resolvedConfig: params.resolvedConfig,
|
||||||
provider,
|
provider,
|
||||||
|
metadata: metadata ?? {},
|
||||||
value: resolution.value,
|
value: resolution.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -462,9 +450,11 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
if (resolution.value) {
|
if (resolution.value) {
|
||||||
selectedProvider = provider;
|
selectedProvider = provider;
|
||||||
selectedResolution = resolution;
|
selectedResolution = resolution;
|
||||||
|
const metadata = searchProviderMetadata.get(provider);
|
||||||
setResolvedWebSearchApiKey({
|
setResolvedWebSearchApiKey({
|
||||||
resolvedConfig: params.resolvedConfig,
|
resolvedConfig: params.resolvedConfig,
|
||||||
provider,
|
provider,
|
||||||
|
metadata: metadata ?? {},
|
||||||
value: resolution.value,
|
value: resolution.value,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -487,7 +477,7 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`);
|
throw new Error(`[WEB_SEARCH_KEY_UNRESOLVED_NO_FALLBACK] ${unresolved.reason}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (configuredProvider) {
|
if (hasConfiguredSelection) {
|
||||||
const unresolved = unresolvedWithoutFallback[0];
|
const unresolved = unresolvedWithoutFallback[0];
|
||||||
if (unresolved) {
|
if (unresolved) {
|
||||||
failUnresolvedSearchNoFallback(unresolved);
|
failUnresolvedSearchNoFallback(unresolved);
|
||||||
@ -511,28 +501,34 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
if (selectedProvider) {
|
if (selectedProvider) {
|
||||||
searchMetadata.selectedProvider = selectedProvider;
|
searchMetadata.selectedProvider = selectedProvider;
|
||||||
searchMetadata.selectedProviderKeySource = selectedResolution?.source;
|
searchMetadata.selectedProviderKeySource = selectedResolution?.source;
|
||||||
if (!configuredProvider) {
|
if (!hasConfiguredSelection) {
|
||||||
searchMetadata.providerSource = "auto-detect";
|
searchMetadata.providerSource = "auto-detect";
|
||||||
}
|
}
|
||||||
if (selectedProvider === "perplexity") {
|
const runtimeMetadata = searchProviderMetadata
|
||||||
searchMetadata.perplexityTransport = resolvePerplexityRuntimeTransport({
|
.get(selectedProvider)
|
||||||
|
?.resolveRuntimeMetadata?.({
|
||||||
|
search,
|
||||||
keyValue: selectedResolution?.value,
|
keyValue: selectedResolution?.value,
|
||||||
keySource: selectedResolution?.source ?? "missing",
|
keySource: selectedResolution?.source ?? "missing",
|
||||||
fallbackEnvVar: selectedResolution?.fallbackEnvVar,
|
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) {
|
if (searchEnabled && search && !hasConfiguredSelection && searchMetadata.selectedProvider) {
|
||||||
for (const provider of WEB_SEARCH_PROVIDERS) {
|
for (const provider of knownProviders) {
|
||||||
if (provider === searchMetadata.selectedProvider) {
|
if (provider === searchMetadata.selectedProvider) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const path =
|
const path = providerConfigPath(searchProviderMetadata, provider);
|
||||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
|
||||||
const value = resolveProviderKeyValue(search, provider);
|
|
||||||
if (!hasConfiguredSecretRef(value, defaults)) {
|
if (!hasConfiguredSecretRef(value, defaults)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -543,10 +539,9 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (search && !searchEnabled) {
|
} else if (search && !searchEnabled) {
|
||||||
for (const provider of WEB_SEARCH_PROVIDERS) {
|
for (const provider of knownProviders) {
|
||||||
const path =
|
const path = providerConfigPath(searchProviderMetadata, provider);
|
||||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
|
||||||
const value = resolveProviderKeyValue(search, provider);
|
|
||||||
if (!hasConfiguredSecretRef(value, defaults)) {
|
if (!hasConfiguredSecretRef(value, defaults)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -558,21 +553,20 @@ export async function resolveRuntimeWebTools(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchEnabled && search && configuredProvider) {
|
if (searchEnabled && search && hasConfiguredSelection && searchMetadata.providerConfigured) {
|
||||||
for (const provider of WEB_SEARCH_PROVIDERS) {
|
for (const provider of knownProviders) {
|
||||||
if (provider === configuredProvider) {
|
if (provider === searchMetadata.providerConfigured) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const path =
|
const path = providerConfigPath(searchProviderMetadata, provider);
|
||||||
provider === "brave" ? "tools.web.search.apiKey" : `tools.web.search.${provider}.apiKey`;
|
const value = resolveProviderKeyValue(searchProviderMetadata, search, provider);
|
||||||
const value = resolveProviderKeyValue(search, provider);
|
|
||||||
if (!hasConfiguredSecretRef(value, defaults)) {
|
if (!hasConfiguredSecretRef(value, defaults)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
pushInactiveSurfaceWarning({
|
pushInactiveSurfaceWarning({
|
||||||
context: params.context,
|
context: params.context,
|
||||||
path,
|
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: [],
|
typedHooks: [],
|
||||||
channels: channels as unknown as PluginRegistry["channels"],
|
channels: channels as unknown as PluginRegistry["channels"],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
searchProviders: [],
|
||||||
gatewayHandlers: {},
|
gatewayHandlers: {},
|
||||||
httpRoutes: [],
|
httpRoutes: [],
|
||||||
cliRegistrars: [],
|
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 { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
@ -20,6 +20,9 @@ const gatewayServiceRestart = vi.hoisted(() =>
|
|||||||
);
|
);
|
||||||
const gatewayServiceUninstall = vi.hoisted(() => vi.fn(async () => {}));
|
const gatewayServiceUninstall = vi.hoisted(() => vi.fn(async () => {}));
|
||||||
const gatewayServiceIsLoaded = vi.hoisted(() => vi.fn(async () => false));
|
const gatewayServiceIsLoaded = vi.hoisted(() => vi.fn(async () => false));
|
||||||
|
const loadOpenClawPlugins = vi.hoisted(() =>
|
||||||
|
vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })),
|
||||||
|
);
|
||||||
const resolveGatewayInstallToken = vi.hoisted(() =>
|
const resolveGatewayInstallToken = vi.hoisted(() =>
|
||||||
vi.fn(async () => ({
|
vi.fn(async () => ({
|
||||||
token: undefined,
|
token: undefined,
|
||||||
@ -88,6 +91,10 @@ vi.mock("../infra/control-ui-assets.js", () => ({
|
|||||||
ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })),
|
ensureControlUiAssetsBuilt: vi.fn(async () => ({ ok: true })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/loader.js", () => ({
|
||||||
|
loadOpenClawPlugins,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../terminal/restore.js", () => ({
|
vi.mock("../terminal/restore.js", () => ({
|
||||||
restoreTerminalState: vi.fn(),
|
restoreTerminalState: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@ -118,6 +125,10 @@ function expectFirstOnboardingInstallPlanCallOmitsToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("finalizeOnboardingWizard", () => {
|
describe("finalizeOnboardingWizard", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
runTui.mockClear();
|
runTui.mockClear();
|
||||||
probeGatewayReachable.mockClear();
|
probeGatewayReachable.mockClear();
|
||||||
@ -130,6 +141,8 @@ describe("finalizeOnboardingWizard", () => {
|
|||||||
gatewayServiceRestart.mockResolvedValue({ outcome: "completed" });
|
gatewayServiceRestart.mockResolvedValue({ outcome: "completed" });
|
||||||
gatewayServiceUninstall.mockReset();
|
gatewayServiceUninstall.mockReset();
|
||||||
resolveGatewayInstallToken.mockClear();
|
resolveGatewayInstallToken.mockClear();
|
||||||
|
loadOpenClawPlugins.mockReset();
|
||||||
|
loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] });
|
||||||
isSystemdUserServiceAvailable.mockReset();
|
isSystemdUserServiceAvailable.mockReset();
|
||||||
isSystemdUserServiceAvailable.mockResolvedValue(true);
|
isSystemdUserServiceAvailable.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
@ -307,4 +320,151 @@ describe("finalizeOnboardingWizard", () => {
|
|||||||
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
|
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
|
||||||
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
|
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 { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/service.js";
|
||||||
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
|
||||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||||
|
import { resolveCapabilitySlotSelection } from "../plugins/capability-slots.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { restoreTerminalState } from "../terminal/restore.js";
|
import { restoreTerminalState } from "../terminal/restore.js";
|
||||||
import { runTui } from "../tui/tui.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;
|
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
|
||||||
if (webSearchProvider) {
|
if (webSearchProvider) {
|
||||||
const { SEARCH_PROVIDER_OPTIONS, resolveExistingKey, hasExistingKey, hasKeyInEnv } =
|
const {
|
||||||
await import("../commands/onboard-search.js");
|
resolveExistingKey,
|
||||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === webSearchProvider);
|
hasExistingKey,
|
||||||
const label = entry?.label ?? webSearchProvider;
|
hasKeyInEnv,
|
||||||
const storedKey = resolveExistingKey(nextConfig, webSearchProvider);
|
resolveSearchProviderPickerEntry,
|
||||||
const keyConfigured = hasExistingKey(nextConfig, webSearchProvider);
|
resolveSearchProviderPickerEntries,
|
||||||
const envAvailable = entry ? hasKeyInEnv(entry) : false;
|
} = 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 hasKey = keyConfigured || envAvailable;
|
||||||
const keySource = storedKey
|
const keySource = storedKey
|
||||||
? "API key: stored in config."
|
? "API key: stored in config."
|
||||||
: keyConfigured
|
: keyConfigured
|
||||||
? "API key: configured via secret reference."
|
? "API key: configured via secret reference."
|
||||||
: envAvailable
|
: envAvailable
|
||||||
? `API key: provided via ${entry?.envKeys.join(" / ")} env var.`
|
? `API key: provided via ${credentialMetadata?.envKeys?.join(" / ")} env var.`
|
||||||
: undefined;
|
: 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(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
"Web search is enabled, so your agent can look things up online when needed.",
|
"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] : []),
|
...(keySource ? [keySource] : []),
|
||||||
"Docs: https://docs.openclaw.ai/tools/web",
|
"Docs: https://docs.openclaw.ai/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@ -517,7 +560,7 @@ export async function finalizeOnboardingWizard(
|
|||||||
"web_search will not work until a key is added.",
|
"web_search will not work until a key is added.",
|
||||||
` ${formatCliCommand("openclaw configure --section web")}`,
|
` ${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",
|
"Docs: https://docs.openclaw.ai/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"Web search",
|
||||||
@ -536,15 +579,22 @@ export async function finalizeOnboardingWizard(
|
|||||||
} else {
|
} else {
|
||||||
// Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without
|
// 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".
|
// 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");
|
await import("../commands/onboard-search.js");
|
||||||
const legacyDetected = SEARCH_PROVIDER_OPTIONS.find(
|
const providerEntries = await resolveSearchProviderPickerEntries(
|
||||||
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
|
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(
|
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",
|
"Docs: https://docs.openclaw.ai/tools/web",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Web search",
|
"Web search",
|
||||||
|
|||||||
@ -531,6 +531,7 @@ export async function runOnboardingWizard(
|
|||||||
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
|
nextConfig = await setupSearch(nextConfig, runtime, prompter, {
|
||||||
quickstartDefaults: flow === "quickstart",
|
quickstartDefaults: flow === "quickstart",
|
||||||
secretInputMode: opts.secretInputMode,
|
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 ciWorkers = isWindows ? 2 : 3;
|
||||||
const pluginSdkSubpaths = [
|
const pluginSdkSubpaths = [
|
||||||
"account-id",
|
"account-id",
|
||||||
|
"account-resolution",
|
||||||
|
"allow-from",
|
||||||
|
"boolean-param",
|
||||||
"core",
|
"core",
|
||||||
|
"web-search",
|
||||||
"compat",
|
"compat",
|
||||||
"telegram",
|
"telegram",
|
||||||
"discord",
|
"discord",
|
||||||
@ -29,7 +33,9 @@ const pluginSdkSubpaths = [
|
|||||||
"feishu",
|
"feishu",
|
||||||
"google-gemini-cli-auth",
|
"google-gemini-cli-auth",
|
||||||
"googlechat",
|
"googlechat",
|
||||||
|
"group-access",
|
||||||
"irc",
|
"irc",
|
||||||
|
"json-store",
|
||||||
"llm-task",
|
"llm-task",
|
||||||
"lobster",
|
"lobster",
|
||||||
"matrix",
|
"matrix",
|
||||||
@ -47,11 +53,13 @@ const pluginSdkSubpaths = [
|
|||||||
"test-utils",
|
"test-utils",
|
||||||
"thread-ownership",
|
"thread-ownership",
|
||||||
"tlon",
|
"tlon",
|
||||||
|
"tool-send",
|
||||||
"twitch",
|
"twitch",
|
||||||
"voice-call",
|
"voice-call",
|
||||||
"zalo",
|
"zalo",
|
||||||
"zalouser",
|
"zalouser",
|
||||||
"keyed-async-queue",
|
"keyed-async-queue",
|
||||||
|
"request-url",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user