docs(plugins): define config ownership contract

* fix(plugins): flag channel config metadata gaps

* docs(plugins): clarify config ownership
This commit is contained in:
Vincent Koc
2026-04-24 17:17:10 -07:00
committed by GitHub
parent de8a00d922
commit b7c8c53af2
10 changed files with 190 additions and 9 deletions

View File

@@ -122,6 +122,7 @@ provider / base-URL setup moved to a dedicated page — see
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs. - `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
- `plugins.entries.<id>.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.<id>.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.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available). - `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
- Channel plugin account/runtime settings live under `channels.<id>` 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. - `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. - `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`). - `baseUrl`: Firecrawl API base URL (default: `https://api.firecrawl.dev`).

View File

@@ -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 directly for configured external channels when no setup entry is available, or
when `setup.requiresRuntime: false` declares setup runtime unnecessary. when `setup.requiresRuntime: false` declares setup runtime unnecessary.
For a channel plugin, `configSchema` and `channelConfigs` describe different
paths:
- `configSchema` validates `plugins.entries.<plugin-id>.config`
- `channelConfigs.<channel-id>.schema` validates `channels.<channel-id>`
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 ```json
{ {
"channelConfigs": { "channelConfigs": {

View File

@@ -322,9 +322,13 @@ should use `resolveInboundMentionDecision({ facts, policy })`.
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {}
"acme-chat": { },
"channelConfigs": {
"acme-chat": {
"schema": {
"type": "object", "type": "object",
"additionalProperties": false,
"properties": { "properties": {
"token": { "type": "string" }, "token": { "type": "string" },
"allowFrom": { "allowFrom": {
@@ -332,6 +336,12 @@ should use `resolveInboundMentionDecision({ facts, policy })`.
"items": { "type": "string" } "items": { "type": "string" }
} }
} }
},
"uiHints": {
"token": {
"label": "Bot token",
"sensitive": true
}
} }
} }
} }
@@ -339,6 +349,11 @@ should use `resolveInboundMentionDecision({ facts, policy })`.
``` ```
</CodeGroup> </CodeGroup>
`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.
</Step> </Step>
<Step title="Build the channel plugin object"> <Step title="Build the channel plugin object">

View File

@@ -260,7 +260,7 @@ releases.
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` | | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` | | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` | | `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/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-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | Account status and draft stream lifecycle helpers | `createAccountStatusSink`, draft preview finalization helpers | | `plugin-sdk/channel-lifecycle` | Account status and draft stream lifecycle helpers | `createAccountStatusSink`, draft preview finalization helpers |

View File

@@ -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 the broader umbrella surface and shared helpers such as
`buildChannelConfigSchema`. `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.
<Warning> <Warning>
Do not import provider- or channel-branded convenience seams (for example Do not import provider- or channel-branded convenience seams (for example
`openclaw/plugin-sdk/slack`, `.../discord`, `.../signal`, `.../whatsapp`). `openclaw/plugin-sdk/slack`, `.../discord`, `.../signal`, `.../whatsapp`).

View File

@@ -409,12 +409,12 @@ For channel-specific config, use the channel config section instead:
### Building channel config schemas ### Building channel config schemas
Use `buildChannelConfigSchema` from `openclaw/plugin-sdk/core` to convert a Use `buildChannelConfigSchema` to convert a Zod schema into the
Zod schema into the `ChannelConfigSchema` wrapper that OpenClaw validates: `ChannelConfigSchema` wrapper used by plugin-owned config artifacts:
```typescript ```typescript
import { z } from "zod"; import { z } from "zod";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
const accountSchema = z.object({ const accountSchema = z.object({
token: z.string().optional(), token: z.string().optional(),
@@ -426,6 +426,11 @@ const accountSchema = z.object({
const configSchema = buildChannelConfigSchema(accountSchema); 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.<id>` without
loading runtime code.
## Setup wizards ## Setup wizards
Channel plugins can provide interactive setup wizards for `openclaw onboard`. Channel plugins can provide interactive setup wizards for `openclaw onboard`.

View File

@@ -16,6 +16,9 @@ export {
requireOpenAllowFrom, requireOpenAllowFrom,
} from "../config/zod-schema.core.js"; } from "../config/zod-schema.core.js";
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.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 { export {
DiscordConfigSchema, DiscordConfigSchema,
GoogleChatConfigSchema, GoogleChatConfigSchema,

View File

@@ -49,7 +49,24 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] {
return out; return out;
} }
function asRecord(value: unknown): Record<string, unknown> {
expect(value && typeof value === "object" && !Array.isArray(value)).toBe(true);
return value as Record<string, unknown>;
}
describe("config footprint guardrails", () => { 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", () => { it("keeps retired legacy paths out of the generated base config schema", () => {
const basePaths = new Set(collectSchemaPaths(GENERATED_BASE_CONFIG_SCHEMA.schema)); const basePaths = new Set(collectSchemaPaths(GENERATED_BASE_CONFIG_SCHEMA.schema));
@@ -144,4 +161,32 @@ describe("config footprint guardrails", () => {
"return ssrfPolicyFromDangerouslyAllowPrivateNetwork(allowPrivateNetwork);", "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 \{(?<exports>[\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");
});
}); });

View File

@@ -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", () => { it("falls back providerDiscoverySource from .ts to emitted .js files", () => {
const dir = makeTempDir(); const dir = makeTempDir();
writeManifest(dir, { writeManifest(dir, {

View File

@@ -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: { function matchesInstalledPluginRecord(params: {
pluginId: string; pluginId: string;
candidate: PluginCandidate; candidate: PluginCandidate;
@@ -666,7 +700,7 @@ export function loadPluginManifestRegistry(
if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) { if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) {
records[existing.recordIndex] = record; records[existing.recordIndex] = record;
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex }); seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics }); pushManifestCompatibilityDiagnostics({ record, diagnostics });
} }
continue; continue;
} }
@@ -689,7 +723,7 @@ export function loadPluginManifestRegistry(
if (candidateWins) { if (candidateWins) {
records[existing.recordIndex] = record; records[existing.recordIndex] = record;
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex }); seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics }); pushManifestCompatibilityDiagnostics({ record, diagnostics });
} }
diagnostics.push({ diagnostics.push({
level: "warn", level: "warn",
@@ -702,7 +736,7 @@ export function loadPluginManifestRegistry(
seenIds.set(manifest.id, { candidate, recordIndex: records.length }); seenIds.set(manifest.id, { candidate, recordIndex: records.length });
records.push(record); records.push(record);
pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics }); pushManifestCompatibilityDiagnostics({ record, diagnostics });
} }
const registry = { plugins: records, diagnostics }; const registry = { plugins: records, diagnostics };