From b7c8c53af20fbe63dd2ff1237b492108cdd01895 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 17:17:10 -0700 Subject: [PATCH] docs(plugins): define config ownership contract * fix(plugins): flag channel config metadata gaps * docs(plugins): clarify config ownership --- docs/gateway/configuration-reference.md | 1 + docs/plugins/manifest.md | 11 ++++ docs/plugins/sdk-channel-plugins.md | 19 +++++- docs/plugins/sdk-migration.md | 2 +- docs/plugins/sdk-overview.md | 6 ++ docs/plugins/sdk-setup.md | 11 +++- src/plugin-sdk/channel-config-schema.ts | 3 + .../config-footprint-guardrails.test.ts | 45 ++++++++++++++ src/plugins/manifest-registry.test.ts | 61 +++++++++++++++++++ src/plugins/manifest-registry.ts | 40 +++++++++++- 10 files changed, 190 insertions(+), 9 deletions(-) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index d425412aef5..7801e1ad6b9 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -122,6 +122,7 @@ provider / base-URL setup moved to a dedicated page — see - `plugins.entries..subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. - `plugins.entries..subagent.allowedModels`: optional allowlist of canonical `provider/model` targets for trusted subagent overrides. Use `"*"` only when you intentionally want to allow any model. - `plugins.entries..config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). +- Channel plugin account/runtime settings live under `channels.` and should be described by the owning plugin's manifest `channelConfigs` metadata, not by a central OpenClaw option registry. - `plugins.entries.firecrawl.config.webFetch`: Firecrawl web-fetch provider settings. - `apiKey`: Firecrawl API key (accepts SecretRef). Falls back to `plugins.entries.firecrawl.config.webSearch.apiKey`, legacy `tools.web.fetch.firecrawl.apiKey`, or `FIRECRAWL_API_KEY` env var. - `baseUrl`: Firecrawl API base URL (default: `https://api.firecrawl.dev`). diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 1cca08c9919..036d37f24ad 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -507,6 +507,17 @@ runtime loads. Read-only channel setup/status discovery can use this metadata directly for configured external channels when no setup entry is available, or when `setup.requiresRuntime: false` declares setup runtime unnecessary. +For a channel plugin, `configSchema` and `channelConfigs` describe different +paths: + +- `configSchema` validates `plugins.entries..config` +- `channelConfigs..schema` validates `channels.` + +Non-bundled plugins that declare `channels[]` should also declare matching +`channelConfigs` entries. Without them, OpenClaw can still load the plugin, but +cold-path config schema, setup, and Control UI surfaces cannot know the +channel-owned option shape until plugin runtime executes. + ```json { "channelConfigs": { diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 6ea998874df..b4939dd20b5 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -322,9 +322,13 @@ should use `resolveInboundMentionDecision({ facts, policy })`. "configSchema": { "type": "object", "additionalProperties": false, - "properties": { - "acme-chat": { + "properties": {} + }, + "channelConfigs": { + "acme-chat": { + "schema": { "type": "object", + "additionalProperties": false, "properties": { "token": { "type": "string" }, "allowFrom": { @@ -332,6 +336,12 @@ should use `resolveInboundMentionDecision({ facts, policy })`. "items": { "type": "string" } } } + }, + "uiHints": { + "token": { + "label": "Bot token", + "sensitive": true + } } } } @@ -339,6 +349,11 @@ should use `resolveInboundMentionDecision({ facts, policy })`. ``` + `configSchema` validates `plugins.entries.acme-chat.config`. Use it for + plugin-owned settings that are not the channel account config. `channelConfigs` + validates `channels.acme-chat` and is the cold-path source used by config + schema, setup, and UI surfaces before the plugin runtime loads. + diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 65c54ec6630..262c1ad1d4f 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -260,7 +260,7 @@ releases. | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | | `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` | - | `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types | + | `plugin-sdk/channel-config-schema` | Config schema builders | Shared channel config schema primitives; bundled-channel-named schema exports are legacy compatibility only | | `plugin-sdk/telegram-command-config` | Telegram command config helpers | Command-name normalization, description trimming, duplicate/conflict validation | | `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` | | `plugin-sdk/channel-lifecycle` | Account status and draft stream lifecycle helpers | `createAccountStatusSink`, draft preview finalization helpers | diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 7569eb41c40..f1671fe0911 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -35,6 +35,12 @@ prefer `openclaw/plugin-sdk/channel-core`; keep `openclaw/plugin-sdk/core` for the broader umbrella surface and shared helpers such as `buildChannelConfigSchema`. +For channel config, publish the channel-owned JSON Schema through +`openclaw.plugin.json#channelConfigs`. The `plugin-sdk/channel-config-schema` +subpath is for shared schema primitives and the generic builder. Any +bundled-channel-named schema exports on that subpath are legacy compatibility +exports, not a pattern for new plugins. + Do not import provider- or channel-branded convenience seams (for example `openclaw/plugin-sdk/slack`, `.../discord`, `.../signal`, `.../whatsapp`). diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 57d36d716b7..8d2e53a3af7 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -409,12 +409,12 @@ For channel-specific config, use the channel config section instead: ### Building channel config schemas -Use `buildChannelConfigSchema` from `openclaw/plugin-sdk/core` to convert a -Zod schema into the `ChannelConfigSchema` wrapper that OpenClaw validates: +Use `buildChannelConfigSchema` to convert a Zod schema into the +`ChannelConfigSchema` wrapper used by plugin-owned config artifacts: ```typescript import { z } from "zod"; -import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core"; +import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; const accountSchema = z.object({ token: z.string().optional(), @@ -426,6 +426,11 @@ const accountSchema = z.object({ const configSchema = buildChannelConfigSchema(accountSchema); ``` +For third-party plugins, the cold-path contract is still the plugin manifest: +mirror the generated JSON Schema into `openclaw.plugin.json#channelConfigs` so +config schema, setup, and UI surfaces can inspect `channels.` without +loading runtime code. + ## Setup wizards Channel plugins can provide interactive setup wizards for `openclaw onboard`. diff --git a/src/plugin-sdk/channel-config-schema.ts b/src/plugin-sdk/channel-config-schema.ts index 489483aeec0..152db2f8ca0 100644 --- a/src/plugin-sdk/channel-config-schema.ts +++ b/src/plugin-sdk/channel-config-schema.ts @@ -16,6 +16,9 @@ export { requireOpenAllowFrom, } from "../config/zod-schema.core.js"; export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js"; +// Legacy bundled channel schema exports. New channel plugins should define +// plugin-local schemas and expose JSON Schema through openclaw.plugin.json +// channelConfigs or a lightweight plugin-owned config artifact. export { DiscordConfigSchema, GoogleChatConfigSchema, diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 566754cd355..7561ecfeb9a 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -49,7 +49,24 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] { return out; } +function asRecord(value: unknown): Record { + expect(value && typeof value === "object" && !Array.isArray(value)).toBe(true); + return value as Record; +} + describe("config footprint guardrails", () => { + it("keeps plugin entry config generic in the generated base schema", () => { + const root = asRecord(GENERATED_BASE_CONFIG_SCHEMA.schema); + const plugins = asRecord(asRecord(root.properties).plugins); + const entries = asRecord(asRecord(plugins.properties).entries); + const entry = asRecord(entries.additionalProperties); + const pluginConfig = asRecord(asRecord(entry.properties).config); + + expect(pluginConfig.type).toBe("object"); + expect(pluginConfig.additionalProperties).toEqual({}); + expect(pluginConfig.properties).toBeUndefined(); + }); + it("keeps retired legacy paths out of the generated base config schema", () => { const basePaths = new Set(collectSchemaPaths(GENERATED_BASE_CONFIG_SCHEMA.schema)); @@ -144,4 +161,32 @@ describe("config footprint guardrails", () => { "return ssrfPolicyFromDangerouslyAllowPrivateNetwork(allowPrivateNetwork);", ); }); + + it("keeps bundled channel schemas as a fixed legacy SDK compatibility surface", () => { + const source = readSource("src/plugin-sdk/channel-config-schema.ts"); + const providersCoreExports = source.match( + /Legacy bundled channel schema exports[\s\S]*?export \{(?[\s\S]*?)\} from "\.\.\/config\/zod-schema\.providers-core\.js";/, + )?.groups?.exports; + expect(providersCoreExports).toBeDefined(); + const exportedSchemaNames = Array.from( + `${providersCoreExports ?? ""}\nWhatsAppConfigSchema`.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(exportedSchemaNames).toEqual([ + "DiscordConfigSchema", + "GoogleChatConfigSchema", + "IMessageConfigSchema", + "MSTeamsConfigSchema", + "SignalConfigSchema", + "SlackConfigSchema", + "TelegramConfigSchema", + "WhatsAppConfigSchema", + ]); + expect(source).toContain("Legacy bundled channel schema exports"); + }); }); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 2984ae84f94..bd8b2bf8606 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -510,6 +510,67 @@ describe("loadPluginManifestRegistry", () => { ); }); + it("reports non-bundled channel manifests without channel config descriptors", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "external-chat", + channels: ["external-chat"], + configSchema: { type: "object" }, + }); + + const registry = loadSingleCandidateRegistry({ + idHint: "external-chat", + rootDir: dir, + origin: "global", + }); + + expect(registry.plugins[0]?.channels).toEqual(["external-chat"]); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "warn", + pluginId: "external-chat", + source: path.join(dir, "openclaw.plugin.json"), + message: expect.stringContaining("without channelConfigs metadata"), + }), + ); + }); + + it("accepts non-bundled channel manifests with channel config descriptors", () => { + const dir = makeTempDir(); + writeManifest(dir, { + id: "external-chat", + channels: ["external-chat"], + configSchema: { type: "object" }, + channelConfigs: { + "external-chat": { + schema: { + type: "object", + additionalProperties: false, + properties: { + token: { type: "string" }, + }, + }, + }, + }, + }); + + const registry = loadSingleCandidateRegistry({ + idHint: "external-chat", + rootDir: dir, + origin: "global", + }); + + expect(registry.plugins[0]?.channelConfigs?.["external-chat"]?.schema).toMatchObject({ + type: "object", + additionalProperties: false, + }); + expect( + registry.diagnostics.some((diagnostic) => + diagnostic.message.includes("without channelConfigs metadata"), + ), + ).toBe(false); + }); + it("falls back providerDiscoverySource from .ts to emitted .js files", () => { const dir = makeTempDir(); writeManifest(dir, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 07087d85fe0..04e544f5fa9 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -474,6 +474,40 @@ function pushProviderAuthEnvVarsCompatDiagnostic(params: { }); } +function pushNonBundledChannelConfigDescriptorDiagnostic(params: { + record: PluginManifestRecord; + diagnostics: PluginDiagnostic[]; +}): void { + if (params.record.origin === "bundled" || params.record.format === "bundle") { + return; + } + const declaredChannels = params.record.channels + .map((channelId) => channelId.trim()) + .filter((channelId) => channelId.length > 0); + if (declaredChannels.length === 0) { + return; + } + const channelConfigs = params.record.channelConfigs ?? {}; + const missingChannels = declaredChannels.filter((channelId) => !channelConfigs[channelId]); + if (missingChannels.length === 0) { + return; + } + params.diagnostics.push({ + level: "warn", + pluginId: params.record.id, + source: params.record.manifestPath, + message: `channel plugin manifest declares ${missingChannels.join(", ")} without channelConfigs metadata; add openclaw.plugin.json#channelConfigs so config schema and setup surfaces work before runtime loads`, + }); +} + +function pushManifestCompatibilityDiagnostics(params: { + record: PluginManifestRecord; + diagnostics: PluginDiagnostic[]; +}): void { + pushProviderAuthEnvVarsCompatDiagnostic(params); + pushNonBundledChannelConfigDescriptorDiagnostic(params); +} + function matchesInstalledPluginRecord(params: { pluginId: string; candidate: PluginCandidate; @@ -666,7 +700,7 @@ export function loadPluginManifestRegistry( if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) { records[existing.recordIndex] = record; seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex }); - pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics }); + pushManifestCompatibilityDiagnostics({ record, diagnostics }); } continue; } @@ -689,7 +723,7 @@ export function loadPluginManifestRegistry( if (candidateWins) { records[existing.recordIndex] = record; seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex }); - pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics }); + pushManifestCompatibilityDiagnostics({ record, diagnostics }); } diagnostics.push({ level: "warn", @@ -702,7 +736,7 @@ export function loadPluginManifestRegistry( seenIds.set(manifest.id, { candidate, recordIndex: records.length }); records.push(record); - pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics }); + pushManifestCompatibilityDiagnostics({ record, diagnostics }); } const registry = { plugins: records, diagnostics };