mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:50:42 +00:00
docs(plugins): define config ownership contract
* fix(plugins): flag channel config metadata gaps * docs(plugins): clarify config ownership
This commit is contained in:
@@ -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`).
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user