Merge dff6f2976285f8e745d7c36941c77e7a3d964d41 into 5bb5d7dab4b29e68b15bb7665d0736f46499a35c

This commit is contained in:
MaxxxDong 2026-03-21 05:31:03 +00:00 committed by GitHub
commit 1522863d5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 381 additions and 94 deletions

View File

@ -101,15 +101,19 @@ vi.mock("../../runtime.js", () => ({
}),
}));
vi.mock("../accounts.js", () => ({
resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set<string>()),
resolveMatrixAccount: () => ({
accountId: "default",
config: {
dm: {},
},
}),
}));
vi.mock("../accounts.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../accounts.js")>();
return {
...actual,
resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set<string>()),
resolveMatrixAccount: () => ({
accountId: "default",
config: {
dm: {},
},
}),
};
});
vi.mock("../active-client.js", () => ({
setActiveMatrixClient: hoisted.setActiveMatrixClient,

View File

@ -103,16 +103,32 @@ function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
type FileReferencePatterns = {
fileReferencePattern: RegExp;
orphanedTldPattern: RegExp;
};
let cachedFileReferencePatterns: FileReferencePatterns | null = null;
function getFileReferencePatterns(): FileReferencePatterns {
if (cachedFileReferencePatterns) {
return cachedFileReferencePatterns;
}
const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
cachedFileReferencePatterns = {
fileReferencePattern: new RegExp(
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${fileExtensionsPattern}))(?=$|[^a-zA-Z0-9_\\-/])`,
"gi",
),
orphanedTldPattern: new RegExp(
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${fileExtensionsPattern}))(?=[^a-zA-Z0-9/]|$)`,
"g",
),
};
return cachedFileReferencePatterns;
}
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
const FILE_REFERENCE_PATTERN = new RegExp(
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
"gi",
);
const ORPHANED_TLD_PATTERN = new RegExp(
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
"g",
);
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
@ -134,8 +150,9 @@ function wrapSegmentFileRefs(
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
return text;
}
const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
const { fileReferencePattern, orphanedTldPattern } = getFileReferencePatterns();
const wrappedStandalone = text.replace(fileReferencePattern, wrapStandaloneFileRef);
return wrappedStandalone.replace(orphanedTldPattern, (match, prefix: string, tld: string) =>
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
);
}

View File

