diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 1e7db90d4df..3cce319a1dc 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -101,15 +101,19 @@ vi.mock("../../runtime.js", () => ({ }), })); -vi.mock("../accounts.js", () => ({ - resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set()), - resolveMatrixAccount: () => ({ - accountId: "default", - config: { - dm: {}, - }, - }), -})); +vi.mock("../accounts.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set()), + resolveMatrixAccount: () => ({ + accountId: "default", + config: { + dm: {}, + }, + }), + }; +}); vi.mock("../active-client.js", () => ({ setActiveMatrixClient: hoisted.setActiveMatrixClient, diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 4d14f179b2f..dcaeca8a37c 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -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 = /]*>\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}${escapeHtml(tld)}`, ); } diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index a6b20ca5b3d..c0fd2ddbe30 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -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("./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", diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 3574a63ab27..2c4aa3d5e32 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -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; }; diff --git a/src/cli/gateway-rpc.test.ts b/src/cli/gateway-rpc.test.ts new file mode 100644 index 00000000000..301f23d612a --- /dev/null +++ b/src/cli/gateway-rpc.test.ts @@ -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) => 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"]); + }); +}); diff --git a/src/cli/gateway-rpc.ts b/src/cli/gateway-rpc.ts index feac3abcd2e..aa3e6ef53ed 100644 --- a/src/cli/gateway-rpc.ts +++ b/src/cli/gateway-rpc.ts @@ -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}`); + }, ); } diff --git a/src/hooks/hooks-install.test.ts b/src/hooks/hooks-install.test.ts index 98afa7319cc..002ff479508 100644 --- a/src/hooks/hooks-install.test.ts +++ b/src/hooks/hooks-install.test.ts @@ -49,6 +49,7 @@ describe("hooks install (e2e)", () => { { name: "@acme/hello-hooks", version: "0.0.0", + type: "module", openclaw: { hooks: ["./hooks/hello-hook"] }, }, null, diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index b4c2fa4a1f3..9b1386c9d51 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -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, }; diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index fd633fcb20d..ae0807e4bed 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -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 }, }); diff --git a/src/infra/outbound/message-action-runner.context.test.ts b/src/infra/outbound/message-action-runner.context.test.ts index 0819c2cfae1..c2bfab36729 100644 --- a/src/infra/outbound/message-action-runner.context.test.ts +++ b/src/infra/outbound/message-action-runner.context.test.ts @@ -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" }, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index d8d40cbe28c..cc5ecbd3d99 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -477,7 +477,6 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise | 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(); } diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index 3836f15508d..1f542f27368 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -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"; diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index c4e2f0651bb..a6881c6aa4b 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -44,6 +44,14 @@ const MANIFEST_PATH_BY_FORMAT: Record = { }; 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, ]), ); diff --git a/src/plugins/runtime/runtime-config.ts b/src/plugins/runtime/runtime-config.ts index c25646f830d..1e94b41604a 100644 --- a/src/plugins/runtime/runtime-config.ts +++ b/src/plugins/runtime/runtime-config.ts @@ -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"); + }, }; } diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index bce2911b88f..43237db5693 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -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; } diff --git a/src/secrets/runtime.integration.test.ts b/src/secrets/runtime.integration.test.ts index f39607cbe80..78191ff15cf 100644 --- a/src/secrets/runtime.integration.test.ts +++ b/src/secrets/runtime.integration.test.ts @@ -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(); + 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 }; + const entries = (plugins.entries ??= {}); + const google = (entries.google ??= {}) as { config?: Record }; + 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, diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 0894fe0d5b5..ead171321f9 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -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,