diff --git a/extensions/discord/src/audit.test.ts b/extensions/discord/src/audit.test.ts index c1b276f320b..ffa7b370c5a 100644 --- a/extensions/discord/src/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("./send.js", () => ({ + addRoleDiscord: vi.fn(), fetchChannelPermissionsDiscord: vi.fn(), })); diff --git a/extensions/discord/src/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts index 6d0405d756c..1d4bb1d0522 100644 --- a/extensions/discord/src/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -3,16 +3,57 @@ import { vi } from "vitest"; export const sendMock: MockFn = vi.fn(); export const reactMock: MockFn = vi.fn(); +export const recordInboundSessionMock: MockFn = vi.fn(); export const updateLastRouteMock: MockFn = vi.fn(); export const dispatchMock: MockFn = vi.fn(); export const readAllowFromStoreMock: MockFn = vi.fn(); export const upsertPairingRequestMock: MockFn = vi.fn(); vi.mock("./send.js", () => ({ - sendMessageDiscord: (...args: unknown[]) => sendMock(...args), + addRoleDiscord: vi.fn(), + banMemberDiscord: vi.fn(), + createChannelDiscord: vi.fn(), + createScheduledEventDiscord: vi.fn(), + createThreadDiscord: vi.fn(), + deleteChannelDiscord: vi.fn(), + deleteMessageDiscord: vi.fn(), + editChannelDiscord: vi.fn(), + editMessageDiscord: vi.fn(), + fetchChannelInfoDiscord: vi.fn(), + fetchChannelPermissionsDiscord: vi.fn(), + fetchMemberInfoDiscord: vi.fn(), + fetchMessageDiscord: vi.fn(), + fetchReactionsDiscord: vi.fn(), + fetchRoleInfoDiscord: vi.fn(), + fetchVoiceStatusDiscord: vi.fn(), + hasAnyGuildPermissionDiscord: vi.fn(), + kickMemberDiscord: vi.fn(), + listGuildChannelsDiscord: vi.fn(), + listGuildEmojisDiscord: vi.fn(), + listPinsDiscord: vi.fn(), + listScheduledEventsDiscord: vi.fn(), + listThreadsDiscord: vi.fn(), + moveChannelDiscord: vi.fn(), + pinMessageDiscord: vi.fn(), reactMessageDiscord: async (...args: unknown[]) => { reactMock(...args); }, + readMessagesDiscord: vi.fn(), + removeChannelPermissionDiscord: vi.fn(), + removeOwnReactionsDiscord: vi.fn(), + removeReactionDiscord: vi.fn(), + removeRoleDiscord: vi.fn(), + searchMessagesDiscord: vi.fn(), + sendDiscordComponentMessage: vi.fn(), + sendMessageDiscord: (...args: unknown[]) => sendMock(...args), + sendPollDiscord: vi.fn(), + sendStickerDiscord: vi.fn(), + sendVoiceMessageDiscord: vi.fn(), + setChannelPermissionDiscord: vi.fn(), + timeoutMemberDiscord: vi.fn(), + unpinMessageDiscord: vi.fn(), + uploadEmojiDiscord: vi.fn(), + uploadStickerDiscord: vi.fn(), })); vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { @@ -36,12 +77,27 @@ function createPairingStoreMocks() { }; } -vi.mock("openclaw/plugin-sdk/conversation-runtime", () => createPairingStoreMocks()); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ...createPairingStoreMocks(), + }; +}); + +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), + }; +}); vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + readSessionUpdatedAt: vi.fn(() => undefined), resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), resolveSessionKey: vi.fn(), diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index fc04211a38f..e419706b30b 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -68,6 +68,7 @@ const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt; const resolveStorePath = configSessionsMocks.resolveStorePath; vi.mock("../send.js", () => ({ + addRoleDiscord: vi.fn(), reactMessageDiscord: sendMocks.reactMessageDiscord, removeReactionDiscord: sendMocks.removeReactionDiscord, })); diff --git a/extensions/discord/src/monitor/message-utils.test.ts b/extensions/discord/src/monitor/message-utils.test.ts index 0a29fc5b0ab..d0e90fb65b1 100644 --- a/extensions/discord/src/monitor/message-utils.test.ts +++ b/extensions/discord/src/monitor/message-utils.test.ts @@ -9,9 +9,13 @@ vi.mock("../../../../src/media/fetch.js", () => ({ fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args), })); -vi.mock("../../../../src/media/store.js", () => ({ - saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), -})); +vi.mock("../../../../src/media/store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), + }; +}); vi.mock("../../../../src/globals.js", () => ({ logVerbose: () => {}, diff --git a/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts index eb085235da7..ac5ee63ccd4 100644 --- a/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.discord-api.test.ts @@ -25,6 +25,7 @@ vi.mock("../client.js", () => ({ })); vi.mock("../send.js", () => ({ + addRoleDiscord: vi.fn(), sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscord(...args), sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscord(...args), })); diff --git a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts index 237cc6b8081..884cf846fb9 100644 --- a/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts +++ b/extensions/discord/src/monitor/thread-bindings.lifecycle.test.ts @@ -42,6 +42,7 @@ const hoisted = vi.hoisted(() => { }); vi.mock("../send.js", () => ({ + addRoleDiscord: vi.fn(), sendMessageDiscord: hoisted.sendMessageDiscord, sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord, })); diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index 77d76fb2dfb..25fafd07baf 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -10,6 +10,8 @@ import { } from "../api.js"; import type { OpenClawPluginApi } from "../api.js"; +const AjvCtor = Ajv as unknown as typeof import("ajv").default; + function stripCodeFences(s: string): string { const trimmed = s.trim(); const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); @@ -214,7 +216,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { // oxlint-disable-next-line typescript/no-explicit-any const schema = (params as any).schema as unknown; if (schema && typeof schema === "object" && !Array.isArray(schema)) { - const ajv = new Ajv.default({ allErrors: true, strict: false }); + const ajv = new AjvCtor({ allErrors: true, strict: false }); // oxlint-disable-next-line typescript/no-explicit-any const validate = ajv.compile(schema as any); const ok = validate(parsed); diff --git a/extensions/matrix/src/runtime-api.test.ts b/extensions/matrix/src/runtime-api.test.ts index 97b6ffcbda4..680143f429c 100644 --- a/extensions/matrix/src/runtime-api.test.ts +++ b/extensions/matrix/src/runtime-api.test.ts @@ -14,8 +14,8 @@ describe("matrix runtime-api", () => { expect(typeof runtimeApi.buildSecretInputSchema).toBe("function"); }); - it("does not re-export setup entrypoints that create extension cycles", () => { - expect("matrixSetupWizard" in runtimeApi).toBe(false); - expect("matrixSetupAdapter" in runtimeApi).toBe(false); + it("re-exports setup entrypoints from the bundled plugin-sdk surface", () => { + expect(typeof runtimeApi.matrixSetupWizard).toBe("object"); + expect(typeof runtimeApi.matrixSetupAdapter).toBe("object"); }); }); diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index a9acbd52c40..b43fac9cc87 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../runtime-api.js"; -import { buildModelsProviderData } from "../../runtime-api.js"; import { buildMattermostAllowedModelRefs, parseMattermostModelPickerContext, @@ -145,7 +144,17 @@ describe("Mattermost model picker", () => { ], }, }; - const providerData = await buildModelsProviderData(cfg, "support"); + const providerData = { + byProvider: new Map>([ + ["anthropic", new Set(["claude-opus-4-5"])], + ["openai", new Set(["gpt-5"])], + ]), + providers: ["anthropic", "openai"], + resolvedDefault: { + provider: "openai", + model: "gpt-5", + }, + }; expect( resolveMattermostModelPickerCurrentModel({ diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index bcca049f4d7..6995e71320e 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -76,9 +76,13 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ - getReplyFromConfig: (...args: unknown[]) => replyMock(...args), -})); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getReplyFromConfig: (...args: unknown[]) => replyMock(...args), + }; +}); vi.mock("./send.js", () => ({ sendMessageSignal: (...args: unknown[]) => sendMock(...args), @@ -116,9 +120,13 @@ vi.mock("./daemon.js", async (importOriginal) => { }; }); -vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({ - waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args), -})); +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args), + }; +}); export function installSignalToolResultTestHooks() { beforeEach(() => { diff --git a/extensions/slack/src/send.upload.test.ts b/extensions/slack/src/send.upload.test.ts index 1ee3c76deac..dfecdc06089 100644 --- a/extensions/slack/src/send.upload.test.ts +++ b/extensions/slack/src/send.upload.test.ts @@ -22,7 +22,7 @@ vi.mock("../../../src/infra/net/fetch-guard.js", () => ({ }), })); -vi.mock("../../whatsapp/src/media.js", () => ({ +vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia: vi.fn(async () => ({ buffer: Buffer.from("fake-image"), contentType: "image/png", diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 177e045f9e8..46f8527725b 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -27,11 +27,18 @@ vi.mock("./bot/delivery.js", () => ({ })); vi.mock("./send.js", () => ({ + createForumTopicTelegram: vi.fn(), + deleteMessageTelegram: vi.fn(), + editForumTopicTelegram: vi.fn(), editMessageTelegram, + reactMessageTelegram: vi.fn(), + sendMessageTelegram: vi.fn(), + sendPollTelegram: vi.fn(), + sendStickerTelegram: vi.fn(), })); -vi.mock("../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadSessionStore, diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index f3707f87679..57659422c15 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -29,14 +29,18 @@ type MockWebListener = { export const TEST_NET_IP = "203.0.113.10"; -vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), - isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), - queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, -})); +vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), + isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: vi.fn(), + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, + }; +}); export async function rmDirWithRetries( dir: string, diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts index a370876f514..4ac29d20d71 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.last-route.test.ts @@ -1,13 +1,23 @@ import "./test-helpers.js"; -import fs from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js"; import { buildMentionConfig } from "./auto-reply/mentions.js"; import { createEchoTracker } from "./auto-reply/monitor/echo.js"; -import { awaitBackgroundTasks } from "./auto-reply/monitor/last-route.js"; import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js"; +const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn()); + +vi.mock("./auto-reply/monitor/last-route.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateLastRouteInBackground: (...args: unknown[]) => updateLastRouteInBackgroundMock(...args), + }; +}); + +const { awaitBackgroundTasks } = await import("./auto-reply/monitor/last-route.js"); + function makeCfg(storePath: string): OpenClawConfig { return { channels: { whatsapp: { allowFrom: ["*"] } }, @@ -86,13 +96,6 @@ function buildInboundMessage(params: { }; } -async function readStoredRoutes(storePath: string) { - return JSON.parse(await fs.readFile(storePath, "utf8")) as Record< - string, - { lastChannel?: string; lastTo?: string; lastAccountId?: string } - >; -} - describe("web auto-reply last-route", () => { installWebAutoReplyUnitTestHooks(); @@ -118,9 +121,12 @@ describe("web auto-reply last-route", () => { await awaitBackgroundTasks(backgroundTasks); - const stored = await readStoredRoutes(store.storePath); - expect(stored[mainSessionKey]?.lastChannel).toBe("whatsapp"); - expect(stored[mainSessionKey]?.lastTo).toBe("+1000"); + expect(updateLastRouteInBackgroundMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "whatsapp", + to: "+1000", + }), + ); await store.cleanup(); }); @@ -151,10 +157,13 @@ describe("web auto-reply last-route", () => { await awaitBackgroundTasks(backgroundTasks); - const stored = await readStoredRoutes(store.storePath); - expect(stored[groupSessionKey]?.lastChannel).toBe("whatsapp"); - expect(stored[groupSessionKey]?.lastTo).toBe("123@g.us"); - expect(stored[groupSessionKey]?.lastAccountId).toBe("work"); + expect(updateLastRouteInBackgroundMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "whatsapp", + to: "123@g.us", + accountId: "work", + }), + ); await store.cleanup(); }); diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts index a0022abaa8c..651074db852 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts @@ -41,9 +41,13 @@ vi.mock("../../../../src/config/config.js", () => ({ loadConfig: () => ({ agents: { defaults: {} }, session: {} }), })); -vi.mock("../../../../src/routing/session-key.js", () => ({ - normalizeMainKey: () => null, -})); +vi.mock("../../../../src/routing/session-key.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + normalizeMainKey: () => null, + }; +}); vi.mock("../../../../src/infra/heartbeat-visibility.js", () => ({ resolveHeartbeatVisibility: () => state.visibility, @@ -74,6 +78,42 @@ vi.mock("../../../../src/logging.js", () => ({ }), })); +vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveOAuthDir: () => "/tmp/openclaw-oauth", + }; +}); + +vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { + const actual = await importOriginal(); + const logger = { + child: () => logger, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { + ...actual, + createSubsystemLogger: () => logger, + }; +}); + +vi.mock("../auth-store.js", () => ({ + WA_WEB_AUTH_DIR: "/tmp/openclaw-oauth/whatsapp/default", + resolveDefaultWebAuthDir: () => "/tmp/openclaw-oauth/whatsapp/default", + hasWebCredsSync: () => false, + maybeRestoreCredsFromBackup: () => undefined, + webAuthExists: async () => false, + logoutWeb: async () => undefined, + readWebSelfId: () => null, + getWebAuthAgeMs: () => null, + logWebSelfId: () => undefined, + pickWebChannel: async () => undefined, +})); + vi.mock("./loggers.js", () => ({ whatsappHeartbeatLog: { info: (msg: string) => state.heartbeatInfoLogs.push(msg), @@ -87,6 +127,7 @@ vi.mock("../reconnect.js", () => ({ vi.mock("../send.js", () => ({ sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })), + sendReactionWhatsApp: vi.fn(async () => undefined), })); vi.mock("../session.js", () => ({ diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index bb2cd3d6fa0..6ce9a3e3f1c 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -1,3 +1,5 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; import { vi } from "vitest"; import type { MockBaileysSocket } from "../../../test/mocks/baileys.js"; import { createMockBaileys } from "../../../test/mocks/baileys.js"; @@ -41,6 +43,31 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { } return DEFAULT_CONFIG; }, + updateLastRoute: async (params: { + storePath: string; + sessionKey: string; + deliveryContext: { channel: string; to: string; accountId?: string }; + }) => { + const raw = await fs.readFile(params.storePath, "utf8").catch(() => "{}"); + const store = JSON.parse(raw) as Record>; + const current = store[params.sessionKey] ?? {}; + store[params.sessionKey] = { + ...current, + lastChannel: params.deliveryContext.channel, + lastTo: params.deliveryContext.to, + lastAccountId: params.deliveryContext.accountId, + }; + await fs.writeFile(params.storePath, JSON.stringify(store)); + }, + loadSessionStore: (storePath: string) => { + try { + return JSON.parse(fsSync.readFileSync(storePath, "utf8")) as Record; + } catch { + return {}; + } + }, + recordSessionMetaFromInbound: async () => undefined, + resolveStorePath: actual.resolveStorePath, }; }); @@ -82,6 +109,14 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { return mockModule; }); +vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveOAuthDir: () => "/tmp/openclaw-oauth", + }; +}); + vi.mock("@whiskeysockets/baileys", () => { const created = createMockBaileys(); (globalThis as Record)[Symbol.for("openclaw:lastSocket")] = diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 87e171d7ce4..3d1db95891a 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -289,7 +289,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect(result.targetStatesByPath["tools.web.search.gemini.apiKey"]).toBe("resolved_local"); expectGatewayUnavailableLocalFallbackDiagnostics(result); }); - }); + }, 300_000); it("falls back to local resolution for Firecrawl SecretRefs when gateway is unavailable", async () => { const envKey = "WEB_FETCH_FIRECRAWL_API_KEY_LOCAL_FALLBACK"; diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index bab49155c94..4e0c4d0c49a 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -97,6 +97,78 @@ function targetsRuntimeWebPath(path: string): boolean { return WEB_RUNTIME_SECRET_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)); } +function classifyRuntimeWebTargetPathState(params: { + config: OpenClawConfig; + path: string; +}): "active" | "inactive" | "unknown" { + if (params.path === "tools.web.fetch.firecrawl.apiKey") { + const fetch = params.config.tools?.web?.fetch; + return fetch?.enabled !== false && fetch?.firecrawl?.enabled !== false ? "active" : "inactive"; + } + + if (params.path === "tools.web.search.apiKey") { + return params.config.tools?.web?.search?.enabled !== false ? "active" : "inactive"; + } + + const match = /^tools\.web\.search\.([^.]+)\.apiKey$/.exec(params.path); + if (!match) { + return "unknown"; + } + + const search = params.config.tools?.web?.search; + if (search?.enabled === false) { + return "inactive"; + } + + const configuredProvider = + typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; + if (!configuredProvider) { + return "active"; + } + + return configuredProvider === match[1] ? "active" : "inactive"; +} + +function describeInactiveRuntimeWebTargetPath(params: { + config: OpenClawConfig; + path: string; +}): string | undefined { + if (params.path === "tools.web.fetch.firecrawl.apiKey") { + const fetch = params.config.tools?.web?.fetch; + if (fetch?.enabled === false) { + return "tools.web.fetch is disabled."; + } + if (fetch?.firecrawl?.enabled === false) { + return "tools.web.fetch.firecrawl.enabled is false."; + } + return undefined; + } + + if (params.path === "tools.web.search.apiKey") { + return params.config.tools?.web?.search?.enabled === false + ? "tools.web.search is disabled." + : undefined; + } + + const match = /^tools\.web\.search\.([^.]+)\.apiKey$/.exec(params.path); + if (!match) { + return undefined; + } + + const search = params.config.tools?.web?.search; + if (search?.enabled === false) { + return "tools.web.search is disabled."; + } + + const configuredProvider = + typeof search?.provider === "string" ? search.provider.trim().toLowerCase() : ""; + if (configuredProvider && configuredProvider !== match[1]) { + return `tools.web.search.provider is "${configuredProvider}".`; + } + + return undefined; +} + function targetsRuntimeWebResolution(params: { targetIds: ReadonlySet; allowedPaths?: ReadonlySet; @@ -285,6 +357,34 @@ async function resolveCommandSecretRefsLocally(params: { .filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path)) .map((warning) => warning.path), ); + const runtimeWebActivePaths = new Set(); + const runtimeWebInactiveDiagnostics: string[] = []; + for (const target of discoverConfigSecretTargetsByIds(sourceConfig, params.targetIds)) { + if (!targetsRuntimeWebPath(target.path)) { + continue; + } + if (params.allowedPaths && !params.allowedPaths.has(target.path)) { + continue; + } + const runtimeState = classifyRuntimeWebTargetPathState({ + config: sourceConfig, + path: target.path, + }); + if (runtimeState === "inactive") { + inactiveRefPaths.add(target.path); + const inactiveDetail = describeInactiveRuntimeWebTargetPath({ + config: sourceConfig, + path: target.path, + }); + if (inactiveDetail) { + runtimeWebInactiveDiagnostics.push(`${target.path}: ${inactiveDetail}`); + } + continue; + } + if (runtimeState === "active") { + runtimeWebActivePaths.add(target.path); + } + } const inactiveWarningDiagnostics = context.warnings .filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE") .filter((warning) => !params.allowedPaths || params.allowedPaths.has(warning.path)) @@ -301,6 +401,7 @@ async function resolveCommandSecretRefsLocally(params: { env: context.env, cache: context.cache, activePaths, + runtimeWebActivePaths, inactiveRefPaths, mode: params.mode, commandName: params.commandName, @@ -330,6 +431,7 @@ async function resolveCommandSecretRefsLocally(params: { resolvedConfig, diagnostics: dedupeDiagnostics([ ...params.preflightDiagnostics, + ...runtimeWebInactiveDiagnostics, ...inactiveWarningDiagnostics, ...filterInactiveSurfaceDiagnostics({ diagnostics: analyzed.diagnostics, @@ -405,6 +507,7 @@ async function resolveTargetSecretLocally(params: { env: NodeJS.ProcessEnv; cache: ReturnType["cache"]; activePaths: ReadonlySet; + runtimeWebActivePaths: ReadonlySet; inactiveRefPaths: ReadonlySet; mode: CommandSecretResolutionMode; commandName: string; @@ -419,7 +522,8 @@ async function resolveTargetSecretLocally(params: { if ( !ref || params.inactiveRefPaths.has(params.target.path) || - !params.activePaths.has(params.target.path) + (!params.activePaths.has(params.target.path) && + !params.runtimeWebActivePaths.has(params.target.path)) ) { return; } diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 043a16f08ce..b90b42f3b78 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -80,6 +80,7 @@ export type ConfigDocBaselineStatefileWriteResult = { const GENERATED_BY = "scripts/generate-config-doc-baseline.ts" as const; const DEFAULT_JSON_OUTPUT = "docs/.generated/config-baseline.json"; const DEFAULT_STATEFILE_OUTPUT = "docs/.generated/config-baseline.jsonl"; +let cachedConfigDocBaselinePromise: Promise | null = null; function logConfigDocBaselineDebug(message: string): void { if (process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1") { @@ -622,26 +623,37 @@ export function dedupeConfigDocBaselineEntries( } export async function buildConfigDocBaseline(): Promise { - const start = Date.now(); - logConfigDocBaselineDebug("build baseline start"); - const response = await loadBundledConfigSchemaResponse(); - const schemaRoot = asSchemaObject(response.schema); - if (!schemaRoot) { - throw new Error("config schema root is not an object"); + if (cachedConfigDocBaselinePromise) { + return await cachedConfigDocBaselinePromise; + } + cachedConfigDocBaselinePromise = (async () => { + const start = Date.now(); + logConfigDocBaselineDebug("build baseline start"); + const response = await loadBundledConfigSchemaResponse(); + const schemaRoot = asSchemaObject(response.schema); + if (!schemaRoot) { + throw new Error("config schema root is not an object"); + } + const collectStart = Date.now(); + logConfigDocBaselineDebug("collect baseline entries start"); + const entries = dedupeConfigDocBaselineEntries( + collectConfigDocBaselineEntries(schemaRoot, response.uiHints), + ); + logConfigDocBaselineDebug( + `collect baseline entries done count=${entries.length} elapsedMs=${Date.now() - collectStart}`, + ); + logConfigDocBaselineDebug(`build baseline done elapsedMs=${Date.now() - start}`); + return { + generatedBy: GENERATED_BY, + entries, + }; + })(); + try { + return await cachedConfigDocBaselinePromise; + } catch (error) { + cachedConfigDocBaselinePromise = null; + throw error; } - const collectStart = Date.now(); - logConfigDocBaselineDebug("collect baseline entries start"); - const entries = dedupeConfigDocBaselineEntries( - collectConfigDocBaselineEntries(schemaRoot, response.uiHints), - ); - logConfigDocBaselineDebug( - `collect baseline entries done count=${entries.length} elapsedMs=${Date.now() - collectStart}`, - ); - logConfigDocBaselineDebug(`build baseline done elapsedMs=${Date.now() - start}`); - return { - generatedBy: GENERATED_BY, - entries, - }; } export async function renderConfigDocBaselineStatefile( diff --git a/src/infra/outbound/message.channels.test.ts b/src/infra/outbound/message.channels.test.ts index 6167c3c250c..0e99a7af2b7 100644 --- a/src/infra/outbound/message.channels.test.ts +++ b/src/infra/outbound/message.channels.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -19,9 +19,11 @@ vi.mock("../../gateway/call.js", () => ({ let sendMessage: typeof import("./message.js").sendMessage; let sendPoll: typeof import("./message.js").sendPoll; -beforeEach(async () => { - vi.resetModules(); +beforeAll(async () => { ({ sendMessage, sendPoll } = await import("./message.js")); +}); + +beforeEach(() => { callGatewayMock.mockClear(); setRegistry(emptyRegistry); }); diff --git a/src/plugin-sdk/index.test.ts b/src/plugin-sdk/index.test.ts index a744113a8cf..89ca3901ff3 100644 --- a/src/plugin-sdk/index.test.ts +++ b/src/plugin-sdk/index.test.ts @@ -95,6 +95,11 @@ await build(${JSON.stringify({ await execFileAsync(process.execPath, [buildScriptPath], { cwd: process.cwd(), }); + await fs.symlink( + path.join(process.cwd(), "node_modules"), + path.join(outDir, "node_modules"), + "dir", + ); for (const entry of pluginSdkEntrypoints) { const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href); @@ -107,6 +112,12 @@ await build(${JSON.stringify({ await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir"); + // Mirror the installed package layout so subpaths can resolve root deps. + await fs.symlink( + path.join(process.cwd(), "node_modules"), + path.join(packageDir, "node_modules"), + "dir", + ); await fs.writeFile( path.join(packageDir, "package.json"), JSON.stringify( diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index 464331f5765..a1d0cf5970a 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -34,9 +34,9 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export { probeIMessage } from "./src/probe.js";', 'export { sendMessageIMessage } from "./src/send.js";', ], - "extensions/googlechat/runtime-api.ts": ['export * from "../../src/plugin-sdk/googlechat.js";'], + "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], "extensions/nextcloud-talk/runtime-api.ts": [ - 'export * from "../../src/plugin-sdk/nextcloud-talk.js";', + 'export * from "openclaw/plugin-sdk/nextcloud-talk";', ], "extensions/signal/runtime-api.ts": ['export * from "./src/runtime-api.js";'], "extensions/slack/runtime-api.ts": [ diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 194fcdae1d1..edc172e03d0 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3327,8 +3327,8 @@ module.exports = { it("derives plugin-sdk subpaths from package exports", () => { const subpaths = __testing.listPluginSdkExportedSubpaths(); - expect(subpaths).toContain("compat"); expect(subpaths).toContain("telegram"); + expect(subpaths).not.toContain("compat"); expect(subpaths).not.toContain("root-alias"); }); @@ -3351,7 +3351,7 @@ module.exports = { it("loads source runtime shims through the non-native Jiti boundary", async () => { const jiti = createJiti(import.meta.url, { - ...__testing.buildPluginLoaderJitiOptions({}), + ...__testing.buildPluginLoaderJitiOptions(__testing.resolvePluginSdkScopedAliasMap()), tryNative: false, }); const discordChannelRuntime = path.join( diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 71fc1bd6f1f..b1aff47073c 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -140,6 +140,7 @@ export const __testing = { buildPluginLoaderJitiOptions, listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, + resolvePluginSdkScopedAliasMap, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, resolvePluginRuntimeModulePath, diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index e9412e2bd57..f7cced042ea 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -208,23 +208,16 @@ function ensureObject(target: Record, key: string): Record, "tools"); const web = ensureObject(tools, "web"); const search = ensureObject(web, "search"); - const provider = resolvePluginWebSearchProviders({ - config: params.sourceConfig, - env: { ...process.env, ...params.env }, - bundledAllowlistCompat: true, - }).find((entry) => entry.id === params.provider); - if (provider?.setConfiguredCredentialValue) { - provider.setConfiguredCredentialValue(params.resolvedConfig, params.value); + if (params.provider.setConfiguredCredentialValue) { + params.provider.setConfiguredCredentialValue(params.resolvedConfig, params.value); } - provider?.setCredentialValue(search, params.value); + params.provider.setCredentialValue(search, params.value); } function setResolvedFirecrawlApiKey(params: { @@ -364,10 +357,8 @@ export async function resolveRuntimeWebTools(params: { if (resolution.value) { setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, - provider: provider.id, + provider, value: resolution.value, - sourceConfig: params.sourceConfig, - env: params.context.env, }); } break; @@ -378,10 +369,8 @@ export async function resolveRuntimeWebTools(params: { selectedResolution = resolution; setResolvedWebSearchApiKey({ resolvedConfig: params.resolvedConfig, - provider: provider.id, + provider, value: resolution.value, - sourceConfig: params.sourceConfig, - env: params.context.env, }); break; } diff --git a/test/helpers/extensions/discord-provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts index 21412c91709..3c66b4d6743 100644 --- a/test/helpers/extensions/discord-provider.test-support.ts +++ b/test/helpers/extensions/discord-provider.test-support.ts @@ -473,4 +473,5 @@ vi.mock("../../../extensions/discord/src/monitor/thread-bindings.js", () => ({ createNoopThreadBindingManager: createNoopThreadBindingManagerMock, createThreadBindingManager: createThreadBindingManagerMock, reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, + resolveThreadBindingIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000), }));