Skip to content

Commit b7c8c53

Browse files
authored
docs(plugins): define config ownership contract
* fix(plugins): flag channel config metadata gaps * docs(plugins): clarify config ownership
1 parent de8a00d commit b7c8c53

10 files changed

Lines changed: 190 additions & 9 deletions

docs/gateway/configuration-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ provider / base-URL setup moved to a dedicated page — see
122122
- `plugins.entries.<id>.subagent.allowModelOverride`: explicitly trust this plugin to request per-run `provider` and `model` overrides for background subagent runs.
123123
- `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.
124124
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
125+
- 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.
125126
- `plugins.entries.firecrawl.config.webFetch`: Firecrawl web-fetch provider settings.
126127
- `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.
127128
- `baseUrl`: Firecrawl API base URL (default: `https://api.firecrawl.dev`).

docs/plugins/manifest.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,17 @@ runtime loads. Read-only channel setup/status discovery can use this metadata
507507
directly for configured external channels when no setup entry is available, or
508508
when `setup.requiresRuntime: false` declares setup runtime unnecessary.
509509

510+
For a channel plugin, `configSchema` and `channelConfigs` describe different
511+
paths:
512+
513+
- `configSchema` validates `plugins.entries.<plugin-id>.config`
514+
- `channelConfigs.<channel-id>.schema` validates `channels.<channel-id>`
515+
516+
Non-bundled plugins that declare `channels[]` should also declare matching
517+
`channelConfigs` entries. Without them, OpenClaw can still load the plugin, but
518+
cold-path config schema, setup, and Control UI surfaces cannot know the
519+
channel-owned option shape until plugin runtime executes.
520+
510521
```json
511522
{
512523
"channelConfigs": {

docs/plugins/sdk-channel-plugins.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,23 +322,38 @@ should use `resolveInboundMentionDecision({ facts, policy })`.
322322
"configSchema": {
323323
"type": "object",
324324
"additionalProperties": false,
325-
"properties": {
326-
"acme-chat": {
325+
"properties": {}
326+
},
327+
"channelConfigs": {
328+
"acme-chat": {
329+
"schema": {
327330
"type": "object",
331+
"additionalProperties": false,
328332
"properties": {
329333
"token": { "type": "string" },
330334
"allowFrom": {
331335
"type": "array",
332336
"items": { "type": "string" }
333337
}
334338
}
339+
},
340+
"uiHints": {
341+
"token": {
342+
"label": "Bot token",
343+
"sensitive": true
344+
}
335345
}
336346
}
337347
}
338348
}
339349
```
340350
</CodeGroup>
341351

352+
`configSchema` validates `plugins.entries.acme-chat.config`. Use it for
353+
plugin-owned settings that are not the channel account config. `channelConfigs`
354+
validates `channels.acme-chat` and is the cold-path source used by config
355+
schema, setup, and UI surfaces before the plugin runtime loads.
356+
342357
</Step>
343358

344359
<Step title="Build the channel plugin object">

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ releases.
260260
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
261261
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
262262
| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` |
263-
| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
263+
| `plugin-sdk/channel-config-schema` | Config schema builders | Shared channel config schema primitives; bundled-channel-named schema exports are legacy compatibility only |
264264
| `plugin-sdk/telegram-command-config` | Telegram command config helpers | Command-name normalization, description trimming, duplicate/conflict validation |
265265
| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
266266
| `plugin-sdk/channel-lifecycle` | Account status and draft stream lifecycle helpers | `createAccountStatusSink`, draft preview finalization helpers |

docs/plugins/sdk-overview.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ prefer `openclaw/plugin-sdk/channel-core`; keep `openclaw/plugin-sdk/core` for
3535
the broader umbrella surface and shared helpers such as
3636
`buildChannelConfigSchema`.
3737

38+
For channel config, publish the channel-owned JSON Schema through
39+
`openclaw.plugin.json#channelConfigs`. The `plugin-sdk/channel-config-schema`
40+
subpath is for shared schema primitives and the generic builder. Any
41+
bundled-channel-named schema exports on that subpath are legacy compatibility
42+
exports, not a pattern for new plugins.
43+
3844
<Warning>
3945
Do not import provider- or channel-branded convenience seams (for example
4046
`openclaw/plugin-sdk/slack`, `.../discord`, `.../signal`, `.../whatsapp`).

docs/plugins/sdk-setup.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,12 +409,12 @@ For channel-specific config, use the channel config section instead:
409409

410410
### Building channel config schemas
411411

