mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 21:40:21 +00:00
Web: derive search provider metadata from plugin contracts (#50935)
Merged via squash.
Prepared head SHA: e1c7d72833
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
acf32287b4
commit
3da66718f4
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -7,6 +7,12 @@ const mocks = vi.hoisted(() => ({
|
||||
clackSelect: vi.fn(),
|
||||
clackText: vi.fn(),
|
||||
clackConfirm: vi.fn(),
|
||||
applySearchKey: vi.fn(),
|
||||
applySearchProviderSelection: vi.fn(),
|
||||
hasExistingKey: vi.fn(),
|
||||
hasKeyInEnv: vi.fn(),
|
||||
resolveExistingKey: vi.fn(),
|
||||
resolveSearchProviderOptions: vi.fn(),
|
||||
readConfigFileSnapshot: vi.fn(),
|
||||
writeConfigFile: vi.fn(),
|
||||
resolveGatewayPort: vi.fn(),
|
||||
@@ -95,10 +101,51 @@ vi.mock("./onboard-channels.js", () => ({
|
||||
setupChannels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-search.js", () => ({
|
||||
resolveSearchProviderOptions: mocks.resolveSearchProviderOptions,
|
||||
SEARCH_PROVIDER_OPTIONS: [
|
||||
{
|
||||
id: "firecrawl",
|
||||
label: "Firecrawl Search",
|
||||
hint: "Structured results with optional result scraping",
|
||||
envVars: ["FIRECRAWL_API_KEY"],
|
||||
placeholder: "fc-...",
|
||||
signupUrl: "https://www.firecrawl.dev/",
|
||||
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
},
|
||||
],
|
||||
resolveExistingKey: mocks.resolveExistingKey,
|
||||
hasExistingKey: mocks.hasExistingKey,
|
||||
applySearchKey: mocks.applySearchKey,
|
||||
applySearchProviderSelection: mocks.applySearchProviderSelection,
|
||||
hasKeyInEnv: mocks.hasKeyInEnv,
|
||||
}));
|
||||
|
||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||
import { runConfigureWizard } from "./configure.wizard.js";
|
||||
|
||||
describe("runConfigureWizard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
|
||||
mocks.resolveExistingKey.mockReturnValue(undefined);
|
||||
mocks.hasExistingKey.mockReturnValue(false);
|
||||
mocks.hasKeyInEnv.mockReturnValue(false);
|
||||
mocks.resolveSearchProviderOptions.mockReturnValue([
|
||||
{
|
||||
id: "firecrawl",
|
||||
label: "Firecrawl Search",
|
||||
hint: "Structured results with optional result scraping",
|
||||
envVars: ["FIRECRAWL_API_KEY"],
|
||||
placeholder: "fc-...",
|
||||
signupUrl: "https://www.firecrawl.dev/",
|
||||
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
},
|
||||
]);
|
||||
mocks.applySearchKey.mockReset();
|
||||
mocks.applySearchProviderSelection.mockReset();
|
||||
});
|
||||
|
||||
it("persists gateway.mode=local when only the run mode is selected", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
@@ -158,4 +205,214 @@ describe("runConfigureWizard", () => {
|
||||
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("persists provider-owned web search config changes returned by applySearchKey", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {},
|
||||
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.resolveExistingKey.mockReturnValue(undefined);
|
||||
mocks.hasExistingKey.mockReturnValue(false);
|
||||
mocks.hasKeyInEnv.mockReturnValue(false);
|
||||
mocks.applySearchKey.mockImplementation(
|
||||
(cfg: OpenClawConfig, provider: string, key: string) => ({
|
||||
...cfg,
|
||||
tools: {
|
||||
...cfg.tools,
|
||||
web: {
|
||||
...cfg.tools?.web,
|
||||
search: {
|
||||
provider,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
config: { webSearch: { apiKey: key } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const selectQueue = ["local", "firecrawl"];
|
||||
const confirmQueue = [true, false];
|
||||
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
|
||||
mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift());
|
||||
mocks.clackText.mockResolvedValue("fc-entered-key");
|
||||
mocks.clackIntro.mockResolvedValue(undefined);
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
|
||||
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({
|
||||
provider: "firecrawl",
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
plugins: expect.objectContaining({
|
||||
entries: expect.objectContaining({
|
||||
firecrawl: expect.objectContaining({
|
||||
enabled: true,
|
||||
config: expect.objectContaining({
|
||||
webSearch: expect.objectContaining({ apiKey: "fc-entered-key" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("applies provider selection side effects when a key already exists via secret ref or env", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {},
|
||||
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.resolveExistingKey.mockReturnValue(undefined);
|
||||
mocks.hasExistingKey.mockReturnValue(true);
|
||||
mocks.hasKeyInEnv.mockReturnValue(false);
|
||||
mocks.applySearchProviderSelection.mockImplementation(
|
||||
(cfg: OpenClawConfig, provider: string) => ({
|
||||
...cfg,
|
||||
tools: {
|
||||
...cfg.tools,
|
||||
web: {
|
||||
...cfg.tools?.web,
|
||||
search: {
|
||||
provider,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
firecrawl: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const selectQueue = ["local", "firecrawl"];
|
||||
const confirmQueue = [true, false];
|
||||
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
|
||||
mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift());
|
||||
mocks.clackText.mockResolvedValue("");
|
||||
mocks.clackIntro.mockResolvedValue(undefined);
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
|
||||
await runConfigureWizard(
|
||||
{ command: "configure", sections: ["web"] },
|
||||
{
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.applySearchProviderSelection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gateway: expect.objectContaining({ mode: "local" }),
|
||||
}),
|
||||
"firecrawl",
|
||||
);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
entries: expect.objectContaining({
|
||||
firecrawl: expect.objectContaining({
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not crash when web search providers are unavailable under plugin policy", async () => {
|
||||
mocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
exists: false,
|
||||
valid: true,
|
||||
config: {},
|
||||
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.resolveSearchProviderOptions.mockReturnValue([]);
|
||||
|
||||
const selectQueue = ["local"];
|
||||
const confirmQueue = [true, false];
|
||||
mocks.clackSelect.mockImplementation(async () => selectQueue.shift());
|
||||
mocks.clackConfirm.mockImplementation(async () => confirmQueue.shift());
|
||||
mocks.clackText.mockResolvedValue("");
|
||||
mocks.clackIntro.mockResolvedValue(undefined);
|
||||
mocks.clackOutro.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
runConfigureWizard(
|
||||
{ command: "configure", sections: ["web"] },
|
||||
{
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"No web search providers are currently available under this plugin policy.",
|
||||
),
|
||||
"Web search",
|
||||
);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
web: expect.objectContaining({
|
||||
search: expect.objectContaining({
|
||||
enabled: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,34 +167,30 @@ async function promptWebToolsConfig(
|
||||
const existingSearch = nextConfig.tools?.web?.search;
|
||||
const existingFetch = nextConfig.tools?.web?.fetch;
|
||||
const {
|
||||
SEARCH_PROVIDER_OPTIONS,
|
||||
resolveSearchProviderOptions,
|
||||
resolveExistingKey,
|
||||
hasExistingKey,
|
||||
applySearchKey,
|
||||
applySearchProviderSelection,
|
||||
hasKeyInEnv,
|
||||
} = await import("./onboard-search.js");
|
||||
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
|
||||
const defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value;
|
||||
if (!defaultProvider) {
|
||||
throw new Error("No web search providers are registered.");
|
||||
}
|
||||
const searchProviderOptions = resolveSearchProviderOptions(nextConfig);
|
||||
const defaultProvider = searchProviderOptions[0]?.id;
|
||||
|
||||
const hasKeyForProvider = (provider: string): boolean => {
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
|
||||
const entry = searchProviderOptions.find((e) => e.id === provider);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return hasExistingKey(nextConfig, provider) || hasKeyInEnv(entry);
|
||||
};
|
||||
|
||||
const existingProvider: SP = (() => {
|
||||
const existingProvider = (() => {
|
||||
const stored = existingSearch?.provider;
|
||||
if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) {
|
||||
if (stored && searchProviderOptions.some((e) => e.id === stored)) {
|
||||
return stored;
|
||||
}
|
||||
return (
|
||||
SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? defaultProvider
|
||||
);
|
||||
return searchProviderOptions.find((e) => hasKeyForProvider(e.id))?.id ?? defaultProvider;
|
||||
})();
|
||||
|
||||
note(
|
||||
@@ -210,7 +206,7 @@ async function promptWebToolsConfig(
|
||||
await confirm({
|
||||
message: "Enable web_search?",
|
||||
initialValue:
|
||||
existingSearch?.enabled ?? SEARCH_PROVIDER_OPTIONS.some((e) => hasKeyForProvider(e.value)),
|
||||
existingSearch?.enabled ?? searchProviderOptions.some((e) => hasKeyForProvider(e.id)),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
@@ -219,64 +215,82 @@ async function promptWebToolsConfig(
|
||||
...existingSearch,
|
||||
enabled: enableSearch,
|
||||
};
|
||||
let workingConfig = nextConfig;
|
||||
|
||||
if (enableSearch) {
|
||||
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
||||
const configured = hasKeyForProvider(entry.value);
|
||||
return {
|
||||
value: entry.value,
|
||||
label: entry.label,
|
||||
hint: configured ? `${entry.hint} · configured` : entry.hint,
|
||||
};
|
||||
});
|
||||
|
||||
const providerChoice = guardCancel(
|
||||
await select({
|
||||
message: "Choose web search provider",
|
||||
options: providerOptions,
|
||||
initialValue: existingProvider,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
nextSearch = { ...nextSearch, provider: providerChoice };
|
||||
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
|
||||
const existingKey = resolveExistingKey(nextConfig, providerChoice);
|
||||
const keyConfigured = hasExistingKey(nextConfig, providerChoice);
|
||||
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, (key || existingKey)!);
|
||||
nextSearch = { ...applied.tools?.web?.search };
|
||||
} else if (keyConfigured || envAvailable) {
|
||||
nextSearch = { ...nextSearch };
|
||||
} else {
|
||||
if (searchProviderOptions.length === 0) {
|
||||
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}`,
|
||||
"No web search providers are currently available under this plugin policy.",
|
||||
"Enable plugins or remove deny rules, then rerun configure.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
nextSearch = {
|
||||
...existingSearch,
|
||||
enabled: false,
|
||||
};
|
||||
} else {
|
||||
const providerOptions = searchProviderOptions.map((entry) => {
|
||||
const configured = hasKeyForProvider(entry.id);
|
||||
return {
|
||||
value: entry.id,
|
||||
label: entry.label,
|
||||
hint: configured ? `${entry.hint} · configured` : entry.hint,
|
||||
};
|
||||
});
|
||||
|
||||
const providerChoice = guardCancel(
|
||||
await select({
|
||||
message: "Choose web search provider",
|
||||
options: providerOptions,
|
||||
initialValue: existingProvider,
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
|
||||
nextSearch = { ...nextSearch, provider: providerChoice };
|
||||
|
||||
const entry = searchProviderOptions.find((e) => e.id === providerChoice)!;
|
||||
const existingKey = resolveExistingKey(nextConfig, providerChoice);
|
||||
const keyConfigured = hasExistingKey(nextConfig, providerChoice);
|
||||
const envAvailable = entry.envVars.some((k) => Boolean(process.env[k]?.trim()));
|
||||
const envVarNames = entry.envVars.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) {
|
||||
workingConfig = applySearchKey(workingConfig, providerChoice, (key || existingKey)!);
|
||||
nextSearch = { ...workingConfig.tools?.web?.search };
|
||||
} else if (keyConfigured || envAvailable) {
|
||||
workingConfig = applySearchProviderSelection(workingConfig, providerChoice);
|
||||
nextSearch = { ...workingConfig.tools?.web?.search };
|
||||
} else {
|
||||
nextSearch = { ...nextSearch, provider: providerChoice };
|
||||
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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,11 +308,11 @@ async function promptWebToolsConfig(
|
||||
};
|
||||
|
||||
return {
|
||||
...nextConfig,
|
||||
...workingConfig,
|
||||
tools: {
|
||||
...nextConfig.tools,
|
||||
...workingConfig.tools,
|
||||
web: {
|
||||
...nextConfig.tools?.web,
|
||||
...workingConfig.tools?.web,
|
||||
search: nextSearch,
|
||||
fetch: nextFetch,
|
||||
},
|
||||
|
||||
210
src/commands/onboard-search.providers.test.ts
Normal file
210
src/commands/onboard-search.providers.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolvePluginWebSearchProviders: vi.fn<
|
||||
(params?: { config?: OpenClawConfig }) => PluginWebSearchProviderEntry[]
|
||||
>(() => []),
|
||||
listBundledWebSearchProviders: vi.fn<() => PluginWebSearchProviderEntry[]>(() => []),
|
||||
resolveBundledWebSearchPluginId: vi.fn<(providerId?: string) => string | undefined>(
|
||||
() => undefined,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/web-search-providers.runtime.js", () => ({
|
||||
resolvePluginWebSearchProviders: mocks.resolvePluginWebSearchProviders,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/bundled-web-search.js", () => ({
|
||||
listBundledWebSearchProviders: mocks.listBundledWebSearchProviders,
|
||||
resolveBundledWebSearchPluginId: mocks.resolveBundledWebSearchPluginId,
|
||||
}));
|
||||
|
||||
function createCustomProviderEntry(): PluginWebSearchProviderEntry {
|
||||
return {
|
||||
id: "custom-search" as never,
|
||||
pluginId: "custom-plugin",
|
||||
label: "Custom Search",
|
||||
hint: "Custom provider",
|
||||
envVars: ["CUSTOM_SEARCH_API_KEY"],
|
||||
placeholder: "custom-...",
|
||||
signupUrl: "https://example.com/custom",
|
||||
credentialPath: "plugins.entries.custom-plugin.config.webSearch.apiKey",
|
||||
getCredentialValue: () => undefined,
|
||||
setCredentialValue: () => {},
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
(
|
||||
config?.plugins?.entries?.["custom-plugin"]?.config as
|
||||
| { webSearch?: { apiKey?: unknown } }
|
||||
| undefined
|
||||
)?.webSearch?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
const entries = ((configTarget.plugins ??= {}).entries ??= {});
|
||||
const pluginEntry = (entries["custom-plugin"] ??= {});
|
||||
const pluginConfig = ((pluginEntry as Record<string, unknown>).config ??= {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const webSearch = (pluginConfig.webSearch ??= {}) as Record<string, unknown>;
|
||||
webSearch.apiKey = value;
|
||||
},
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
function createBundledFirecrawlEntry(): PluginWebSearchProviderEntry {
|
||||
return {
|
||||
id: "firecrawl",
|
||||
pluginId: "firecrawl",
|
||||
label: "Firecrawl Search",
|
||||
hint: "Structured results",
|
||||
envVars: ["FIRECRAWL_API_KEY"],
|
||||
placeholder: "fc-...",
|
||||
signupUrl: "https://example.com/firecrawl",
|
||||
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
getCredentialValue: () => undefined,
|
||||
setCredentialValue: () => {},
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
(
|
||||
config?.plugins?.entries?.firecrawl?.config as
|
||||
| { webSearch?: { apiKey?: unknown } }
|
||||
| undefined
|
||||
)?.webSearch?.apiKey,
|
||||
setConfiguredCredentialValue: () => {},
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("onboard-search provider resolution", () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses config-aware non-bundled provider hooks when resolving existing keys", async () => {
|
||||
const customEntry = createCustomProviderEntry();
|
||||
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
|
||||
params?.config ? [customEntry] : [],
|
||||
);
|
||||
|
||||
const mod = await import("./onboard-search.js");
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "custom-search" as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"custom-plugin": {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "custom-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mod.hasExistingKey(cfg, "custom-search" as never)).toBe(true);
|
||||
expect(mod.resolveExistingKey(cfg, "custom-search" as never)).toBe("custom-key");
|
||||
|
||||
const updated = mod.applySearchKey(cfg, "custom-search" as never, "next-key");
|
||||
expect(
|
||||
(
|
||||
updated.plugins?.entries?.["custom-plugin"]?.config as
|
||||
| { webSearch?: { apiKey?: unknown } }
|
||||
| undefined
|
||||
)?.webSearch?.apiKey,
|
||||
).toBe("next-key");
|
||||
});
|
||||
|
||||
it("uses config-aware non-bundled providers when building secret refs", async () => {
|
||||
const customEntry = createCustomProviderEntry();
|
||||
mocks.resolvePluginWebSearchProviders.mockImplementation((params) =>
|
||||
params?.config ? [customEntry] : [],
|
||||
);
|
||||
|
||||
const mod = await import("./onboard-search.js");
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
installs: {
|
||||
"custom-plugin": {
|
||||
installPath: "/tmp/custom-plugin",
|
||||
source: "path",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const notes: Array<{ title?: string; message: string }> = [];
|
||||
const prompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async (message: string, title?: string) => {
|
||||
notes.push({ title, message });
|
||||
}),
|
||||
select: vi.fn(async () => "custom-search"),
|
||||
multiselect: vi.fn(async () => []),
|
||||
text: vi.fn(async () => ""),
|
||||
confirm: vi.fn(async () => true),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
|
||||
const result = await mod.setupSearch(cfg, {} as never, prompter as never, {
|
||||
secretInputMode: "ref",
|
||||
});
|
||||
|
||||
expect(result.tools?.web?.search?.provider).toBe("custom-search");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(
|
||||
(
|
||||
result.plugins?.entries?.["custom-plugin"]?.config as
|
||||
| { webSearch?: { apiKey?: unknown } }
|
||||
| undefined
|
||||
)?.webSearch?.apiKey,
|
||||
).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "CUSTOM_SEARCH_API_KEY",
|
||||
});
|
||||
expect(notes.some((note) => note.message.includes("CUSTOM_SEARCH_API_KEY"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat hard-disabled bundled providers as selectable credentials", async () => {
|
||||
const firecrawlEntry = createBundledFirecrawlEntry();
|
||||
mocks.resolvePluginWebSearchProviders.mockReturnValue([]);
|
||||
mocks.listBundledWebSearchProviders.mockReturnValue([firecrawlEntry]);
|
||||
mocks.resolveBundledWebSearchPluginId.mockReturnValue("firecrawl");
|
||||
|
||||
const mod = await import("./onboard-search.js");
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "firecrawl",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
enabled: false,
|
||||
entries: {
|
||||
firecrawl: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "fc-disabled-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(mod.hasExistingKey(cfg, "firecrawl")).toBe(false);
|
||||
expect(mod.resolveExistingKey(cfg, "firecrawl")).toBeUndefined();
|
||||
expect(mod.applySearchProviderSelection(cfg, "firecrawl")).toBe(cfg);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,45 @@ function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknow
|
||||
return entry?.config?.webSearch?.apiKey;
|
||||
}
|
||||
|
||||
function createDisabledFirecrawlConfig(apiKey?: string): OpenClawConfig {
|
||||
return {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "firecrawl",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
firecrawl: {
|
||||
enabled: false,
|
||||
...(apiKey
|
||||
? {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readFirecrawlPluginApiKey(config: OpenClawConfig): string | undefined {
|
||||
const pluginConfig = config.plugins?.entries?.firecrawl?.config as
|
||||
| {
|
||||
webSearch?: {
|
||||
apiKey?: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
return pluginConfig?.webSearch?.apiKey;
|
||||
}
|
||||
|
||||
async function runBlankPerplexityKeyEntry(
|
||||
apiKey: string,
|
||||
enabled?: boolean,
|
||||
@@ -141,6 +180,20 @@ describe("setupSearch", () => {
|
||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("re-enables firecrawl and persists its plugin config when selected from disabled state", async () => {
|
||||
const cfg = createDisabledFirecrawlConfig();
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "firecrawl",
|
||||
textValue: "fc-disabled-key",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined();
|
||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
expect(readFirecrawlPluginApiKey(result)).toBe("fc-disabled-key");
|
||||
});
|
||||
|
||||
it("sets provider and key for grok", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
@@ -314,6 +367,60 @@ describe("setupSearch", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("quickstart detects an existing firecrawl key even when the plugin is disabled", async () => {
|
||||
const cfg = createDisabledFirecrawlConfig("fc-configured-key");
|
||||
const { prompter } = createPrompter({ selectValue: "firecrawl" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.firecrawl?.apiKey).toBeUndefined();
|
||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
expect(readFirecrawlPluginApiKey(result)).toBe("fc-configured-key");
|
||||
});
|
||||
|
||||
it("preserves disabled firecrawl plugin state and allowlist when web search stays disabled", async () => {
|
||||
const original = process.env.FIRECRAWL_API_KEY;
|
||||
process.env.FIRECRAWL_API_KEY = "env-firecrawl-key"; // pragma: allowlist secret
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "firecrawl",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["google"],
|
||||
entries: {
|
||||
firecrawl: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
try {
|
||||
const { prompter } = createPrompter({ selectValue: "firecrawl" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(false);
|
||||
expect(result.plugins?.allow).toEqual(["google"]);
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.FIRECRAWL_API_KEY;
|
||||
} else {
|
||||
process.env.FIRECRAWL_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("stores env-backed SecretRef when secretInputMode=ref for perplexity", async () => {
|
||||
const originalPerplexity = process.env.PERPLEXITY_API_KEY;
|
||||
const originalOpenRouter = process.env.OPENROUTER_API_KEY;
|
||||
@@ -430,8 +537,8 @@ describe("setupSearch", () => {
|
||||
});
|
||||
|
||||
it("exports all 7 providers in SEARCH_PROVIDER_OPTIONS", () => {
|
||||
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.id);
|
||||
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(7);
|
||||
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);
|
||||
expect(values).toEqual([
|
||||
"brave",
|
||||
"gemini",
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "../config/types.secrets.js";
|
||||
import {
|
||||
listBundledWebSearchProviders,
|
||||
resolveBundledWebSearchPluginId,
|
||||
} from "../plugins/bundled-web-search.js";
|
||||
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
|
||||
import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@@ -18,41 +22,77 @@ export type SearchProvider = NonNullable<
|
||||
type SearchConfig = NonNullable<NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"]>;
|
||||
type MutableSearchConfig = SearchConfig & Record<string, unknown>;
|
||||
|
||||
type SearchProviderEntry = {
|
||||
value: SearchProvider;
|
||||
label: string;
|
||||
hint: string;
|
||||
envKeys: string[];
|
||||
placeholder: string;
|
||||
signupUrl: string;
|
||||
credentialPath: string;
|
||||
applySelectionConfig?: PluginWebSearchProviderEntry["applySelectionConfig"];
|
||||
};
|
||||
|
||||
export const SEARCH_PROVIDER_OPTIONS: readonly SearchProviderEntry[] =
|
||||
export const SEARCH_PROVIDER_OPTIONS: readonly PluginWebSearchProviderEntry[] =
|
||||
resolvePluginWebSearchProviders({
|
||||
bundledAllowlistCompat: true,
|
||||
}).map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.label,
|
||||
hint: provider.hint,
|
||||
envKeys: provider.envVars,
|
||||
placeholder: provider.placeholder,
|
||||
signupUrl: provider.signupUrl,
|
||||
credentialPath: provider.credentialPath,
|
||||
applySelectionConfig: provider.applySelectionConfig,
|
||||
}));
|
||||
});
|
||||
|
||||
export function hasKeyInEnv(entry: SearchProviderEntry): boolean {
|
||||
return entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
|
||||
function sortSearchProviderOptions(
|
||||
providers: PluginWebSearchProviderEntry[],
|
||||
): PluginWebSearchProviderEntry[] {
|
||||
return providers.toSorted((left, right) => {
|
||||
const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
function canRepairBundledProviderSelection(
|
||||
config: OpenClawConfig,
|
||||
provider: Pick<PluginWebSearchProviderEntry, "id" | "pluginId">,
|
||||
): boolean {
|
||||
const pluginId = provider.pluginId ?? resolveBundledWebSearchPluginId(provider.id);
|
||||
if (!pluginId) {
|
||||
return false;
|
||||
}
|
||||
if (config.plugins?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return !config.plugins?.deny?.includes(pluginId);
|
||||
}
|
||||
|
||||
export function resolveSearchProviderOptions(
|
||||
config?: OpenClawConfig,
|
||||
): readonly PluginWebSearchProviderEntry[] {
|
||||
if (!config) {
|
||||
return SEARCH_PROVIDER_OPTIONS;
|
||||
}
|
||||
|
||||
const merged = new Map<string, PluginWebSearchProviderEntry>(
|
||||
resolvePluginWebSearchProviders({
|
||||
config,
|
||||
bundledAllowlistCompat: true,
|
||||
env: process.env,
|
||||
}).map((entry) => [entry.id, entry]),
|
||||
);
|
||||
|
||||
for (const entry of listBundledWebSearchProviders()) {
|
||||
if (merged.has(entry.id) || !canRepairBundledProviderSelection(config, entry)) {
|
||||
continue;
|
||||
}
|
||||
merged.set(entry.id, entry);
|
||||
}
|
||||
|
||||
return sortSearchProviderOptions([...merged.values()]);
|
||||
}
|
||||
|
||||
function resolveSearchProviderEntry(
|
||||
config: OpenClawConfig,
|
||||
provider: SearchProvider,
|
||||
): PluginWebSearchProviderEntry | undefined {
|
||||
return resolveSearchProviderOptions(config).find((entry) => entry.id === provider);
|
||||
}
|
||||
|
||||
export function hasKeyInEnv(entry: Pick<PluginWebSearchProviderEntry, "envVars">): boolean {
|
||||
return entry.envVars.some((k) => Boolean(process.env[k]?.trim()));
|
||||
}
|
||||
|
||||
function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown {
|
||||
const search = config.tools?.web?.search;
|
||||
const entry = resolvePluginWebSearchProviders({
|
||||
config,
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((candidate) => candidate.id === provider);
|
||||
const entry = resolveSearchProviderEntry(config, provider);
|
||||
return (
|
||||
entry?.getConfiguredCredentialValue?.(config) ??
|
||||
entry?.getCredentialValue(search as Record<string, unknown> | undefined)
|
||||
@@ -73,9 +113,12 @@ export function hasExistingKey(config: OpenClawConfig, provider: SearchProvider)
|
||||
}
|
||||
|
||||
/** Build an env-backed SecretRef for a search provider. */
|
||||
function buildSearchEnvRef(provider: SearchProvider): SecretRef {
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === provider);
|
||||
const envVar = entry?.envKeys.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envKeys[0];
|
||||
function buildSearchEnvRef(config: OpenClawConfig, provider: SearchProvider): SecretRef {
|
||||
const entry =
|
||||
resolveSearchProviderEntry(config, provider) ??
|
||||
SEARCH_PROVIDER_OPTIONS.find((candidate) => candidate.id === provider) ??
|
||||
listBundledWebSearchProviders().find((candidate) => candidate.id === provider);
|
||||
const envVar = entry?.envVars.find((k) => Boolean(process.env[k]?.trim())) ?? entry?.envVars[0];
|
||||
if (!envVar) {
|
||||
throw new Error(
|
||||
`No env var mapping for search provider "${provider}" at ${entry?.credentialPath ?? "unknown path"} in secret-input-mode=ref.`,
|
||||
@@ -86,13 +129,14 @@ function buildSearchEnvRef(provider: SearchProvider): SecretRef {
|
||||
|
||||
/** Resolve a plaintext key into the appropriate SecretInput based on mode. */
|
||||
function resolveSearchSecretInput(
|
||||
config: OpenClawConfig,
|
||||
provider: SearchProvider,
|
||||
key: string,
|
||||
secretInputMode?: SecretInputMode,
|
||||
): SecretInput {
|
||||
const useSecretRefMode = secretInputMode === "ref"; // pragma: allowlist secret
|
||||
if (useSecretRefMode) {
|
||||
return buildSearchEnvRef(provider);
|
||||
return buildSearchEnvRef(config, provider);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
@@ -102,12 +146,12 @@ export function applySearchKey(
|
||||
provider: SearchProvider,
|
||||
key: SecretInput,
|
||||
): OpenClawConfig {
|
||||
const providerEntry = resolvePluginWebSearchProviders({
|
||||
config,
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((candidate) => candidate.id === provider);
|
||||
const providerEntry = resolveSearchProviderEntry(config, provider);
|
||||
if (!providerEntry) {
|
||||
return config;
|
||||
}
|
||||
const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, enabled: true };
|
||||
if (providerEntry && !providerEntry.setConfiguredCredentialValue) {
|
||||
if (!providerEntry.setConfiguredCredentialValue) {
|
||||
providerEntry.setCredentialValue(search, key);
|
||||
}
|
||||
const nextBase: OpenClawConfig = {
|
||||
@@ -117,16 +161,19 @@ export function applySearchKey(
|
||||
web: { ...config.tools?.web, search },
|
||||
},
|
||||
};
|
||||
const next = providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
|
||||
providerEntry?.setConfiguredCredentialValue?.(next, key);
|
||||
const next = providerEntry.applySelectionConfig?.(nextBase) ?? nextBase;
|
||||
providerEntry.setConfiguredCredentialValue?.(next, key);
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig {
|
||||
const providerEntry = resolvePluginWebSearchProviders({
|
||||
config,
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((candidate) => candidate.id === provider);
|
||||
export function applySearchProviderSelection(
|
||||
config: OpenClawConfig,
|
||||
provider: SearchProvider,
|
||||
): OpenClawConfig {
|
||||
const providerEntry = resolveSearchProviderEntry(config, provider);
|
||||
if (!providerEntry) {
|
||||
return config;
|
||||
}
|
||||
const search: MutableSearchConfig = {
|
||||
...config.tools?.web?.search,
|
||||
provider,
|
||||
@@ -142,20 +189,65 @@ function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): Op
|
||||
},
|
||||
},
|
||||
};
|
||||
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
|
||||
return providerEntry.applySelectionConfig?.(nextBase) ?? nextBase;
|
||||
}
|
||||
|
||||
function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig): OpenClawConfig {
|
||||
if (original.tools?.web?.search?.enabled !== false) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
|
||||
const next: OpenClawConfig = {
|
||||
...result,
|
||||
tools: {
|
||||
...result.tools,
|
||||
web: { ...result.tools?.web, search: { ...result.tools?.web?.search, enabled: false } },
|
||||
},
|
||||
};
|
||||
|
||||
const provider = next.tools?.web?.search?.provider;
|
||||
if (typeof provider !== "string") {
|
||||
return next;
|
||||
}
|
||||
const providerEntry = resolveSearchProviderEntry(original, provider);
|
||||
if (!providerEntry?.pluginId) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const pluginId = providerEntry.pluginId;
|
||||
const originalPluginEntry = (
|
||||
original.plugins?.entries as Record<string, Record<string, unknown>> | undefined
|
||||
)?.[pluginId];
|
||||
const resultPluginEntry = (
|
||||
next.plugins?.entries as Record<string, Record<string, unknown>> | undefined
|
||||
)?.[pluginId];
|
||||
|
||||
const nextPlugins = { ...next.plugins } as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(original.plugins?.allow)) {
|
||||
nextPlugins.allow = [...original.plugins.allow];
|
||||
} else {
|
||||
delete nextPlugins.allow;
|
||||
}
|
||||
|
||||
if (resultPluginEntry || originalPluginEntry) {
|
||||
const nextEntries = {
|
||||
...(nextPlugins.entries as Record<string, Record<string, unknown>> | undefined),
|
||||
};
|
||||
const patchedEntry = { ...resultPluginEntry };
|
||||
if (typeof originalPluginEntry?.enabled === "boolean") {
|
||||
patchedEntry.enabled = originalPluginEntry.enabled;
|
||||
} else {
|
||||
delete patchedEntry.enabled;
|
||||
}
|
||||
nextEntries[pluginId] = patchedEntry;
|
||||
nextPlugins.entries = nextEntries;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
plugins: nextPlugins as OpenClawConfig["plugins"],
|
||||
};
|
||||
}
|
||||
|
||||
export type SetupSearchOptions = {
|
||||
@@ -169,6 +261,19 @@ export async function setupSearch(
|
||||
prompter: WizardPrompter,
|
||||
opts?: SetupSearchOptions,
|
||||
): Promise<OpenClawConfig> {
|
||||
const providerOptions = resolveSearchProviderOptions(config);
|
||||
if (providerOptions.length === 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
"No web search providers are currently available under this plugin policy.",
|
||||
"Enable plugins or remove deny rules, then run setup again.",
|
||||
"Docs: https://docs.openclaw.ai/tools/web",
|
||||
].join("\n"),
|
||||
"Web search",
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Web search lets your agent look things up online.",
|
||||
@@ -180,23 +285,21 @@ export async function setupSearch(
|
||||
|
||||
const existingProvider = config.tools?.web?.search?.provider;
|
||||
|
||||
const options = SEARCH_PROVIDER_OPTIONS.map((entry) => {
|
||||
const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry);
|
||||
const options = providerOptions.map((entry) => {
|
||||
const configured = hasExistingKey(config, entry.id) || hasKeyInEnv(entry);
|
||||
const hint = configured ? `${entry.hint} · configured` : entry.hint;
|
||||
return { value: entry.value, label: entry.label, hint };
|
||||
return { value: entry.id, label: entry.label, hint };
|
||||
});
|
||||
|
||||
const defaultProvider: SearchProvider = (() => {
|
||||
if (existingProvider && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === existingProvider)) {
|
||||
if (existingProvider && providerOptions.some((entry) => entry.id === existingProvider)) {
|
||||
return existingProvider;
|
||||
}
|
||||
const detected = SEARCH_PROVIDER_OPTIONS.find(
|
||||
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),
|
||||
);
|
||||
const detected = providerOptions.find((e) => hasExistingKey(config, e.id) || hasKeyInEnv(e));
|
||||
if (detected) {
|
||||
return detected.value;
|
||||
return detected.id;
|
||||
}
|
||||
return SEARCH_PROVIDER_OPTIONS[0].value;
|
||||
return providerOptions[0].id;
|
||||
})();
|
||||
|
||||
const choice = await prompter.select({
|
||||
@@ -216,7 +319,11 @@ export async function setupSearch(
|
||||
return config;
|
||||
}
|
||||
|
||||
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
|
||||
const entry =
|
||||
resolveSearchProviderEntry(config, choice) ?? providerOptions.find((e) => e.id === choice);
|
||||
if (!entry) {
|
||||
return config;
|
||||
}
|
||||
const existingKey = resolveExistingKey(config, choice);
|
||||
const keyConfigured = hasExistingKey(config, choice);
|
||||
const envAvailable = hasKeyInEnv(entry);
|
||||
@@ -224,16 +331,16 @@ export async function setupSearch(
|
||||
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
|
||||
const result = existingKey
|
||||
? applySearchKey(config, choice, existingKey)
|
||||
: applyProviderOnly(config, choice);
|
||||
: applySearchProviderSelection(config, choice);
|
||||
return preserveDisabledState(config, result);
|
||||
}
|
||||
|
||||
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
|
||||
if (useSecretRefMode) {
|
||||
if (keyConfigured) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
||||
return preserveDisabledState(config, applySearchProviderSelection(config, choice));
|
||||
}
|
||||
const ref = buildSearchEnvRef(choice);
|
||||
const ref = buildSearchEnvRef(config, choice);
|
||||
await prompter.note(
|
||||
[
|
||||
"Secret references enabled — OpenClaw will store a reference instead of the API key.",
|
||||
@@ -257,7 +364,7 @@ export async function setupSearch(
|
||||
|
||||
const key = keyInput?.trim() ?? "";
|
||||
if (key) {
|
||||
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
|
||||
const secretInput = resolveSearchSecretInput(config, choice, key, opts?.secretInputMode);
|
||||
return applySearchKey(config, choice, secretInput);
|
||||
}
|
||||
|
||||
@@ -266,7 +373,7 @@ export async function setupSearch(
|
||||
}
|
||||
|
||||
if (keyConfigured || envAvailable) {
|
||||
return preserveDisabledState(config, applyProviderOnly(config, choice));
|
||||
return preserveDisabledState(config, applySearchProviderSelection(config, choice));
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
|
||||
Reference in New Issue
Block a user