@ -7,7 +7,7 @@ const defaultGatewayMock = async (
method: string,
_opts: unknown,
params?: unknown,
_timeoutMs?: number,
_extra?: { expectFinal?: boolean; progress?: boolean; quiet?: boolean },
) => {
if (method === "cron.status") {
return { enabled: true };
@ -20,8 +20,12 @@ vi.mock("./gateway-rpc.js", async () => {
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
return {
...actual,
callGatewayFromCli: (method: string, opts: unknown, params?: unknown, extra?: unknown) =>
callGatewayFromCli(method, opts, params, extra as number | undefined),
callGatewayFromCli: (
method: string,
opts: unknown,
params?: unknown,
extra?: { expectFinal?: boolean; progress?: boolean; quiet?: boolean },
) => callGatewayFromCli(method, opts, params, extra),
};
});
@ -266,6 +270,48 @@ describe("cron cli", () => {
expect(params?.delivery?.mode).toBe("announce");
});
it("skips cron.status helper in json mode", async () => {
await runCronCommand([
"cron",
"add",
"--name",
"Json add",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
"--json",
]);
const statusCalls = callGatewayFromCli.mock.calls.filter((call) => call[0] === "cron.status");
expect(statusCalls).toHaveLength(0);
});
it("runs cron.status helper quietly outside json mode", async () => {
await runCronCommand([
"cron",
"add",
"--name",
"Quiet helper",
"--cron",
"* * * * *",
"--session",
"isolated",
"--message",
"hello",
"--expect-final",
]);
const statusCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.status");
expect(statusCall?.[3]).toEqual({
progress: false,
quiet: true,
expectFinal: false,
});
});
it("infers sessionTarget from payload when --session is omitted", async () => {
await runCronCommand([
"cron",

View File

@ -22,8 +22,20 @@ export function handleCronCliError(err: unknown) {
}
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
if (opts?.json === true) {
return;
}
try {
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
const res = (await callGatewayFromCli(
"cron.status",
opts,
{},
{
progress: false,
quiet: true,
expectFinal: false,
},
)) as {
enabled?: boolean;
storePath?: string;
};

View File

@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGateway = vi.fn();
const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn());
vi.mock("../gateway/call.js", () => ({
callGateway,
}));
vi.mock("./progress.js", () => ({
withProgress,
}));
const { callGatewayFromCli } = await import("./gateway-rpc.js");
describe("callGatewayFromCli", () => {
beforeEach(() => {
callGateway.mockReset();
withProgress.mockClear();
});
it("uses probe mode for quiet calls", async () => {
callGateway.mockResolvedValueOnce({ ok: true });
await callGatewayFromCli("cron.status", { timeout: "30000" }, {}, { quiet: true });
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "cron.status",
mode: "probe",
clientName: "cli",
}),
);
});
it("retries transient transport errors with probe mode after the first CLI attempt", async () => {
callGateway
.mockRejectedValueOnce(new Error("gateway closed (1000 normal closure): no close reason"))
.mockResolvedValueOnce({ ok: true });
await callGatewayFromCli("cron.add", { timeout: "30000" }, { name: "job" });
expect(callGateway).toHaveBeenCalledTimes(2);
expect(callGateway.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ method: "cron.add", mode: "cli" }),
);
expect(callGateway.mock.calls[1]?.[0]).toEqual(
expect.objectContaining({ method: "cron.add", mode: "probe" }),
);
});
it("does not retry non-transport errors", async () => {
callGateway.mockRejectedValueOnce(new Error("active gateway does not support required method"));
await expect(
callGatewayFromCli("cron.add", { timeout: "30000" }, { name: "job" }),
).rejects.toThrow("active gateway does not support required method");
expect(callGateway).toHaveBeenCalledTimes(1);
});
it("stops after three transient failures", async () => {
callGateway.mockRejectedValue(
new Error("gateway closed (1006 abnormal closure (no close frame)): no close reason"),
);
await expect(
callGatewayFromCli("cron.add", { timeout: "30000" }, { name: "job" }),
).rejects.toThrow("gateway closed (1006 abnormal closure (no close frame)): no close reason");
expect(callGateway).toHaveBeenCalledTimes(3);
expect(callGateway.mock.calls.map((call) => call[0]?.mode)).toEqual(["cli", "probe", "probe"]);
});
});

View File

@ -19,29 +19,51 @@ export function addGatewayClientOptions(cmd: Command) {
.option("--expect-final", "Wait for final response (agent)", false);
}
function isRetryableCliTransportError(err: unknown): boolean {
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
return (
message.includes("gateway closed (1000") ||
message.includes("gateway closed (1006") ||
message.includes("gateway timeout") ||
message.includes("connect challenge timeout")
);
}
export async function callGatewayFromCli(
method: string,
opts: GatewayRpcOpts,
params?: unknown,
extra?: { expectFinal?: boolean; progress?: boolean },
extra?: { expectFinal?: boolean; progress?: boolean; quiet?: boolean },
) {
const showProgress = extra?.progress ?? opts.json !== true;
const quiet = extra?.quiet === true;
const baseMode = quiet ? GATEWAY_CLIENT_MODES.PROBE : GATEWAY_CLIENT_MODES.CLI;
return await withProgress(
{
label: `Gateway ${method}`,
indeterminate: true,
enabled: showProgress,
},
async () =>
await callGateway({
url: opts.url,
token: opts.token,
method,
params,
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal),
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
}),
async () => {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await callGateway({
url: opts.url,
token: opts.token,
method,
params,
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal),
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: attempt === 0 ? baseMode : GATEWAY_CLIENT_MODES.PROBE,
});
} catch (err) {
if (attempt === 2 || !isRetryableCliTransportError(err)) {
throw err;
}
}
}
throw new Error(`gateway retries exhausted for ${method}`);
},
);
}

View File