412-
Use `buildChannelConfigSchema` from `openclaw/plugin-sdk/core` to convert a
413-
Zod schema into the `ChannelConfigSchema` wrapper that OpenClaw validates:
412+
Use `buildChannelConfigSchema` to convert a Zod schema into the
413+
`ChannelConfigSchema` wrapper used by plugin-owned config artifacts:
414414

415415
```typescript
416416
import { z } from "zod";
417-
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
417+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
418418

419419
const accountSchema = z.object({
420420
token: z.string().optional(),
@@ -426,6 +426,11 @@ const accountSchema = z.object({
426426
const configSchema = buildChannelConfigSchema(accountSchema);
427427
```
428428

429+
For third-party plugins, the cold-path contract is still the plugin manifest:
430+
mirror the generated JSON Schema into `openclaw.plugin.json#channelConfigs` so
431+
config schema, setup, and UI surfaces can inspect `channels.<id>` without
432+
loading runtime code.
433+
429434
## Setup wizards
430435

431436
Channel plugins can provide interactive setup wizards for `openclaw onboard`.

src/plugin-sdk/channel-config-schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export {
1616
requireOpenAllowFrom,
1717
} from "../config/zod-schema.core.js";
1818
export { ToolPolicySchema } from "../config/zod-schema.agent-runtime.js";
19+
// Legacy bundled channel schema exports. New channel plugins should define
20+
// plugin-local schemas and expose JSON Schema through openclaw.plugin.json
21+
// channelConfigs or a lightweight plugin-owned config artifact.
1922
export {
2023
DiscordConfigSchema,
2124
GoogleChatConfigSchema,

src/plugins/contracts/config-footprint-guardrails.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,24 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] {
4949
return out;
5050
}
5151

52+
function asRecord(value: unknown): Record<string, unknown> {
53+
expect(value && typeof value === "object" && !Array.isArray(value)).toBe(true);
54+
return value as Record<string, unknown>;
55+
}
56+
5257
describe("config footprint guardrails", () => {
58+
it("keeps plugin entry config generic in the generated base schema", () => {
59+
const root = asRecord(GENERATED_BASE_CONFIG_SCHEMA.schema);
60+
const plugins = asRecord(asRecord(root.properties).plugins);
61+
const entries = asRecord(asRecord(plugins.properties).entries);
62+
const entry = asRecord(entries.additionalProperties);
63+
const pluginConfig = asRecord(asRecord(entry.properties).config);
64+
65+
expect(pluginConfig.type).toBe("object");
66+
expect(pluginConfig.additionalProperties).toEqual({});
67+
expect(pluginConfig.properties).toBeUndefined();
68+
});
69+
5370
it("keeps retired legacy paths out of the generated base config schema", () => {
5471
const basePaths = new Set(collectSchemaPaths(GENERATED_BASE_CONFIG_SCHEMA.schema));
5572

@@ -144,4 +161,32 @@ describe("config footprint guardrails", () => {
144161
"return ssrfPolicyFromDangerouslyAllowPrivateNetwork(allowPrivateNetwork);",
145162
);
146163
});
164+
165+
it("keeps bundled channel schemas as a fixed legacy SDK compatibility surface", () => {
166+
const source = readSource("src/plugin-sdk/channel-config-schema.ts");
167+
const providersCoreExports = source.match(
168+
/Legacy bundled channel schema exports[\s\S]*?export \{(?<exports>[\s\S]*?)\} from "\.\.\/config\/zod-schema\.providers-core\.js";/,
169+
)?.groups?.exports;
170+
expect(providersCoreExports).toBeDefined();
171+
const exportedSchemaNames = Array.from(
172+
`${providersCoreExports ?? ""}\nWhatsAppConfigSchema`.matchAll(
173+
/\b([A-Z][A-Za-z0-9]+ConfigSchema)\b/g,
174+
),
175+
)
176+
.map((match) => match[1])
177+
.filter((name): name is string => Boolean(name))
178+
.toSorted((left, right) => left.localeCompare(right));
179+
180+
expect(exportedSchemaNames).toEqual([
181+
"DiscordConfigSchema",
182+
"GoogleChatConfigSchema",
183+
"IMessageConfigSchema",
184+
"MSTeamsConfigSchema",
185+
"SignalConfigSchema",
186+
"SlackConfigSchema",
187+
"TelegramConfigSchema",
188+
"WhatsAppConfigSchema",
189+
]);
190+
expect(source).toContain("Legacy bundled channel schema exports");
191+
});
147192
});

src/plugins/manifest-registry.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,67 @@ describe("loadPluginManifestRegistry", () => {
510510
);
511511
});
512512

