diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 7e1b445dd7b1..2c89c95520d7 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -22,7 +22,7 @@ ad60ccc4fe9084d47f0477e02d9296bacad32f26d7456e2a84be8d25a53a25c2 module/boolean 6e3b8ccee738f8eddde5d813b6e204d6f7c8541fda4cb279a3be3ead27f47e7d module/channel-actions 00d326fdff396dfcc657d1fd16a4015b81d60ae8bf7dad09ac1010b376814de6 module/channel-config-helpers 16dc9d32e8ca3ef78fc63e0fc4b20e6b943633e9572de1a49d24a880b6ffc66c module/channel-config-primitives -8e51280de9fbf0cb51f4738130ae40df251d0055f7abb78e3ec1245534025d0f module/channel-config-schema +c0f910ebfa3dbf283145fb1e3b9c016d03e853ef13e70402b09f3b9d9c2f4ab0 module/channel-config-schema 2dd98659d9600e755f09ef00dd91c36692b8562ed3700461937e94f6cd1e640a module/channel-contract 82a966cf4c343179eea76afdcc982cd814a80048b3cbee95844dcddb64a949dd module/channel-core fbf353eb38ae68d8ded3f2a60b432c7bb2c245d2ec7e7c9f53c6da19a0db0938 module/channel-entry-contract diff --git a/extensions/imessage/config-api.ts b/extensions/imessage/config-api.ts index 5337c1c51828..11cd17d3c760 100644 --- a/extensions/imessage/config-api.ts +++ b/extensions/imessage/config-api.ts @@ -1,3 +1,3 @@ // Imessage API module exposes the plugin public contract. export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; -export { IMessageConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema"; +export { IMessageConfigSchema } from "./src/config-schema.js"; diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 4bffab7c66ce..a83d47e7e57b 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -5,7 +5,8 @@ "description": "OpenClaw iMessage channel plugin using imsg on a signed-in Mac", "type": "module", "dependencies": { - "typebox": "1.3.3" + "typebox": "1.3.3", + "zod": "4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/imessage/src/config-schema.test.ts b/extensions/imessage/src/config-schema.test.ts index 406b29f20400..745fdb5e7258 100644 --- a/extensions/imessage/src/config-schema.test.ts +++ b/extensions/imessage/src/config-schema.test.ts @@ -24,6 +24,14 @@ describe("imessage config schema", () => { } }); + it("accepts account allowlist policy inherited from the channel", () => { + const result = IMessageConfigSchema.safeParse({ + allowFrom: ["alice"], + accounts: { work: { dmPolicy: "allowlist" } }, + }); + expect(result.success).toBe(true); + }); + it("defaults dm/group policy", () => { const res = IMessageConfigSchema.safeParse({}); diff --git a/extensions/imessage/src/config-schema.ts b/extensions/imessage/src/config-schema.ts index 071ee59e3f9e..f83b83728150 100644 --- a/extensions/imessage/src/config-schema.ts +++ b/extensions/imessage/src/config-schema.ts @@ -1,7 +1,138 @@ -// Imessage helper module supports config schema behavior. -import { buildChannelConfigSchema, IMessageConfigSchema } from "../config-api.js"; +// iMessage helper module supports config schema behavior. +import { + buildChannelConfigSchema, + buildChannelReactionShape, + buildCommonChannelAccountShape, + buildGroupEntrySchema, + ChannelDeliveryStreamingConfigSchema, + ChannelSendReadReceiptsSchema, + ExecutableTokenSchema, + isSafeScpRemoteHost, + isValidInboundPathRootPattern, + requireAllowlistAllowFrom, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk/channel-config-schema"; +import { z } from "zod"; import { iMessageChannelConfigUiHints } from "./config-ui-hints.js"; +const IMessageActionSchema = z + .object({ + reactions: z.boolean().optional(), + edit: z.boolean().optional(), + unsend: z.boolean().optional(), + reply: z.boolean().optional(), + sendWithEffect: z.boolean().optional(), + renameGroup: z.boolean().optional(), + setGroupIcon: z.boolean().optional(), + addParticipant: z.boolean().optional(), + removeParticipant: z.boolean().optional(), + leaveGroup: z.boolean().optional(), + sendAttachment: z.boolean().optional(), + polls: z.boolean().optional(), + }) + .strict() + .optional(); + +const IMessageAccountSchemaBase = z + .object({ + ...buildCommonChannelAccountShape({ + useDefaults: true, + omit: ["mentionPatterns", "replyToMode"], + streaming: ChannelDeliveryStreamingConfigSchema.optional(), + mediaMaxMb: z.number().int().positive().optional(), + }), + cliPath: ExecutableTokenSchema.optional(), + dbPath: z.string().optional(), + remoteHost: z + .string() + .refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)") + .optional(), + actions: IMessageActionSchema, + service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(), + sendTransport: z.enum(["auto", "bridge", "applescript"]).optional(), + region: z.string().optional(), + includeAttachments: z.boolean().optional(), + attachmentRoots: z + .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root")) + .optional(), + remoteAttachmentRoots: z + .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root")) + .optional(), + probeTimeoutMs: z.number().int().positive().optional(), + sendReadReceipts: ChannelSendReadReceiptsSchema, + ...buildChannelReactionShape({ notificationModes: ["off", "own", "all"] }), + catchup: z + .object({ + enabled: z.boolean().optional(), + maxAgeMinutes: z.number().int().min(1).max(720).optional(), + perRunLimit: z.number().int().min(1).max(500).optional(), + firstRunLookbackMinutes: z.number().int().min(1).max(720).optional(), + maxFailureRetries: z.number().int().min(1).max(1000).optional(), + }) + .strict() + .optional(), + groups: z + .record( + z.string(), + buildGroupEntrySchema(undefined, { + omit: ["skills", "enabled", "allowFrom"], + }).optional(), + ) + .optional(), + }) + .strict(); + +export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ + // Account-level schemas skip allowFrom validation because accounts inherit + // allowFrom from the parent channel config at runtime. + accounts: z.record(z.string(), IMessageAccountSchemaBase.optional()).optional(), + defaultAccount: z.string().optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"', + }); + requireAllowlistAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.imessage.dmPolicy="allowlist" requires channels.imessage.allowFrom to contain at least one sender ID', + }); + + if (!value.accounts) { + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.imessage.accounts.*.dmPolicy="open" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.imessage.accounts.*.dmPolicy="allowlist" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to contain at least one sender ID', + }); + } +}); + export const IMessageChannelConfigSchema = buildChannelConfigSchema(IMessageConfigSchema, { uiHints: iMessageChannelConfigUiHints, }); diff --git a/extensions/telegram/config-api.ts b/extensions/telegram/config-api.ts index 1627790273c2..86ede9717efc 100644 --- a/extensions/telegram/config-api.ts +++ b/extensions/telegram/config-api.ts @@ -1,6 +1,6 @@ // Telegram API module exposes the plugin public contract. export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; -export { TelegramConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema"; +export { TelegramConfigSchema } from "./src/config-schema.js"; export { normalizeTelegramCommandDescription, normalizeTelegramCommandName, diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index b294e7d32140..8857a130a09e 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -9,7 +9,8 @@ "@grammyjs/transformer-throttler": "1.2.1", "grammy": "1.44.0", "typebox": "1.3.3", - "undici": "8.5.0" + "undici": "8.5.0", + "zod": "4.4.3" }, "devDependencies": { "@openclaw/plugin-sdk": "workspace:*" diff --git a/extensions/telegram/src/config-schema.test.ts b/extensions/telegram/src/config-schema.test.ts index ef7a661f8c20..cb2a02ee3581 100644 --- a/extensions/telegram/src/config-schema.test.ts +++ b/extensions/telegram/src/config-schema.test.ts @@ -31,6 +31,24 @@ describe("telegram custom commands schema", () => { } }); + it('rejects dmPolicy="allowlist" without allowFrom', () => { + expectTelegramConfigIssue({ dmPolicy: "allowlist", botToken: "fake" }, "allowFrom"); + }); + + it("accepts account allowlist policy inherited from the channel", () => { + expectTelegramConfigValid({ + allowFrom: ["12345"], + accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } }, + }); + }); + + it("rejects account allowlist without account or channel allowFrom", () => { + expectTelegramConfigIssue( + { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } }, + "accounts.bot1.allowFrom", + ); + }); + it("defaults dm/group policy", () => { const res = TelegramConfigSchema.safeParse({}); diff --git a/extensions/telegram/src/config-schema.ts b/extensions/telegram/src/config-schema.ts index ec435dbc031b..d1611fec2488 100644 --- a/extensions/telegram/src/config-schema.ts +++ b/extensions/telegram/src/config-schema.ts @@ -1,7 +1,370 @@ // Telegram helper module supports config schema behavior. -import { buildChannelConfigSchema, TelegramConfigSchema } from "../config-api.js"; +import { + buildChannelConfigSchema, + buildChannelExecApprovalsSchema, + buildChannelReactionShape, + buildCommonChannelAccountShape, + buildGroupEntrySchema, + ChannelPreviewStreamingConfigSchema, + ChannelStreamingPreviewSchema, + DmPolicySchema, + GroupPolicySchema, + ProviderCommandsSchema, + requireAllowlistAllowFrom, + requireOpenAllowFrom, + ToolPolicySchema, +} from "openclaw/plugin-sdk/channel-config-schema"; +import { + buildSecretInputSchema, + hasConfiguredSecretInput, + registerSensitiveConfigSchema, +} from "openclaw/plugin-sdk/secret-input"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { z } from "zod"; +import { + normalizeTelegramCommandDescription, + normalizeTelegramCommandName, + resolveTelegramCustomCommands, +} from "./command-config.js"; import { telegramChannelConfigUiHints } from "./config-ui-hints.js"; +const SecretInputSchema = buildSecretInputSchema(); +const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); + +type TelegramAccountLike = { + enabled?: unknown; + webhookUrl?: unknown; + webhookSecret?: unknown; +}; + +function validateTelegramWebhookSecretRequirements( + value: { + webhookUrl?: unknown; + webhookSecret?: unknown; + accounts?: Record; + }, + ctx: z.RefinementCtx, +): void { + const baseWebhookUrl = normalizeOptionalString(value.webhookUrl) ?? ""; + const hasBaseWebhookSecret = hasConfiguredSecretInput(value.webhookSecret); + if (baseWebhookUrl && !hasBaseWebhookSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "channels.telegram.webhookUrl requires channels.telegram.webhookSecret", + path: ["webhookSecret"], + }); + } + for (const [accountId, account] of Object.entries(value.accounts ?? {})) { + if (!account || account.enabled === false) { + continue; + } + const accountWebhookUrl = normalizeOptionalString(account.webhookUrl) ?? ""; + if (!accountWebhookUrl) { + continue; + } + if (!hasConfiguredSecretInput(account.webhookSecret) && !hasBaseWebhookSecret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "channels.telegram.accounts.*.webhookUrl requires channels.telegram.webhookSecret or channels.telegram.accounts.*.webhookSecret", + path: ["accounts", accountId, "webhookSecret"], + }); + } + } +} + +const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]); +const TelegramCapabilitiesSchema = z.union([ + z.array(z.string()), + z + .object({ + inlineButtons: TelegramInlineButtonsScopeSchema.optional(), + }) + .strict(), +]); +const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({ + preview: ChannelStreamingPreviewSchema.optional(), +}).strict(); +const TelegramErrorPolicySchema = z.enum(["always", "once", "silent"]).optional(); +const TelegramTopicSchema = z + .object({ + requireMention: z.boolean().optional(), + ingest: z.boolean().optional(), + disableAudioPreflight: z.boolean().optional(), + groupPolicy: GroupPolicySchema.optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + agentId: z.string().optional(), + errorPolicy: TelegramErrorPolicySchema, + }) + .strict(); + +const TelegramGroupSchema = buildGroupEntrySchema({ + ingest: z.boolean().optional(), + disableAudioPreflight: z.boolean().optional(), + groupPolicy: GroupPolicySchema.optional(), + topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), + errorPolicy: TelegramErrorPolicySchema, +}); + +const AutoTopicLabelSchema = z + .union([ + z.boolean(), + z + .object({ + enabled: z.boolean().optional(), + prompt: z.string().optional(), + }) + .strict(), + ]) + .optional(); + +const TelegramDirectSchema = z + .object({ + dmPolicy: DmPolicySchema.optional(), + tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), + errorPolicy: TelegramErrorPolicySchema, + requireTopic: z.boolean().optional(), + autoTopicLabel: AutoTopicLabelSchema, + }) + .strict(); + +const TelegramCustomCommandSchema = z + .object({ + command: z.string().overwrite(normalizeTelegramCommandName), + description: z.string().overwrite(normalizeTelegramCommandDescription), + }) + .strict(); + +const validateTelegramCustomCommands = ( + value: { customCommands?: Array<{ command?: string; description?: string }> }, + ctx: z.RefinementCtx, +) => { + if (!value.customCommands || value.customCommands.length === 0) { + return; + } + const { issues } = resolveTelegramCustomCommands({ + commands: value.customCommands, + checkReserved: false, + checkDuplicates: false, + }); + for (const issue of issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customCommands", issue.index, issue.field], + message: issue.message, + }); + } +}; + +const TelegramAccountSchemaBase = z + .object({ + ...buildCommonChannelAccountShape({ + useDefaults: true, + capabilities: TelegramCapabilitiesSchema.optional(), + defaultTo: z.union([z.string(), z.number()]).optional(), + streaming: TelegramPreviewStreamingConfigSchema.optional(), + }), + execApprovals: buildChannelExecApprovalsSchema(z.union([z.string(), z.number()])), + commands: ProviderCommandsSchema, + customCommands: z.array(TelegramCustomCommandSchema).optional(), + botToken: registerSensitiveConfigSchema(SecretInputSchema.optional()), + tokenFile: z.string().optional(), + groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(), + direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(), + richMessages: z.boolean().optional(), + network: z + .object({ + autoSelectFamily: z.boolean().optional(), + dnsResultOrder: z.enum(["ipv4first", "verbatim"]).optional(), + dangerouslyAllowPrivateNetwork: z + .boolean() + .optional() + .describe( + "Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.", + ), + }) + .strict() + .optional(), + proxy: z.string().optional(), + webhookUrl: z + .string() + .optional() + .describe( + "Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.", + ), + webhookSecret: registerSensitiveConfigSchema( + SecretInputSchema.optional().describe( + "Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.", + ), + ), + webhookPath: z + .string() + .optional() + .describe( + "Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.", + ), + webhookHost: z + .string() + .optional() + .describe( + "Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.", + ), + webhookPort: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.", + ), + webhookCertPath: z + .string() + .optional() + .describe( + "Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).", + ), + actions: z + .object({ + reactions: z.boolean().optional(), + sendMessage: z.boolean().optional(), + poll: z.boolean().optional(), + deleteMessage: z.boolean().optional(), + editMessage: z.boolean().optional(), + sticker: z.boolean().optional(), + createForumTopic: z.boolean().optional(), + editForumTopic: z.boolean().optional(), + }) + .strict() + .optional(), + threadBindings: z + .object({ + enabled: z.boolean().optional(), + idleHours: z.number().nonnegative().optional(), + maxAgeHours: z.number().nonnegative().optional(), + spawnSessions: z.boolean().optional(), + defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), + }) + .strict() + .optional(), + ...buildChannelReactionShape({ + notificationModes: ["off", "own", "all"], + reactionLevels: ["off", "ack", "minimal", "extensive"], + ackReaction: z.string().optional(), + }), + linkPreview: z.boolean().optional(), + silentErrorReplies: z.boolean().optional(), + errorPolicy: TelegramErrorPolicySchema, + apiRoot: z.string().url().optional(), + trustedLocalFileRoots: z + .array(z.string()) + .optional() + .describe( + "Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.", + ), + autoTopicLabel: AutoTopicLabelSchema, + }) + .strict(); + +const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { + // Account-level schemas skip allowFrom validation because accounts inherit + // allowFrom from the parent channel config at runtime (resolveTelegramAccount + // shallow-merges top-level and account values in src/telegram/accounts.ts). + // Validation is enforced at the top-level TelegramConfigSchema instead. + validateTelegramCustomCommands(value, ctx); +}); + +export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ + accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(), + defaultAccount: z.string().optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"', + }); + requireAllowlistAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.telegram.dmPolicy="allowlist" requires channels.telegram.allowFrom to contain at least one sender ID', + }); + validateTelegramCustomCommands(value, ctx); + + if (value.accounts) { + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + const effectivePolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; + requireOpenAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectivePolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to contain at least one sender ID', + }); + } + } + + if (!value.accounts) { + validateTelegramWebhookSecretRequirements(value, ctx); + return; + } + for (const [accountId, account] of Object.entries(value.accounts)) { + if (!account) { + continue; + } + if (account.enabled === false) { + continue; + } + const effectiveDmPolicy = account.dmPolicy ?? value.dmPolicy; + const effectiveAllowFrom = Array.isArray(account.allowFrom) + ? account.allowFrom + : value.allowFrom; + requireOpenAllowFrom({ + policy: effectiveDmPolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to include "*"', + }); + requireAllowlistAllowFrom({ + policy: effectiveDmPolicy, + allowFrom: effectiveAllowFrom, + ctx, + path: ["accounts", accountId, "allowFrom"], + message: + 'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to contain at least one sender ID', + }); + } + validateTelegramWebhookSecretRequirements(value, ctx); +}); + export const TelegramChannelConfigSchema = buildChannelConfigSchema(TelegramConfigSchema, { uiHints: telegramChannelConfigUiHints, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7b6caf0fe8e..17d8b6fba555 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -983,6 +983,9 @@ importers: typebox: specifier: 1.3.3 version: 1.3.3 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* @@ -1794,6 +1797,9 @@ importers: undici: specifier: 8.5.0 version: 8.5.0 + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@openclaw/plugin-sdk': specifier: workspace:* diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 646aff033e4b..54c7c1b81f04 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -149,7 +149,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: channel-owned setup contract factory. // +18: generic schema primitives needed by plugin-owned channel config schemas. // +2: shared Teams reply-style and TTS schema leaves. - 4698, + // +2: generic inbound-root and SCP-host schema validators. + 4700, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -160,7 +161,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: channel-owned setup contract factory. // +4: generic channel schema shape builders. // +1: plugin-owned sensitive-schema registration. - 2847, + // +2: generic inbound-root and SCP-host schema validators. + 2849, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/config/config.allowlist-requires-allowfrom.test.ts b/src/config/config.allowlist-requires-allowfrom.test.ts index db8113a1055d..6100efdc1f05 100644 --- a/src/config/config.allowlist-requires-allowfrom.test.ts +++ b/src/config/config.allowlist-requires-allowfrom.test.ts @@ -1,6 +1,5 @@ // Regresses allowlist config requiring explicit allowFrom entries. import { describe, expect, it } from "vitest"; -import { IMessageConfigSchema, TelegramConfigSchema } from "./zod-schema.providers-core.js"; import { WhatsAppConfigSchema } from "./zod-schema.providers-whatsapp.js"; function expectSchemaAllowlistIssue( @@ -26,12 +25,6 @@ function expectSchemaAllowlistIssue( describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => { it.each([ - { - name: "telegram", - schema: TelegramConfigSchema, - config: { dmPolicy: "allowlist", botToken: "fake" }, - issuePath: "allowFrom", - }, { name: "whatsapp", schema: WhatsAppConfigSchema, @@ -44,45 +37,19 @@ describe('dmPolicy="allowlist" requires non-empty effective allowFrom', () => { expectSchemaAllowlistIssue(schema, config, issuePath); }, ); - - it('accepts dmPolicy="pairing" without allowFrom', () => { - const res = TelegramConfigSchema.safeParse({ dmPolicy: "pairing", botToken: "fake" }); - expect(res.success).toBe(true); - }); }); describe('account dmPolicy="allowlist" uses inherited allowFrom', () => { it.each([ - { - name: "telegram", - schema: TelegramConfigSchema, - config: { - allowFrom: ["12345"], - accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } }, - }, - }, { name: "whatsapp", schema: WhatsAppConfigSchema, config: { allowFrom: ["+15550001111"], accounts: { work: { dmPolicy: "allowlist" } } }, }, - { - name: "imessage", - schema: IMessageConfigSchema, - config: { allowFrom: ["alice"], accounts: { work: { dmPolicy: "allowlist" } } }, - }, ] as const)( "accepts $name account allowlist when parent allowFrom exists", ({ schema, config }) => { expect(schema.safeParse(config).success).toBe(true); }, ); - - it("rejects telegram account allowlist when neither account nor parent has allowFrom", () => { - expectSchemaAllowlistIssue( - TelegramConfigSchema, - { accounts: { bot1: { dmPolicy: "allowlist", botToken: "fake" } } }, - "allowFrom", - ); - }); }); diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 4a0ea76dee6d..5fbaa51018fe 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -7,7 +7,6 @@ import { applyDerivedTags } from "./schema.tags.js"; import { applyResolvedConfigTierHints } from "./schema.tiers.js"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; import { OpenClawSchema } from "./zod-schema.js"; -import { TelegramConfigSchema } from "./zod-schema.providers-core.js"; describe("config schema", () => { type SchemaInput = NonNullable[0]>; @@ -659,17 +658,6 @@ describe("config schema", () => { ).toBe(false); }); - it("accepts progress commentary for shared progress streaming config", () => { - expect( - TelegramConfigSchema.safeParse({ - streaming: { - mode: "progress", - progress: { commentary: true }, - }, - }).success, - ).toBe(true); - }); - it("keeps per-agent model overrides limited to model selection", () => { const result = OpenClawSchema.safeParse({ agents: { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts deleted file mode 100644 index 3956ce3ad8b3..000000000000 --- a/src/config/zod-schema.providers-core.ts +++ /dev/null @@ -1,450 +0,0 @@ -// Defines core provider schema fragments for config parsing. -import { isValidInboundPathRootPattern } from "@openclaw/media-core/inbound-path-policy"; -import { z } from "zod"; -import { buildGroupEntrySchema } from "../channels/plugins/config-schema.js"; -import { isSafeScpRemoteHost } from "../infra/scp-host.js"; -import { - normalizeCommandDescription, - normalizeSlashCommandName, - resolveCustomCommands, -} from "../shared/custom-command-config.js"; -import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; -import { - ChannelSendReadReceiptsSchema, - buildChannelExecApprovalsSchema, - buildChannelReactionShape, - buildCommonChannelAccountShape, - ChannelPreviewStreamingConfigSchema, - ChannelStreamingPreviewSchema, -} from "./zod-schema.channel-messaging-common.js"; -import { - ChannelDeliveryStreamingConfigSchema, - DmPolicySchema, - ExecutableTokenSchema, - GroupPolicySchema, - ProviderCommandsSchema, - SecretInputSchema, - requireAllowlistAllowFrom, - requireOpenAllowFrom, -} from "./zod-schema.core.js"; -import { validateTelegramWebhookSecretRequirements } from "./zod-schema.secret-input-validation.js"; -import { sensitive } from "./zod-schema.sensitive.js"; - -const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional(); - -const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "allowlist"]); -const TelegramCapabilitiesSchema = z.union([ - z.array(z.string()), - z - .object({ - inlineButtons: TelegramInlineButtonsScopeSchema.optional(), - }) - .strict(), -]); -const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({ - preview: ChannelStreamingPreviewSchema.optional(), -}).strict(); -const TelegramErrorPolicySchema = z.enum(["always", "once", "silent"]).optional(); -const TelegramCommandNamePattern = /^[a-z0-9_]{1,32}$/; -const TelegramCustomCommandConfig = { - label: "Telegram", - pattern: TelegramCommandNamePattern, - patternDescription: "use a-z, 0-9, underscore; max 32 chars", -} as const; -const TelegramTopicSchema = z - .object({ - requireMention: z.boolean().optional(), - ingest: z.boolean().optional(), - disableAudioPreflight: z.boolean().optional(), - groupPolicy: GroupPolicySchema.optional(), - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - systemPrompt: z.string().optional(), - agentId: z.string().optional(), - errorPolicy: TelegramErrorPolicySchema, - }) - .strict(); - -const TelegramGroupSchema = buildGroupEntrySchema({ - ingest: z.boolean().optional(), - disableAudioPreflight: z.boolean().optional(), - groupPolicy: GroupPolicySchema.optional(), - topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), - errorPolicy: TelegramErrorPolicySchema, -}); - -const AutoTopicLabelSchema = z - .union([ - z.boolean(), - z - .object({ - enabled: z.boolean().optional(), - prompt: z.string().optional(), - }) - .strict(), - ]) - .optional(); - -const TelegramDirectSchema = z - .object({ - dmPolicy: DmPolicySchema.optional(), - tools: ToolPolicySchema, - toolsBySender: ToolPolicyBySenderSchema, - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - systemPrompt: z.string().optional(), - topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), - errorPolicy: TelegramErrorPolicySchema, - requireTopic: z.boolean().optional(), - autoTopicLabel: AutoTopicLabelSchema, - }) - .strict(); - -const TelegramCustomCommandSchema = z - .object({ - command: z.string().overwrite(normalizeSlashCommandName), - description: z.string().overwrite(normalizeCommandDescription), - }) - .strict(); - -const validateTelegramCustomCommands = ( - value: { customCommands?: Array<{ command?: string; description?: string }> }, - ctx: z.RefinementCtx, -) => { - if (!value.customCommands || value.customCommands.length === 0) { - return; - } - const { issues } = resolveCustomCommands({ - commands: value.customCommands, - checkReserved: false, - checkDuplicates: false, - config: TelegramCustomCommandConfig, - }); - for (const issue of issues) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["customCommands", issue.index, issue.field], - message: issue.message, - }); - } -}; - -const TelegramAccountSchemaBase = z - .object({ - ...buildCommonChannelAccountShape({ - useDefaults: true, - capabilities: TelegramCapabilitiesSchema.optional(), - defaultTo: z.union([z.string(), z.number()]).optional(), - streaming: TelegramPreviewStreamingConfigSchema.optional(), - }), - execApprovals: buildChannelExecApprovalsSchema(z.union([z.string(), z.number()])), - commands: ProviderCommandsSchema, - customCommands: z.array(TelegramCustomCommandSchema).optional(), - botToken: SecretInputSchema.optional().register(sensitive), - tokenFile: z.string().optional(), - groups: z.record(z.string(), TelegramGroupSchema.optional()).optional(), - direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(), - richMessages: z.boolean().optional(), - network: z - .object({ - autoSelectFamily: z.boolean().optional(), - dnsResultOrder: z.enum(["ipv4first", "verbatim"]).optional(), - dangerouslyAllowPrivateNetwork: z - .boolean() - .optional() - .describe( - "Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.", - ), - }) - .strict() - .optional(), - proxy: z.string().optional(), - webhookUrl: z - .string() - .optional() - .describe( - "Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.", - ), - webhookSecret: SecretInputSchema.optional() - .describe( - "Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.", - ) - .register(sensitive), - webhookPath: z - .string() - .optional() - .describe( - "Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.", - ), - webhookHost: z - .string() - .optional() - .describe( - "Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.", - ), - webhookPort: z - .number() - .int() - .nonnegative() - .optional() - .describe( - "Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.", - ), - webhookCertPath: z - .string() - .optional() - .describe( - "Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).", - ), - actions: z - .object({ - reactions: z.boolean().optional(), - sendMessage: z.boolean().optional(), - poll: z.boolean().optional(), - deleteMessage: z.boolean().optional(), - editMessage: z.boolean().optional(), - sticker: z.boolean().optional(), - createForumTopic: z.boolean().optional(), - editForumTopic: z.boolean().optional(), - }) - .strict() - .optional(), - threadBindings: z - .object({ - enabled: z.boolean().optional(), - idleHours: z.number().nonnegative().optional(), - maxAgeHours: z.number().nonnegative().optional(), - spawnSessions: z.boolean().optional(), - defaultSpawnContext: z.enum(["isolated", "fork"]).optional(), - }) - .strict() - .optional(), - ...buildChannelReactionShape({ - notificationModes: ["off", "own", "all"], - reactionLevels: ["off", "ack", "minimal", "extensive"], - ackReaction: z.string().optional(), - }), - linkPreview: z.boolean().optional(), - silentErrorReplies: z.boolean().optional(), - errorPolicy: TelegramErrorPolicySchema, - apiRoot: z.string().url().optional(), - trustedLocalFileRoots: z - .array(z.string()) - .optional() - .describe( - "Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.", - ), - autoTopicLabel: AutoTopicLabelSchema, - }) - .strict(); - -const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { - // Account-level schemas skip allowFrom validation because accounts inherit - // allowFrom from the parent channel config at runtime (resolveTelegramAccount - // shallow-merges top-level and account values in src/telegram/accounts.ts). - // Validation is enforced at the top-level TelegramConfigSchema instead. - validateTelegramCustomCommands(value, ctx); -}); - -export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({ - accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(), - defaultAccount: z.string().optional(), -}).superRefine((value, ctx) => { - requireOpenAllowFrom({ - policy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"', - }); - requireAllowlistAllowFrom({ - policy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.telegram.dmPolicy="allowlist" requires channels.telegram.allowFrom to contain at least one sender ID', - }); - validateTelegramCustomCommands(value, ctx); - - if (value.accounts) { - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - const effectivePolicy = account.dmPolicy ?? value.dmPolicy; - const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; - requireOpenAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to include "*"', - }); - requireAllowlistAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.accounts.*.allowFrom (or channels.telegram.allowFrom) to contain at least one sender ID', - }); - } - } - - if (!value.accounts) { - validateTelegramWebhookSecretRequirements(value, ctx); - return; - } - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - if (account.enabled === false) { - continue; - } - const effectiveDmPolicy = account.dmPolicy ?? value.dmPolicy; - const effectiveAllowFrom = Array.isArray(account.allowFrom) - ? account.allowFrom - : value.allowFrom; - requireOpenAllowFrom({ - policy: effectiveDmPolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.telegram.accounts.*.dmPolicy="open" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to include "*"', - }); - requireAllowlistAllowFrom({ - policy: effectiveDmPolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.telegram.accounts.*.dmPolicy="allowlist" requires channels.telegram.allowFrom or channels.telegram.accounts.*.allowFrom to contain at least one sender ID', - }); - } - validateTelegramWebhookSecretRequirements(value, ctx); -}); - -const IMessageActionSchema = z - .object({ - reactions: z.boolean().optional(), - edit: z.boolean().optional(), - unsend: z.boolean().optional(), - reply: z.boolean().optional(), - sendWithEffect: z.boolean().optional(), - renameGroup: z.boolean().optional(), - setGroupIcon: z.boolean().optional(), - addParticipant: z.boolean().optional(), - removeParticipant: z.boolean().optional(), - leaveGroup: z.boolean().optional(), - sendAttachment: z.boolean().optional(), - polls: z.boolean().optional(), - }) - .strict() - .optional(); - -const IMessageAccountSchemaBase = z - .object({ - ...buildCommonChannelAccountShape({ - useDefaults: true, - omit: ["mentionPatterns", "replyToMode"], - streaming: ChannelDeliveryStreamingConfigSchema.optional(), - mediaMaxMb: z.number().int().positive().optional(), - }), - cliPath: ExecutableTokenSchema.optional(), - dbPath: z.string().optional(), - remoteHost: z - .string() - .refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)") - .optional(), - actions: IMessageActionSchema, - service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(), - sendTransport: z.enum(["auto", "bridge", "applescript"]).optional(), - region: z.string().optional(), - includeAttachments: z.boolean().optional(), - attachmentRoots: z - .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root")) - .optional(), - remoteAttachmentRoots: z - .array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root")) - .optional(), - probeTimeoutMs: z.number().int().positive().optional(), - sendReadReceipts: ChannelSendReadReceiptsSchema, - ...buildChannelReactionShape({ notificationModes: ["off", "own", "all"] }), - catchup: z - .object({ - enabled: z.boolean().optional(), - maxAgeMinutes: z.number().int().min(1).max(720).optional(), - perRunLimit: z.number().int().min(1).max(500).optional(), - firstRunLookbackMinutes: z.number().int().min(1).max(720).optional(), - maxFailureRetries: z.number().int().min(1).max(1000).optional(), - }) - .strict() - .optional(), - groups: z - .record( - z.string(), - buildGroupEntrySchema(undefined, { - omit: ["skills", "enabled", "allowFrom"], - }).optional(), - ) - .optional(), - }) - .strict(); - -export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ - // Account-level schemas skip allowFrom validation because accounts inherit - // allowFrom from the parent channel config at runtime. - accounts: z.record(z.string(), IMessageAccountSchemaBase.optional()).optional(), - defaultAccount: z.string().optional(), -}).superRefine((value, ctx) => { - requireOpenAllowFrom({ - policy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.imessage.dmPolicy="open" requires channels.imessage.allowFrom to include "*"', - }); - requireAllowlistAllowFrom({ - policy: value.dmPolicy, - allowFrom: value.allowFrom, - ctx, - path: ["allowFrom"], - message: - 'channels.imessage.dmPolicy="allowlist" requires channels.imessage.allowFrom to contain at least one sender ID', - }); - - if (!value.accounts) { - return; - } - for (const [accountId, account] of Object.entries(value.accounts)) { - if (!account) { - continue; - } - const effectivePolicy = account.dmPolicy ?? value.dmPolicy; - const effectiveAllowFrom = account.allowFrom ?? value.allowFrom; - requireOpenAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.imessage.accounts.*.dmPolicy="open" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to include "*"', - }); - requireAllowlistAllowFrom({ - policy: effectivePolicy, - allowFrom: effectiveAllowFrom, - ctx, - path: ["accounts", accountId, "allowFrom"], - message: - 'channels.imessage.accounts.*.dmPolicy="allowlist" requires channels.imessage.accounts.*.allowFrom (or channels.imessage.allowFrom) to contain at least one sender ID', - }); - } -}); diff --git a/src/config/zod-schema.secret-input-validation.ts b/src/config/zod-schema.secret-input-validation.ts deleted file mode 100644 index 3348af871b15..000000000000 --- a/src/config/zod-schema.secret-input-validation.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Validates secret input schema fragments shared by config sections. -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { z } from "zod"; -import { hasConfiguredSecretInput } from "./types.secrets.js"; - -type TelegramAccountLike = { - enabled?: unknown; - webhookUrl?: unknown; - webhookSecret?: unknown; -}; - -type TelegramConfigLike = { - webhookUrl?: unknown; - webhookSecret?: unknown; - accounts?: Record; -}; - -// Only enabled accounts need per-account secret requirement checks. -function forEachEnabledAccount( - accounts: Record | undefined, - run: (accountId: string, account: T) => void, -): void { - if (!accounts) { - return; - } - for (const [accountId, account] of Object.entries(accounts)) { - if (!account || account.enabled === false) { - continue; - } - run(accountId, account); - } -} - -/** Validates Telegram webhook URLs have a usable shared or account webhook secret. */ -export function validateTelegramWebhookSecretRequirements( - value: TelegramConfigLike, - ctx: z.RefinementCtx, -): void { - const baseWebhookUrl = normalizeOptionalString(value.webhookUrl) ?? ""; - const hasBaseWebhookSecret = hasConfiguredSecretInput(value.webhookSecret); - if (baseWebhookUrl && !hasBaseWebhookSecret) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "channels.telegram.webhookUrl requires channels.telegram.webhookSecret", - path: ["webhookSecret"], - }); - } - forEachEnabledAccount(value.accounts, (accountId, account) => { - const accountWebhookUrl = normalizeOptionalString(account.webhookUrl) ?? ""; - if (!accountWebhookUrl) { - return; - } - const hasAccountSecret = hasConfiguredSecretInput(account.webhookSecret); - if (!hasAccountSecret && !hasBaseWebhookSecret) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "channels.telegram.accounts.*.webhookUrl requires channels.telegram.webhookSecret or channels.telegram.accounts.*.webhookSecret", - path: ["accounts", accountId, "webhookSecret"], - }); - } - }); -} diff --git a/src/config/zod-schema.startup-import.test.ts b/src/config/zod-schema.startup-import.test.ts index 200b3c3a094c..c659d30f339c 100644 --- a/src/config/zod-schema.startup-import.test.ts +++ b/src/config/zod-schema.startup-import.test.ts @@ -3,16 +3,10 @@ import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; const providersWhatsappImportMock = vi.hoisted(() => vi.fn()); -const providersCoreImportMock = vi.hoisted(() => vi.fn()); describe("OpenClawSchema startup imports", () => { beforeEach(() => { providersWhatsappImportMock.mockClear(); - providersCoreImportMock.mockClear(); - vi.doMock("./zod-schema.providers-core.js", () => { - providersCoreImportMock(); - return {}; - }); vi.doMock("./zod-schema.providers-whatsapp.js", () => { providersWhatsappImportMock(); return {}; @@ -40,7 +34,6 @@ describe("OpenClawSchema startup imports", () => { }); expect(parsed.success).toBe(true); - expect(providersCoreImportMock).not.toHaveBeenCalled(); expect(providersWhatsappImportMock).not.toHaveBeenCalled(); }); }); diff --git a/src/plugin-sdk/bundled-channel-config-schema.test.ts b/src/plugin-sdk/bundled-channel-config-schema.test.ts new file mode 100644 index 000000000000..c7a7c372d45c --- /dev/null +++ b/src/plugin-sdk/bundled-channel-config-schema.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +// Bundled channel config schema tests cover lazy plugin-owned schema resolution. +import { z } from "zod"; +import { IMessageConfigSchema, TelegramConfigSchema } from "./bundled-channel-config-schema.js"; +import type { OpenClawConfig } from "./config-contracts.js"; + +describe("bundled channel config schema facade", () => { + it("resolves Telegram and iMessage schemas from bundled plugin public APIs", () => { + expect(TelegramConfigSchema.safeParse({ dmPolicy: "pairing" }).success).toBe(true); + expect(IMessageConfigSchema.safeParse({ dmPolicy: "pairing" }).success).toBe(true); + + const extended = TelegramConfigSchema.safeExtend({ testOnly: z.literal(true) }); + expect(extended.safeParse({ dmPolicy: "pairing", testOnly: true }).success).toBe(true); + + type ChannelConfig = NonNullable; + const telegramConfig: NonNullable = TelegramConfigSchema.parse({}); + const imessageConfig: NonNullable = IMessageConfigSchema.parse({}); + expect(telegramConfig).toBeDefined(); + expect(imessageConfig).toBeDefined(); + }); +}); diff --git a/src/plugin-sdk/bundled-channel-config-schema.ts b/src/plugin-sdk/bundled-channel-config-schema.ts index db685a5aa9dc..a5a857b77b94 100644 --- a/src/plugin-sdk/bundled-channel-config-schema.ts +++ b/src/plugin-sdk/bundled-channel-config-schema.ts @@ -6,6 +6,13 @@ * bundled channel schemas. Internal callers use this subpath only for the * bundled provider schemas; generic primitives come from channel-config-schema. */ +import type { ZodObject, ZodOptional, ZodType } from "zod"; +import type { OpenClawConfig } from "./config-contracts.js"; +import { + createLazyFacadeObjectValue, + loadBundledPluginPublicSurfaceModuleSync, +} from "./facade-loader.js"; + export { AllowFromListSchema, ChannelGroupEntrySchema, @@ -25,6 +32,47 @@ export { requireOpenAllowFrom, ToolPolicySchema, } from "./channel-config-schema.js"; -export { IMessageConfigSchema, TelegramConfigSchema } from "../config/zod-schema.providers-core.js"; +type ChannelConfig = NonNullable; +type ConfigSchemaShape = { + -readonly [K in keyof TOutput]-?: Pick extends Required> + ? ZodType + : ZodOptional>>; +}; +type BundledObjectConfigSchema = ZodObject>; +type BundledConfigSchemaModule = Record< + string, + BundledObjectConfigSchema +>; + +function loadBundledConfigSchema( + dirName: string, + exportName: string, +): BundledObjectConfigSchema { + const schema = loadBundledPluginPublicSurfaceModuleSync>({ + dirName, + artifactBasename: "config-api.js", + })[exportName]; + if (!schema) { + throw new Error(`Bundled plugin ${dirName} config API does not export ${exportName}`); + } + return schema; +} + +export const IMessageConfigSchema = createLazyFacadeObjectValue< + BundledObjectConfigSchema> +>(() => + loadBundledConfigSchema>( + "imessage", + "IMessageConfigSchema", + ), +); +export const TelegramConfigSchema = createLazyFacadeObjectValue< + BundledObjectConfigSchema> +>(() => + loadBundledConfigSchema>( + "telegram", + "TelegramConfigSchema", + ), +); export { GoogleChatConfigSchema } from "../config/zod-schema.providers-googlechat.js"; export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; diff --git a/src/plugin-sdk/channel-config-schema.ts b/src/plugin-sdk/channel-config-schema.ts index 7b0142b8b463..5e35ecd21ca8 100644 --- a/src/plugin-sdk/channel-config-schema.ts +++ b/src/plugin-sdk/channel-config-schema.ts @@ -50,3 +50,5 @@ export { } from "../config/zod-schema.channel-messaging-common.js"; export { ChannelImplicitMentionsSchema } from "../config/zod-schema.implicit-mentions.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; +export { isSafeScpRemoteHost } from "../infra/scp-host.js"; +export { isValidInboundPathRootPattern } from "@openclaw/media-core/inbound-path-policy"; diff --git a/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts b/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts index f251a86912ad..28c61fbedb0f 100644 --- a/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts +++ b/src/plugins/contracts/bundled-extension-config-api-guardrails.test.ts @@ -10,8 +10,8 @@ const REPO_ROOT = resolve(SRC_ROOT, ".."); const BUNDLED_EXTENSION_CONFIG_IMPORT_GUARDS = [ { path: "extensions/telegram/src/config-schema.ts", - allowedSpecifier: "../config-api.js", - forbiddenSpecifier: "openclaw/plugin-sdk/channel-config-schema", + allowedSpecifier: "openclaw/plugin-sdk/channel-config-schema", + forbiddenSpecifier: "openclaw/plugin-sdk/bundled-channel-config-schema", }, { path: "extensions/discord/src/config-schema.ts", @@ -30,8 +30,8 @@ const BUNDLED_EXTENSION_CONFIG_IMPORT_GUARDS = [ }, { path: "extensions/imessage/src/config-schema.ts", - allowedSpecifier: "../config-api.js", - forbiddenSpecifier: "openclaw/plugin-sdk/channel-config-schema", + allowedSpecifier: "openclaw/plugin-sdk/channel-config-schema", + forbiddenSpecifier: "openclaw/plugin-sdk/bundled-channel-config-schema", }, { path: "extensions/whatsapp/src/config-schema.ts", diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index fee82909e80c..723907eb03b1 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -140,16 +140,16 @@ describe("config footprint guardrails", () => { }); it("keeps canonical nested streaming paths in channel-owned schemas", () => { - const source = readSource("src/config/zod-schema.providers-core.ts"); + const telegramSource = readSource("extensions/telegram/src/config-schema.ts"); const discordSource = readSource("extensions/discord/src/config-schema.ts"); const msTeamsSource = readSource("extensions/msteams/src/config-schema.ts"); const slackSource = readSource("extensions/slack/src/config-schema.ts"); - expect(source).toContain("streaming: TelegramPreviewStreamingConfigSchema.optional(),"); + expect(telegramSource).toContain("streaming: TelegramPreviewStreamingConfigSchema.optional(),"); expect(discordSource).toContain("streaming: DiscordPreviewStreamingConfigSchema.optional(),"); expect(msTeamsSource).toContain("streaming: ChannelPreviewStreamingConfigSchema.optional(),"); expect(slackSource).toContain("streaming: SlackStreamingConfigSchema.optional(),"); - for (const schemaSource of [source, discordSource, msTeamsSource, slackSource]) { + for (const schemaSource of [telegramSource, discordSource, msTeamsSource, slackSource]) { expect(schemaSource).not.toContain( 'streamMode: z.enum(["replace", "status_final", "append"])', ); @@ -196,18 +196,23 @@ describe("config footprint guardrails", () => { ); const bundledSchemaExportBlocks = Array.from( bundledSection.matchAll( - /export \{(?[^}]*)\} from "\.\.\/config\/zod-schema\.providers-(?:core|googlechat|whatsapp)\.js";/g, + /export \{(?[^}]*)\} from "\.\.\/config\/zod-schema\.providers-(?:googlechat|whatsapp)\.js";/g, ), ) .map((match) => match.groups?.exports) .filter((block): block is string => Boolean(block)); - expect(bundledSchemaExportBlocks).toHaveLength(3); - const exportedSchemaNames = Array.from( - bundledSchemaExportBlocks.join("\n").matchAll(/\b([A-Z][A-Za-z0-9]+ConfigSchema)\b/g), - ) - .map((match) => match[1]) - .filter((name): name is string => Boolean(name)) - .toSorted((left, right) => left.localeCompare(right)); + expect(bundledSchemaExportBlocks).toHaveLength(2); + const lazySchemaNames = Array.from( + bundledSection.matchAll(/\bexport const ([A-Z][A-Za-z0-9]+ConfigSchema)\b/g), + (match) => match[1], + ).filter((name): name is string => Boolean(name)); + const exportedSchemaNames = [ + ...Array.from( + bundledSchemaExportBlocks.join("\n").matchAll(/\b([A-Z][A-Za-z0-9]+ConfigSchema)\b/g), + (match) => match[1], + ).filter((name): name is string => Boolean(name)), + ...lazySchemaNames, + ].toSorted((left, right) => left.localeCompare(right)); expect(exportedSchemaNames).toEqual([ "GoogleChatConfigSchema", @@ -220,6 +225,12 @@ describe("config footprint guardrails", () => { } expect(bundledSource).toContain("Bundled-channel config schemas"); expect(bundledSource).toContain("openclaw/plugin-sdk/channel-config-schema"); + expect(bundledSource).toMatch( + /loadBundledConfigSchema<[^;]+?>\(\s*"imessage",\s*"IMessageConfigSchema",?\s*\)/u, + ); + expect(bundledSource).toMatch( + /loadBundledConfigSchema<[^;]+?>\(\s*"telegram",\s*"TelegramConfigSchema",?\s*\)/u, + ); // The primitives facade re-exports the canonical channel-config-schema // module; only bundled provider schemas bypass it. const primitivesSource = readSource("src/plugin-sdk/channel-config-primitives.ts"); @@ -234,6 +245,8 @@ describe("config footprint guardrails", () => { // channel-config-schema is the canonical internal module; the primitives // and bundled shells stay export-compatible for plugins only. const allowedShellImporters = new Set([ + // The facade's focused regression test is its only internal consumer. + "src/plugin-sdk/bundled-channel-config-schema.test.ts", // This guardrail file embeds facade specifiers in shell-shape assertions. "src/plugins/contracts/config-footprint-guardrails.test.ts", ]); diff --git a/src/shared/custom-command-config.ts b/src/shared/custom-command-config.ts deleted file mode 100644 index 5853d5e348c1..000000000000 --- a/src/shared/custom-command-config.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Custom command config helpers normalize command configuration records. -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; - -/** Raw custom slash-command entry from config. */ -type CustomCommandInput = { - command?: string | null; - description?: string | null; -}; - -/** Validation issue for one configured custom command. */ -type CustomCommandIssue = { - index: number; - field: "command" | "description"; - message: string; -}; - -/** Command validation policy for one command family. */ -type CustomCommandConfig = { - label: string; - pattern: RegExp; - patternDescription: string; - prefix?: string; -}; - -const DEFAULT_PREFIX = "/"; - -/** Normalize a slash command name to the internal lowercase underscore form. */ -export function normalizeSlashCommandName(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - const withoutSlash = trimmed.startsWith(DEFAULT_PREFIX) ? trimmed.slice(1) : trimmed; - return normalizeLowercaseStringOrEmpty(withoutSlash).replace(/-/g, "_"); -} - -/** Normalize command descriptions without changing user-authored wording. */ -export function normalizeCommandDescription(value: string): string { - return value.trim(); -} - -/** Validate and normalize custom command config entries. */ -export function resolveCustomCommands(params: { - commands?: CustomCommandInput[] | null; - reservedCommands?: Set; - checkReserved?: boolean; - checkDuplicates?: boolean; - config: CustomCommandConfig; -}): { - commands: Array<{ command: string; description: string }>; - issues: CustomCommandIssue[]; -} { - const entries = Array.isArray(params.commands) ? params.commands : []; - const reserved = params.reservedCommands ?? new Set(); - const checkReserved = params.checkReserved !== false; - const checkDuplicates = params.checkDuplicates !== false; - const seen = new Set(); - const resolved: Array<{ command: string; description: string }> = []; - const issues: CustomCommandIssue[] = []; - const label = params.config.label; - const prefix = params.config.prefix ?? DEFAULT_PREFIX; - - for (let index = 0; index < entries.length; index += 1) { - const entry = entries[index]; - // Accumulate issues instead of throwing so config UIs and CLIs can present - // all invalid commands in one pass. - const normalized = normalizeSlashCommandName(entry?.command ?? ""); - if (!normalized) { - issues.push({ - index, - field: "command", - message: `${label} custom command is missing a command name.`, - }); - continue; - } - if (!params.config.pattern.test(normalized)) { - issues.push({ - index, - field: "command", - message: `${label} custom command "${prefix}${normalized}" is invalid (${params.config.patternDescription}).`, - }); - continue; - } - if (checkReserved && reserved.has(normalized)) { - issues.push({ - index, - field: "command", - message: `${label} custom command "${prefix}${normalized}" conflicts with a native command.`, - }); - continue; - } - if (checkDuplicates && seen.has(normalized)) { - issues.push({ - index, - field: "command", - message: `${label} custom command "${prefix}${normalized}" is duplicated.`, - }); - continue; - } - const description = normalizeCommandDescription(entry?.description ?? ""); - if (!description) { - issues.push({ - index, - field: "description", - message: `${label} custom command "${prefix}${normalized}" is missing a description.`, - }); - continue; - } - if (checkDuplicates) { - seen.add(normalized); - } - resolved.push({ command: normalized, description }); - } - - return { commands: resolved, issues }; -}