@ -49,6 +49,7 @@ describe("hooks install (e2e)", () => {
{
name: "@acme/hello-hooks",
version: "0.0.0",
type: "module",
openclaw: { hooks: ["./hooks/hello-hook"] },
},
null,

View File

@ -127,15 +127,29 @@ function loadHookFromDir(params: {
// keep the discovered path when realpath is unavailable
}
let hookFilePath = hookMdPath;
try {
hookFilePath = fs.realpathSync.native(hookMdPath);
} catch {
hookFilePath = hookMdPath;
}
let resolvedHandlerPath = handlerPath;
try {
resolvedHandlerPath = fs.realpathSync.native(handlerPath);
} catch {
resolvedHandlerPath = handlerPath;
}
return {
hook: {
name,
description,
source: params.source,
pluginId: params.pluginId,
filePath: hookMdPath,
filePath: hookFilePath,
baseDir,
handlerPath,
handlerPath: resolvedHandlerPath,
},
frontmatter,
};

View File

@ -1,6 +1,7 @@
import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
import { lookup as dnsLookup } from "node:dns/promises";
import { Agent, EnvHttpProxyAgent, ProxyAgent, type Dispatcher } from "undici";
import * as undici from "undici";
import type { Dispatcher } from "undici";
import {
extractEmbeddedIpv4FromIpv6,
isBlockedSpecialUseIpv4Address,
@ -403,13 +404,19 @@ export function createPinnedDispatcher(
const lookup = resolvePinnedDispatcherLookup(pinned, policy?.pinnedHostname, ssrfPolicy);
if (!policy || policy.mode === "direct") {
return new Agent({
if (typeof undici.Agent !== "function") {
return {
close: async () => undefined,
destroy: () => undefined,
} as unknown as Dispatcher;
}
return new undici.Agent({
connect: withPinnedLookup(lookup, policy?.connect),
});
}
if (policy.mode === "env-proxy") {
return new EnvHttpProxyAgent({
return new undici.EnvHttpProxyAgent({
connect: withPinnedLookup(lookup, policy.connect),
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
});
@ -417,9 +424,9 @@ export function createPinnedDispatcher(
const proxyUrl = policy.proxyUrl.trim();
if (!policy.proxyTls) {
return new ProxyAgent(proxyUrl);
return new undici.ProxyAgent(proxyUrl);
}
return new ProxyAgent({
return new undici.ProxyAgent({
uri: proxyUrl,
proxyTls: { ...policy.proxyTls },
});

View File

@ -206,17 +206,17 @@ describe("runMessageAction context isolation", () => {
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
target: "channel:C12345678",
message: "hi",
},
toolContext: { currentChannelId: "C12345678" },
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
},
{
name: "accepts legacy to parameter for send",
cfg: slackConfig,
actionParams: {
channel: "slack",
to: "#C12345678",
to: "channel:C12345678",
message: "hi",
},
},
@ -234,7 +234,7 @@ describe("runMessageAction context isolation", () => {
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
target: "channel:C12345678",
media: "https://example.com/note.ogg",
},
toolContext: { currentChannelId: "C12345678" },
@ -244,7 +244,7 @@ describe("runMessageAction context isolation", () => {
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
target: "channel:C12345678",
message: "hi",
pollMulti: false,
pollAnonymous: false,
@ -268,7 +268,7 @@ describe("runMessageAction context isolation", () => {
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
target: "channel:C12345678",
},
toolContext: { currentChannelId: "C12345678" },
}),
@ -306,7 +306,7 @@ describe("runMessageAction context isolation", () => {
cfg: slackConfig,
actionParams: {
channel: "slack",
target: "#C12345678",
target: "channel:C12345678",
blocks: [{ type: "divider" }],
},
toolContext: { currentChannelId: "C12345678" },

View File

@ -477,7 +477,6 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
message,
preferComponents: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
if (
!hasReplyPayloadContent(

View File

@ -1,4 +1,3 @@
import { loadConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/config.js";
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
import {
@ -10,9 +9,35 @@ import {
type SessionRef,
type SessionStateValue,
} from "./diagnostic-session-state.js";
import { createSubsystemLogger } from "./subsystem.js";
import { createSubsystemLogger, type SubsystemLogger } from "./subsystem.js";
const diag = createSubsystemLogger("diagnostic");
let diagnosticLoggerInstance: SubsystemLogger | null = null;
let cachedLoadedDiagnosticConfig: OpenClawConfig | undefined;
let diagnosticConfigRefreshPromise: Promise<void> | null = null;
function getDiagnosticLogger(): SubsystemLogger {
diagnosticLoggerInstance ??= createSubsystemLogger("diagnostic");
return diagnosticLoggerInstance;
}
const diag = new Proxy({} as SubsystemLogger, {
get(_target, prop, receiver) {
return Reflect.get(getDiagnosticLogger() as object, prop, receiver);
},
});
function refreshDiagnosticConfigSnapshot(): void {
diagnosticConfigRefreshPromise ??= import("../config/config.js")
.then(({ loadConfig }) => {
cachedLoadedDiagnosticConfig = loadConfig();
})
.catch(() => {
cachedLoadedDiagnosticConfig = undefined;
})
.finally(() => {
diagnosticConfigRefreshPromise = null;
});
}
const webhookStats = {
received: 0,
@ -335,13 +360,9 @@ export function startDiagnosticHeartbeat(config?: OpenClawConfig) {
return;
}
heartbeatInterval = setInterval(() => {
let heartbeatConfig = config;
if (!heartbeatConfig) {
try {
heartbeatConfig = loadConfig();
} catch {
heartbeatConfig = undefined;
}
let heartbeatConfig = config ?? cachedLoadedDiagnosticConfig;
if (!heartbeatConfig && !diagnosticConfigRefreshPromise) {
refreshDiagnosticConfigSnapshot();
}
const stuckSessionWarnMs = resolveStuckSessionWarnMs(heartbeatConfig);
const now = Date.now();
@ -427,6 +448,9 @@ export function resetDiagnosticStateForTest(): void {
webhookStats.errors = 0;
webhookStats.lastReceived = 0;
lastActivityAt = 0;
cachedLoadedDiagnosticConfig = undefined;
diagnosticConfigRefreshPromise = null;
diagnosticLoggerInstance = null;
stopDiagnosticHeartbeat();
}

View File

@ -73,17 +73,16 @@ export type {
TelegramTopicConfig,
TtsConfig,
} from "../config/types.js";
export { resolveStorePath } from "../config/sessions/paths.js";
export { resolveSessionKey } from "../config/sessions/session-key.js";
export {
loadSessionStore,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
resolveSessionKey,
resolveStorePath,
resolveSessionStoreEntry,
updateLastRoute,
updateSessionStore,
type SessionResetMode,
type SessionScope,
} from "../config/sessions.js";
} from "../config/sessions/store.js";
export { resolveGroupSessionKey } from "../config/sessions/group.js";
export {
evaluateSessionFreshness,
@ -91,6 +90,7 @@ export {
resolveSessionResetPolicy,
resolveSessionResetType,
resolveThreadFlag,
type SessionResetMode,
} from "../config/sessions/reset.js";
export { resolveSessionStoreEntry } from "../config/sessions/store.js";
export type { SessionScope } from "../config/sessions/types.js";
export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";

View File

@ -44,6 +44,14 @@ const MANIFEST_PATH_BY_FORMAT: Record<PluginBundleFormat, string> = {
};
const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
function canonicalizeExistingDir(dir: string): string {
try {
return fs.realpathSync.native(dir);
} catch {
return dir;
}
}
function readPluginJsonObject(params: {
rootDir: string;
relativePath: string;
@ -122,11 +130,22 @@ function expandBundleRootPlaceholders(value: string, rootDir: string): string {
}
function normalizeBundlePath(targetPath: string): string {
return path.normalize(path.resolve(targetPath));
return canonicalizeExistingDir(path.normalize(path.resolve(targetPath)));
}
function normalizeExpandedAbsolutePath(value: string): string {
return path.isAbsolute(value) ? path.normalize(value) : value;
return path.isAbsolute(value) ? canonicalizeExistingDir(path.normalize(value)) : value;
}
function resolveBundlePath(value: string, rootDir: string, baseDir: string): string {
const expanded = expandBundleRootPlaceholders(value, rootDir);
if (path.isAbsolute(expanded)) {
return canonicalizeExistingDir(path.normalize(expanded));
}
if (isExplicitRelativePath(expanded)) {
return canonicalizeExistingDir(path.resolve(baseDir, expanded));
}
return expanded;
}
function absolutizeBundleMcpServer(params: {
@ -134,32 +153,30 @@ function absolutizeBundleMcpServer(params: {
baseDir: string;
server: BundleMcpServerConfig;
}): BundleMcpServerConfig {
const rootDir = canonicalizeExistingDir(params.rootDir);
const baseDir = canonicalizeExistingDir(params.baseDir);
const next: BundleMcpServerConfig = { ...params.server };
if (typeof next.cwd !== "string" && typeof next.workingDirectory !== "string") {
next.cwd = params.baseDir;
next.cwd = baseDir;
}
const command = next.command;
if (typeof command === "string") {
const expanded = expandBundleRootPlaceholders(command, params.rootDir);
next.command = isExplicitRelativePath(expanded)
? path.resolve(params.baseDir, expanded)
: normalizeExpandedAbsolutePath(expanded);
next.command = resolveBundlePath(command, rootDir, baseDir);
}
const cwd = next.cwd;
if (typeof cwd === "string") {
const expanded = expandBundleRootPlaceholders(cwd, params.rootDir);
next.cwd = path.isAbsolute(expanded) ? expanded : path.resolve(params.baseDir, expanded);
next.cwd = resolveBundlePath(cwd, rootDir, baseDir);
}
const workingDirectory = next.workingDirectory;
if (typeof workingDirectory === "string") {
const expanded = expandBundleRootPlaceholders(workingDirectory, params.rootDir);
const expanded = expandBundleRootPlaceholders(workingDirectory, rootDir);
next.workingDirectory = path.isAbsolute(expanded)
? path.normalize(expanded)
: path.resolve(params.baseDir, expanded);
? canonicalizeExistingDir(path.normalize(expanded))
: canonicalizeExistingDir(path.resolve(baseDir, expanded));
}
if (Array.isArray(next.args)) {
@ -167,11 +184,11 @@ function absolutizeBundleMcpServer(params: {
if (typeof entry !== "string") {
return entry;
}
const expanded = expandBundleRootPlaceholders(entry, params.rootDir);
const expanded = expandBundleRootPlaceholders(entry, rootDir);
if (!isExplicitRelativePath(expanded)) {
return normalizeExpandedAbsolutePath(expanded);
}
return path.resolve(params.baseDir, expanded);
return canonicalizeExistingDir(path.resolve(baseDir, expanded));
});
}
@ -180,7 +197,7 @@ function absolutizeBundleMcpServer(params: {
Object.entries(next.env).map(([key, value]) => [
key,
typeof value === "string"
? normalizeExpandedAbsolutePath(expandBundleRootPlaceholders(value, params.rootDir))
? normalizeExpandedAbsolutePath(expandBundleRootPlaceholders(value, rootDir))
: value,
]),
);

View File

@ -1,9 +1,14 @@
import { loadConfig, writeConfigFile } from "../../config/config.js";
import * as configRuntime from "../../config/config.js";
import type { PluginRuntime } from "./types.js";
export function createRuntimeConfig(): PluginRuntime["config"] {
return {
loadConfig,
writeConfigFile,
loadConfig: configRuntime.loadConfig,
writeConfigFile:
typeof configRuntime.writeConfigFile === "function"
? configRuntime.writeConfigFile
: async () => {
throw new Error("writeConfigFile is unavailable in the current runtime");
},
};
}

View File

@ -200,6 +200,7 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string)
}
if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily");
setPathCreateStrict(config, ["plugins", "entries", "tavily", "enabled"], true);
}
return config;
}

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js";
import {
clearConfigCache,
@ -10,6 +10,7 @@ import {
writeConfigFile,
} from "../config/config.js";
import { withTempHome } from "../config/home-env.test-harness.js";
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
@ -18,6 +19,18 @@ import {
prepareSecretsRuntimeSnapshot,
} from "./runtime.js";
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => [createGeminiTestProvider()]),
}));
vi.mock("../plugins/web-search-providers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/web-search-providers.js")>();
return {
...actual,
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
};
});
const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const;
const allowInsecureTempSecretFile = process.platform === "win32";
@ -25,6 +38,44 @@ function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
function createGeminiTestProvider(): PluginWebSearchProviderEntry {
return {
pluginId: "google",
id: "gemini",
label: "gemini",
hint: "gemini test provider",
envVars: ["GEMINI_API_KEY"],
placeholder: "gemini-...",
signupUrl: "https://example.com/gemini",
autoDetectOrder: 20,
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const providerConfig =
searchConfig?.gemini && typeof searchConfig.gemini === "object"
? (searchConfig.gemini as { apiKey?: unknown })
: undefined;
return providerConfig?.apiKey ?? searchConfig?.apiKey;
},
setCredentialValue: (searchConfigTarget, value) => {
const providerConfig = (searchConfigTarget.gemini ??= {}) as { apiKey?: unknown };
providerConfig.apiKey = value;
},
getConfiguredCredentialValue: (config) =>
(config?.plugins?.entries?.google?.config as { webSearch?: { apiKey?: unknown } })?.webSearch
?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
const plugins = (configTarget.plugins ??= {}) as { entries?: Record<string, unknown> };
const entries = (plugins.entries ??= {});
const google = (entries.google ??= {}) as { config?: Record<string, unknown> };
const pluginConfig = (google.config ??= {});
const webSearch = (pluginConfig.webSearch ??= {}) as { apiKey?: unknown };
webSearch.apiKey = value;
},
createTool: () => null,
};
}
function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore {
return {
version: 1,

View File

@ -31,14 +31,6 @@
"resolvedPath": "extensions/imessage/runtime-api.js",
"reason": "imports extension-owned file from src/plugins"
},
{
"file": "src/plugins/runtime/runtime-matrix.ts",
"line": 4,
"kind": "import",
"specifier": "../../../extensions/matrix/runtime-api.js",
"resolvedPath": "extensions/matrix/runtime-api.js",
"reason": "imports extension-owned file from src/plugins"
},
{
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
"line": 10,