513+
it("reports non-bundled channel manifests without channel config descriptors", () => {
514+
const dir = makeTempDir();
515+
writeManifest(dir, {
516+
id: "external-chat",
517+
channels: ["external-chat"],
518+
configSchema: { type: "object" },
519+
});
520+
521+
const registry = loadSingleCandidateRegistry({
522+
idHint: "external-chat",
523+
rootDir: dir,
524+
origin: "global",
525+
});
526+
527+
expect(registry.plugins[0]?.channels).toEqual(["external-chat"]);
528+
expect(registry.diagnostics).toContainEqual(
529+
expect.objectContaining({
530+
level: "warn",
531+
pluginId: "external-chat",
532+
source: path.join(dir, "openclaw.plugin.json"),
533+
message: expect.stringContaining("without channelConfigs metadata"),
534+
}),
535+
);
536+
});
537+
538+
it("accepts non-bundled channel manifests with channel config descriptors", () => {
539+
const dir = makeTempDir();
540+
writeManifest(dir, {
541+
id: "external-chat",
542+
channels: ["external-chat"],
543+
configSchema: { type: "object" },
544+
channelConfigs: {
545+
"external-chat": {
546+
schema: {
547+
type: "object",
548+
additionalProperties: false,
549+
properties: {
550+
token: { type: "string" },
551+
},
552+
},
553+
},
554+
},
555+
});
556+
557+
const registry = loadSingleCandidateRegistry({
558+
idHint: "external-chat",
559+
rootDir: dir,
560+
origin: "global",
561+
});
562+
563+
expect(registry.plugins[0]?.channelConfigs?.["external-chat"]?.schema).toMatchObject({
564+
type: "object",
565+
additionalProperties: false,
566+
});
567+
expect(
568+
registry.diagnostics.some((diagnostic) =>
569+
diagnostic.message.includes("without channelConfigs metadata"),
570+
),
571+
).toBe(false);
572+
});
573+
513574
it("falls back providerDiscoverySource from .ts to emitted .js files", () => {
514575
const dir = makeTempDir();
515576
writeManifest(dir, {

src/plugins/manifest-registry.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,40 @@ function pushProviderAuthEnvVarsCompatDiagnostic(params: {
474474
});
475475
}
476476

477+
function pushNonBundledChannelConfigDescriptorDiagnostic(params: {
478+
record: PluginManifestRecord;
479+
diagnostics: PluginDiagnostic[];
480+
}): void {
481+
if (params.record.origin === "bundled" || params.record.format === "bundle") {
482+
return;
483+
}
484+
const declaredChannels = params.record.channels
485+
.map((channelId) => channelId.trim())
486+
.filter((channelId) => channelId.length > 0);
487+
if (declaredChannels.length === 0) {
488+
return;
489+
}
490+
const channelConfigs = params.record.channelConfigs ?? {};
491+
const missingChannels = declaredChannels.filter((channelId) => !channelConfigs[channelId]);
492+
if (missingChannels.length === 0) {
493+
return;
494+
}
495+
params.diagnostics.push({
496+
level: "warn",
497+
pluginId: params.record.id,
498+
source: params.record.manifestPath,
499+
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`,
500+
});
501+
}
502+
503+
function pushManifestCompatibilityDiagnostics(params: {
504+
record: PluginManifestRecord;
505+
diagnostics: PluginDiagnostic[];
506+
}): void {
507+
pushProviderAuthEnvVarsCompatDiagnostic(params);
508+
pushNonBundledChannelConfigDescriptorDiagnostic(params);
509+
}
510+
477511
function matchesInstalledPluginRecord(params: {
478512
pluginId: string;
479513
candidate: PluginCandidate;
@@ -666,7 +700,7 @@ export function loadPluginManifestRegistry(
666700
if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) {
667701
records[existing.recordIndex] = record;
668702
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
669-
pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics });
703+
pushManifestCompatibilityDiagnostics({ record, diagnostics });
670704
}
671705
continue;
672706
}
@@ -689,7 +723,7 @@ export function loadPluginManifestRegistry(
689723
if (candidateWins) {
690724
records[existing.recordIndex] = record;
691725
seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex });
692-
pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics });
726+
pushManifestCompatibilityDiagnostics({ record, diagnostics });
693727
}
694728
diagnostics.push({
695729
level: "warn",
@@ -702,7 +736,7 @@ export function loadPluginManifestRegistry(
702736

703737
seenIds.set(manifest.id, { candidate, recordIndex: records.length });
704738
records.push(record);
705-
pushProviderAuthEnvVarsCompatDiagnostic({ record, diagnostics });
739+
pushManifestCompatibilityDiagnostics({ record, diagnostics });
706740
}
707741

708742
const registry = { plugins: records, diagnostics };

0 commit comments

Comments
 (0)