diff --git a/src/agents/bash-tools.exec-host-shared.test.ts b/src/agents/bash-tools.exec-host-shared.test.ts index 991d12d7454..cfff7bb524f 100644 --- a/src/agents/bash-tools.exec-host-shared.test.ts +++ b/src/agents/bash-tools.exec-host-shared.test.ts @@ -43,17 +43,17 @@ let resolveExecHostApprovalContext: typeof import("./bash-tools.exec-host-shared let sendExecApprovalFollowup: typeof import("./bash-tools.exec-approval-followup.js").sendExecApprovalFollowup; let logWarn: typeof import("../logger.js").logWarn; -describe("sendExecApprovalFollowupResult", () => { - beforeAll(async () => { - ({ - sendExecApprovalFollowupResult, - MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS: maxExecApprovalFollowupFailureLogKeys, - resolveExecHostApprovalContext, - } = await import("./bash-tools.exec-host-shared.js")); - ({ sendExecApprovalFollowup } = await import("./bash-tools.exec-approval-followup.js")); - ({ logWarn } = await import("../logger.js")); - }); +beforeAll(async () => { + ({ + sendExecApprovalFollowupResult, + MAX_EXEC_APPROVAL_FOLLOWUP_FAILURE_LOG_KEYS: maxExecApprovalFollowupFailureLogKeys, + resolveExecHostApprovalContext, + } = await import("./bash-tools.exec-host-shared.js")); + ({ sendExecApprovalFollowup } = await import("./bash-tools.exec-approval-followup.js")); + ({ logWarn } = await import("../logger.js")); +}); +describe("sendExecApprovalFollowupResult", () => { beforeEach(() => { vi.mocked(sendExecApprovalFollowup).mockReset(); vi.mocked(logWarn).mockReset(); diff --git a/src/channels/plugins/config-schema.test.ts b/src/channels/plugins/config-schema.test.ts index 2f788ac8e91..be85b5f7d93 100644 --- a/src/channels/plugins/config-schema.test.ts +++ b/src/channels/plugins/config-schema.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { buildChannelConfigSchema } from "./config-schema.js"; +import { buildChannelConfigSchema, emptyChannelConfigSchema } from "./config-schema.js"; describe("buildChannelConfigSchema", () => { it("builds json schema when toJSONSchema is available", () => { @@ -46,3 +46,22 @@ describe("buildChannelConfigSchema", () => { }); }); }); + +describe("emptyChannelConfigSchema", () => { + it("accepts undefined and empty objects only", () => { + const result = emptyChannelConfigSchema(); + + expect(result.runtime?.safeParse(undefined)).toEqual({ + success: true, + data: undefined, + }); + expect(result.runtime?.safeParse({})).toEqual({ + success: true, + data: {}, + }); + expect(result.runtime?.safeParse({ enabled: true })).toEqual({ + success: false, + issues: [{ path: [], message: "config must be empty" }], + }); + }); +}); diff --git a/src/channels/plugins/config-schema.ts b/src/channels/plugins/config-schema.ts index 971f50abea9..94a71013c6b 100644 --- a/src/channels/plugins/config-schema.ts +++ b/src/channels/plugins/config-schema.ts @@ -104,3 +104,33 @@ export function buildChannelConfigSchema( }, }; } + +export function emptyChannelConfigSchema(): ChannelConfigSchema { + return { + schema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + runtime: { + safeParse(value) { + if (value === undefined) { + return { success: true, data: undefined }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { + success: false, + issues: [{ path: [], message: "expected config object" }], + }; + } + if (Object.keys(value as Record).length > 0) { + return { + success: false, + issues: [{ path: [], message: "config must be empty" }], + }; + } + return { success: true, data: value }; + }, + }, + }; +} diff --git a/src/plugin-sdk/channel-core.ts b/src/plugin-sdk/channel-core.ts index eaa4ee63c1d..99876eba054 100644 --- a/src/plugin-sdk/channel-core.ts +++ b/src/plugin-sdk/channel-core.ts @@ -1,3 +1,4 @@ +import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js"; import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; import { createScopedAccountReplyToModeResolver, @@ -14,14 +15,17 @@ import type { ChannelPollResult, ChannelThreadingAdapter, } from "../channels/plugins/types.core.js"; -import type { ChannelConfigUiHint, ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { + ChannelConfigSchema, + ChannelConfigUiHint, + ChannelPlugin, +} from "../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ReplyToMode } from "../config/types.base.js"; import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; -import { emptyPluginConfigSchema } from "../plugins/config-schema.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; -import type { OpenClawPluginApi, OpenClawPluginConfigSchema } from "../plugins/types.js"; +import type { OpenClawPluginApi } from "../plugins/types.js"; export type { ChannelConfigUiHint, ChannelPlugin }; export type { OpenClawConfig }; @@ -31,12 +35,17 @@ export type ChannelOutboundSessionRouteParams = Parameters< NonNullable >[0]; +type ChannelEntryConfigSchema = + TPlugin extends ChannelPlugin + ? NonNullable + : ChannelConfigSchema; + type DefineChannelPluginEntryOptions = { id: string; name: string; description: string; plugin: TPlugin; - configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema); + configSchema?: ChannelEntryConfigSchema | (() => ChannelEntryConfigSchema); setRuntime?: (runtime: PluginRuntime) => void; registerCliMetadata?: (api: OpenClawPluginApi) => void; registerFull?: (api: OpenClawPluginApi) => void; @@ -46,7 +55,7 @@ type DefinedChannelPluginEntry = { id: string; name: string; description: string; - configSchema: OpenClawPluginConfigSchema; + configSchema: ChannelEntryConfigSchema; register: (api: OpenClawPluginApi) => void; channelPlugin: TPlugin; setChannelRuntime?: (runtime: PluginRuntime) => void; @@ -270,12 +279,15 @@ export function defineChannelPluginEntry({ name, description, plugin, - configSchema = emptyPluginConfigSchema, + configSchema, setRuntime, registerCliMetadata, registerFull, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry { - const resolvedConfigSchema = typeof configSchema === "function" ? configSchema() : configSchema; + const resolvedConfigSchema: ChannelEntryConfigSchema = + typeof configSchema === "function" + ? configSchema() + : ((configSchema ?? emptyChannelConfigSchema()) as ChannelEntryConfigSchema); const entry = { id, name, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 909bb335faa..fc7b0c0af9a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,4 +1,5 @@ import { CHAT_CHANNEL_ORDER, type ChatChannelId } from "../channels/ids.js"; +import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js"; import { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; import { createScopedAccountReplyToModeResolver, @@ -22,7 +23,6 @@ import type { ReplyToMode } from "../config/types.base.js"; import { buildOutboundBaseSessionKey } from "../infra/outbound/base-session-key.js"; import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js"; -import { emptyPluginConfigSchema } from "../plugins/config-schema.js"; import type { PluginPackageChannel } from "../plugins/manifest.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { OpenClawPluginApi } from "../plugins/types.js"; @@ -144,7 +144,10 @@ export { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js" export { generateSecureToken, generateSecureUuid } from "../infra/secure-random.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export { + buildChannelConfigSchema, + emptyChannelConfigSchema, +} from "../channels/plugins/config-schema.js"; export { applyAccountNameToChannelSection, migrateBaseNameToDefaultAccount, @@ -356,35 +359,18 @@ export function buildChannelOutboundSessionRoute(params: { }; } -const emptyChannelConfigSchema: ChannelConfigSchema = { - schema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - runtime: { - safeParse(value: unknown) { - if (value === undefined) { - return { success: true, data: undefined }; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return { success: false, issues: [{ path: [], message: "expected config object" }] }; - } - if (Object.keys(value as Record).length > 0) { - return { success: false, issues: [{ path: [], message: "config must be empty" }] }; - } - return { success: true, data: value }; - }, - }, -}; - /** Options for a channel plugin entry that should register a channel capability. */ +type ChannelEntryConfigSchema = + TPlugin extends ChannelPlugin + ? NonNullable + : ChannelConfigSchema; + type DefineChannelPluginEntryOptions = { id: string; name: string; description: string; plugin: TPlugin; - configSchema?: ChannelConfigSchema | (() => ChannelConfigSchema); + configSchema?: ChannelEntryConfigSchema | (() => ChannelEntryConfigSchema); setRuntime?: (runtime: PluginRuntime) => void; registerCliMetadata?: (api: OpenClawPluginApi) => void; registerFull?: (api: OpenClawPluginApi) => void; @@ -394,7 +380,7 @@ type DefinedChannelPluginEntry = { id: string; name: string; description: string; - configSchema: ChannelConfigSchema; + configSchema: ChannelEntryConfigSchema; register: (api: OpenClawPluginApi) => void; channelPlugin: TPlugin; setChannelRuntime?: (runtime: PluginRuntime) => void; @@ -452,16 +438,17 @@ export function defineChannelPluginEntry({ name, description, plugin, - configSchema = emptyChannelConfigSchema, + configSchema, setRuntime, registerCliMetadata, registerFull, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry { - let resolvedConfigSchema: ChannelConfigSchema | undefined; - const getConfigSchema = (): ChannelConfigSchema => { + let resolvedConfigSchema: ChannelEntryConfigSchema | undefined; + const getConfigSchema = (): ChannelEntryConfigSchema => { resolvedConfigSchema ??= - (typeof configSchema === "function" ? configSchema() : configSchema) ?? - emptyChannelConfigSchema; + typeof configSchema === "function" + ? configSchema() + : ((configSchema ?? emptyChannelConfigSchema()) as ChannelEntryConfigSchema); return resolvedConfigSchema; }; const entry = {