Merge pull request #1272 from clawdbot/shadow/config-plugin-validation

Config: validate plugin config
This commit is contained in:
Peter Steinberger
2026-01-20 11:03:38 +00:00
committed by GitHub
49 changed files with 1817 additions and 377 deletions

View File

@@ -6,18 +6,12 @@ Docs: https://docs.clawd.bot
### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Gateway: raise default lane concurrency for main and sub-agent runs.
- Config: centralize default agent concurrency limits.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
### Fixes
- Cron: serialize scheduler operations per store path to prevent duplicate runs across hot reloads. (#1216) — thanks @carlulsoe.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Agents: treat OAuth refresh failures as auth errors to trigger model fallback. (#1261) — thanks @zknicker.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik.
- Agents: avoid misclassifying context-window-too-small errors as context overflow. (#1266) — thanks @humanwritten.
- Slack: resolve Bolt default-export shapes for monitor startup. (#1208) — thanks @24601.
## 2026.1.19-3

View File

@@ -11,6 +11,7 @@ Manage Gateway plugins/extensions (loaded in-process).
Related:
- Plugin system: [Plugins](/plugin)
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
- Security hardening: [Security](/gateway/security)
## Commands
@@ -28,6 +29,10 @@ clawdbot plugins update --all
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them.
All plugins must ship a `clawdbot.plugin.json` file with an inline JSON Schema
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
the plugin from loading and fail config validation.
### Install
```bash

View File

@@ -48,8 +48,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config
validation does not execute plugin code**; it uses the plugin manifest and JSON
Schema instead. See [Plugin manifest](/plugins/manifest).
Plugins can register:
- Gateway RPC methods
- Gateway HTTP handlers
@@ -83,6 +86,10 @@ Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way.
Each plugin must include a `clawdbot.plugin.json` file in its root. If a path
points at a file, the plugin root is the file's directory and must contain the
manifest.
If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored.
@@ -140,6 +147,14 @@ Fields:
Config changes **require a gateway restart**.
Validation rules (strict):
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
the channel id.
- Plugin config is validated using the JSON Schema embedded in
`clawdbot.plugin.json` (`configSchema`).
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
## Plugin slots (exclusive categories)
Some plugin categories are **exclusive** (only one active at a time). Use
@@ -169,22 +184,26 @@ Clawdbot augments `uiHints` at runtime based on discovered plugins:
`plugins.entries.<id>.config.<field>`
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive),
provide `configSchema.uiHints`.
provide `uiHints` alongside your JSON Schema in the plugin manifest.
Example:
```ts
export default {
id: "my-plugin",
configSchema: {
parse: (v) => v,
uiHints: {
"apiKey": { label: "API Key", sensitive: true },
"region": { label: "Region", placeholder: "us-east-1" },
},
```json
{
"id": "my-plugin",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": { "type": "string" },
"region": { "type": "string" }
}
},
register(api) {},
};
"uiHints": {
"apiKey": { "label": "API Key", "sensitive": true },
"region": { "label": "Region", "placeholder": "us-east-1" }
}
}
```
## CLI

63
docs/plugins/manifest.md Normal file
View File

@@ -0,0 +1,63 @@
---
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
read_when:
- You are building a Clawdbot plugin
- You need to ship a plugin config schema or debug plugin validation errors
---
# Plugin manifest (clawdbot.plugin.json)
Every plugin **must** ship a `clawdbot.plugin.json` file in the **plugin root**.
Clawdbot uses this manifest to validate configuration **without executing plugin
code**. Missing or invalid manifests are treated as plugin errors and block
config validation.
See the full plugin system guide: [Plugins](/plugin).
## Required fields
```json
{
"id": "voice-call",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
```
Required keys:
- `id` (string): canonical plugin id.
- `configSchema` (object): JSON Schema for plugin config (inline).
Optional keys:
- `kind` (string): plugin kind (example: `"memory"`).
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
- `providers` (array): provider ids registered by this plugin.
- `name` (string): display name for the plugin.
- `description` (string): short plugin summary.
- `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering.
- `version` (string): plugin version (informational).
## JSON Schema requirements
- **Every plugin must ship a JSON Schema**, even if it accepts no config.
- An empty schema is acceptable (for example, `{ "type": "object", "additionalProperties": false }`).
- Schemas are validated at config read/write time, not at runtime.
## Validation behavior
- Unknown `channels.*` keys are **errors**, unless the channel id is declared by
a plugin manifest.
- `plugins.entries.<id>`, `plugins.allow`, `plugins.deny`, and `plugins.slots.*`
must reference **discoverable** plugin ids. Unknown ids are **errors**.
- If a plugin is installed but has a broken or missing manifest or schema,
validation fails and Doctor reports the plugin error.
- If plugin config exists but the plugin is **disabled**, the config is kept and
a **warning** is surfaced in Doctor + logs.
## Notes
- The manifest is **required for all plugins**, including local filesystem loads.
- Runtime still loads the plugin module separately; the manifest is only for
discovery + validation.

View File

@@ -22,17 +22,20 @@ read_when:
- Unknown keys are validation errors (no passthrough at root or nested).
- `plugins.entries.<id>.config` must be validated by the plugins schema.
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
- Plugin manifests (`clawdbot.plugin.json`) are required for all plugins.
## Plugin schema enforcement
- Each plugin provides a strict schema for its config (no passthrough).
- Each plugin provides a strict JSON Schema for its config (inline in the manifest).
- Plugin load flow:
1) Resolve plugin schema by plugin id.
1) Resolve plugin manifest + schema (`clawdbot.plugin.json`).
2) Validate config against the schema.
3) If missing schema or invalid config: block plugin load, record error.
- Error message includes:
- Plugin id
- Reason (missing schema / invalid config)
- Path(s) that failed validation
- Disabled plugins keep their config, but Doctor + logs surface a warning.
## Doctor flow
- Doctor runs **every time** config is loaded (dry-run by default).

View File

@@ -0,0 +1,11 @@
{
"id": "bluebubbles",
"channels": [
"bluebubbles"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "copilot-proxy",
"providers": [
"copilot-proxy"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "discord",
"channels": [
"discord"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "google-antigravity-auth",
"providers": [
"google-antigravity"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "google-gemini-cli-auth",
"providers": [
"google-gemini-cli"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "imessage",
"channels": [
"imessage"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "matrix",
"channels": [
"matrix"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "memory-core",
"kind": "memory",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,67 @@
{
"id": "memory-lancedb",
"kind": "memory",
"uiHints": {
"embedding.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"placeholder": "sk-proj-...",
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
},
"embedding.model": {
"label": "Embedding Model",
"placeholder": "text-embedding-3-small",
"help": "OpenAI embedding model to use"
},
"dbPath": {
"label": "Database Path",
"placeholder": "~/.clawdbot/memory/lancedb",
"advanced": true
},
"autoCapture": {
"label": "Auto-Capture",
"help": "Automatically capture important information from conversations"
},
"autoRecall": {
"label": "Auto-Recall",
"help": "Automatically inject relevant memories into context"
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"embedding": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string",
"enum": [
"text-embedding-3-small",
"text-embedding-3-large"
]
}
},
"required": [
"apiKey"
]
},
"dbPath": {
"type": "string"
},
"autoCapture": {
"type": "boolean"
},
"autoRecall": {
"type": "boolean"
}
},
"required": [
"embedding"
]
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "msteams",
"channels": [
"msteams"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "qwen-portal-auth",
"providers": [
"qwen-portal"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "signal",
"channels": [
"signal"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "slack",
"channels": [
"slack"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "telegram",
"channels": [
"telegram"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,405 @@
{
"id": "voice-call",
"uiHints": {
"provider": {
"label": "Provider",
"help": "Use twilio, telnyx, or mock for dev/no-network."
},
"fromNumber": {
"label": "From Number",
"placeholder": "+15550001234"
},
"toNumber": {
"label": "Default To Number",
"placeholder": "+15550001234"
},
"inboundPolicy": {
"label": "Inbound Policy"
},
"allowFrom": {
"label": "Inbound Allowlist"
},
"inboundGreeting": {
"label": "Inbound Greeting",
"advanced": true
},
"telnyx.apiKey": {
"label": "Telnyx API Key",
"sensitive": true
},
"telnyx.connectionId": {
"label": "Telnyx Connection ID"
},
"telnyx.publicKey": {
"label": "Telnyx Public Key",
"sensitive": true
},
"twilio.accountSid": {
"label": "Twilio Account SID"
},
"twilio.authToken": {
"label": "Twilio Auth Token",
"sensitive": true
},
"outbound.defaultMode": {
"label": "Default Call Mode"
},
"outbound.notifyHangupDelaySec": {
"label": "Notify Hangup Delay (sec)",
"advanced": true
},
"serve.port": {
"label": "Webhook Port"
},
"serve.bind": {
"label": "Webhook Bind"
},
"serve.path": {
"label": "Webhook Path"
},
"tailscale.mode": {
"label": "Tailscale Mode",
"advanced": true
},
"tailscale.path": {
"label": "Tailscale Path",
"advanced": true
},
"tunnel.provider": {
"label": "Tunnel Provider",
"advanced": true
},
"tunnel.ngrokAuthToken": {
"label": "ngrok Auth Token",
"sensitive": true,
"advanced": true
},
"tunnel.ngrokDomain": {
"label": "ngrok Domain",
"advanced": true
},
"tunnel.allowNgrokFreeTier": {
"label": "Allow ngrok Free Tier",
"advanced": true
},
"streaming.enabled": {
"label": "Enable Streaming",
"advanced": true
},
"streaming.openaiApiKey": {
"label": "OpenAI Realtime API Key",
"sensitive": true,
"advanced": true
},
"streaming.sttModel": {
"label": "Realtime STT Model",
"advanced": true
},
"streaming.streamPath": {
"label": "Media Stream Path",
"advanced": true
},
"tts.model": {
"label": "TTS Model",
"advanced": true
},
"tts.voice": {
"label": "TTS Voice",
"advanced": true
},
"tts.instructions": {
"label": "TTS Instructions",
"advanced": true
},
"publicUrl": {
"label": "Public Webhook URL",
"advanced": true
},
"skipSignatureVerification": {
"label": "Skip Signature Verification",
"advanced": true
},
"store": {
"label": "Call Log Store Path",
"advanced": true
},
"responseModel": {
"label": "Response Model",
"advanced": true
},
"responseSystemPrompt": {
"label": "Response System Prompt",
"advanced": true
},
"responseTimeoutMs": {
"label": "Response Timeout (ms)",
"advanced": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"provider": {
"type": "string",
"enum": [
"telnyx",
"twilio",
"plivo",
"mock"
]
},
"telnyx": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"connectionId": {
"type": "string"
},
"publicKey": {
"type": "string"
}
}
},
"twilio": {
"type": "object",
"additionalProperties": false,
"properties": {
"accountSid": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"plivo": {
"type": "object",
"additionalProperties": false,
"properties": {
"authId": {
"type": "string"
},
"authToken": {
"type": "string"
}
}
},
"fromNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"toNumber": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
},
"inboundPolicy": {
"type": "string",
"enum": [
"disabled",
"allowlist",
"pairing",
"open"
]
},
"allowFrom": {
"type": "array",
"items": {
"type": "string",
"pattern": "^\\+[1-9]\\d{1,14}$"
}
},
"inboundGreeting": {
"type": "string"
},
"outbound": {
"type": "object",
"additionalProperties": false,
"properties": {
"defaultMode": {
"type": "string",
"enum": [
"notify",
"conversation"
]
},
"notifyHangupDelaySec": {
"type": "integer",
"minimum": 0
}
}
},
"maxDurationSeconds": {
"type": "integer",
"minimum": 1
},
"silenceTimeoutMs": {
"type": "integer",
"minimum": 1
},
"transcriptTimeoutMs": {
"type": "integer",
"minimum": 1
},
"ringTimeoutMs": {
"type": "integer",
"minimum": 1
},
"maxConcurrentCalls": {
"type": "integer",
"minimum": 1
},
"serve": {
"type": "object",
"additionalProperties": false,
"properties": {
"port": {
"type": "integer",
"minimum": 1
},
"bind": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"tailscale": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": [
"off",
"serve",
"funnel"
]
},
"path": {
"type": "string"
}
}
},
"tunnel": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"none",
"ngrok",
"tailscale-serve",
"tailscale-funnel"
]
},
"ngrokAuthToken": {
"type": "string"
},
"ngrokDomain": {
"type": "string"
},
"allowNgrokFreeTier": {
"type": "boolean"
}
}
},
"streaming": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"sttProvider": {
"type": "string",
"enum": [
"openai-realtime"
]
},
"openaiApiKey": {
"type": "string"
},
"sttModel": {
"type": "string"
},
"silenceDurationMs": {
"type": "integer",
"minimum": 1
},
"vadThreshold": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"streamPath": {
"type": "string"
}
}
},
"publicUrl": {
"type": "string"
},
"skipSignatureVerification": {
"type": "boolean"
},
"stt": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"openai"
]
},
"model": {
"type": "string"
}
}
},
"tts": {
"type": "object",
"additionalProperties": false,
"properties": {
"provider": {
"type": "string",
"enum": [
"openai"
]
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
},
"instructions": {
"type": "string"
}
}
},
"store": {
"type": "string"
},
"responseModel": {
"type": "string"
},
"responseSystemPrompt": {
"type": "string"
},
"responseTimeoutMs": {
"type": "integer",
"minimum": 1
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "whatsapp",
"channels": [
"whatsapp"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "zalo",
"channels": [
"zalo"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,11 @@
{
"id": "zalouser",
"channels": [
"zalouser"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -48,6 +48,7 @@ export const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000;
export const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent";
export const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox");
const resolvedSandboxStateDir = STATE_DIR_CLAWDBOT ?? path.join(os.homedir(), ".clawdbot");
export const SANDBOX_STATE_DIR = path.join(resolvedSandboxStateDir, "sandbox");
export const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json");

View File

@@ -10,15 +10,25 @@ import { loadClawdbotPlugins } from "../plugins/loader.js";
import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
const EMPTY_CONFIG_SCHEMA = `configSchema: {
validate: () => ({ ok: true }),
jsonSchema: { type: "object", additionalProperties: true },
uiHints: {}
}`;
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
function writeTempPlugin(params: { dir: string; id: string; body: string }): string {
const file = path.join(params.dir, `${params.id}.mjs`);
const pluginDir = path.join(params.dir, params.id);
fs.mkdirSync(pluginDir, { recursive: true });
const file = path.join(pluginDir, `${params.id}.mjs`);
fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(pluginDir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return file;
}
@@ -63,7 +73,7 @@ describe("tool_result_persist hook", () => {
const pluginA = writeTempPlugin({
dir: tmp,
id: "persist-a",
body: `export default { id: "persist-a", ${EMPTY_CONFIG_SCHEMA}, register(api) {
body: `export default { id: "persist-a", register(api) {
api.on("tool_result_persist", (event, ctx) => {
const msg = event.message;
// Example: remove large diagnostic payloads before persistence.
@@ -76,7 +86,7 @@ describe("tool_result_persist hook", () => {
const pluginB = writeTempPlugin({
dir: tmp,
id: "persist-b",
body: `export default { id: "persist-b", ${EMPTY_CONFIG_SCHEMA}, register(api) {
body: `export default { id: "persist-b", register(api) {
api.on("tool_result_persist", (event) => {
const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : [];
return { message: { ...event.message, persistOrder: [...prior, "b"] } };

View File

@@ -1,6 +1,6 @@
import {
readConfigFileSnapshot,
validateConfigObject,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import {
@@ -120,7 +120,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
reply: { text: `⚙️ No config value found for ${configCommand.path}.` },
};
}
const validated = validateConfigObject(parsedBase);
const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) {
const issue = validated.issues[0];
return {
@@ -146,7 +146,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
};
}
setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value);
const validated = validateConfigObject(parsedBase);
const validated = validateConfigObjectWithPlugins(parsedBase);
if (!validated.ok) {
const issue = validated.issues[0];
return {

View File

@@ -1,8 +1,6 @@
import { readConfigFileSnapshot } from "../../config/config.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js";
@@ -30,26 +28,7 @@ export async function ensureConfigReady(params: {
? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
: [];
const pluginIssues: string[] = [];
if (snapshot.valid) {
const workspaceDir = resolveAgentWorkspaceDir(
snapshot.config,
resolveDefaultAgentId(snapshot.config),
);
const registry = loadClawdbotPlugins({
config: snapshot.config,
workspaceDir: workspaceDir ?? undefined,
cache: false,
mode: "validate",
});
for (const diag of registry.diagnostics) {
if (diag.level !== "error") continue;
const id = diag.pluginId ? ` ${diag.pluginId}` : "";
pluginIssues.push(`- plugin${id}: ${diag.message}`);
}
}
const invalid = snapshot.exists && (!snapshot.valid || pluginIssues.length > 0);
const invalid = snapshot.exists && !snapshot.valid;
if (!invalid) return;
const rich = isRich();
@@ -68,10 +47,6 @@ export async function ensureConfigReady(params: {
params.runtime.error(muted("Legacy config keys detected:"));
params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n"));
}
if (pluginIssues.length > 0) {
params.runtime.error(muted("Plugin config errors:"));
params.runtime.error(pluginIssues.map((issue) => ` ${error(issue)}`).join("\n"));
}
params.runtime.error("");
params.runtime.error(
`${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`,

View File

@@ -128,6 +128,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with best-effort config.", "Config");
}
const warnings = snapshot.warnings ?? [];
if (warnings.length > 0) {
const lines = warnings.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n");
note(lines, "Config warnings");
}
if (snapshot.legacyIssues.length > 0) {
note(

View File

@@ -1,11 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import { applyModelDefaults } from "../config/defaults.js";
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -26,12 +24,6 @@ async function readConfigFileRaw(): Promise<{
}
}
async function writeConfigFile(cfg: ClawdbotConfig) {
await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n");
await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8");
}
export async function setupCommand(
opts?: { workspace?: string },
runtime: RuntimeEnv = defaultRuntime,

View File

@@ -12,7 +12,7 @@ describe("config backup rotation", () => {
const configPath = resolveConfigPath();
const buildConfig = (version: number): ClawdbotConfig =>
({
identity: { name: `v${version}` },
agents: { list: [{ id: `v${version}` }] },
}) as ClawdbotConfig;
for (let version = 0; version <= 6; version += 1) {
@@ -21,7 +21,10 @@ describe("config backup rotation", () => {
const readName = async (suffix = "") => {
const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8");
return (JSON.parse(raw) as { identity?: { name?: string } }).identity?.name ?? null;
return (
(JSON.parse(raw) as { agents?: { list?: Array<{ id?: string }> } }).agents?.list?.[0]
?.id ?? null
);
};
await expect(readName()).resolves.toBe("v6");

View File

@@ -96,6 +96,25 @@ describe("Nix integration (U3, U5, U9)", () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
const pluginDir = path.join(home, "plugins", "demo-plugin");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(
path.join(pluginDir, "index.js"),
'export default { id: "demo-plugin", register() {} };',
"utf-8",
);
await fs.writeFile(
path.join(pluginDir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: "demo-plugin",
configSchema: { type: "object", additionalProperties: false, properties: {} },
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
JSON.stringify(

View File

@@ -0,0 +1,152 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
async function writePluginFixture(params: {
dir: string;
id: string;
schema: Record<string, unknown>;
}) {
await fs.mkdir(params.dir, { recursive: true });
await fs.writeFile(
path.join(params.dir, "index.js"),
`export default { id: "${params.id}", register() {} };`,
"utf-8",
);
await fs.writeFile(
path.join(params.dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: params.schema,
},
null,
2,
),
"utf-8",
);
}
describe("config plugin validation", () => {
it("rejects missing plugin load paths", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const missingPath = path.join(home, "missing-plugin");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [missingPath] } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
const hasIssue = res.issues.some(
(issue) =>
issue.path === "plugins.load.paths" && issue.message.includes("plugin path not found"),
);
expect(hasIssue).toBe(true);
}
});
});
it("rejects missing plugin ids in entries", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "plugins.entries.missing-plugin",
message: "plugin not found: missing-plugin",
});
}
});
});
it("rejects missing plugin ids in allow/deny/slots", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: false,
allow: ["missing-allow"],
deny: ["missing-deny"],
slots: { memory: "missing-slot" },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toEqual(
expect.arrayContaining([
{ path: "plugins.allow", message: "plugin not found: missing-allow" },
{ path: "plugins.deny", message: "plugin not found: missing-deny" },
{ path: "plugins.slots.memory", message: "plugin not found: missing-slot" },
]),
);
}
});
});
it("surfaces plugin config diagnostics", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
const pluginDir = path.join(home, "bad-plugin");
await writePluginFixture({
dir: pluginDir,
id: "bad-plugin",
schema: {
type: "object",
additionalProperties: false,
properties: {
value: { type: "boolean" },
},
required: ["value"],
},
});
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
load: { paths: [pluginDir] },
entries: { "bad-plugin": { config: { value: "nope" } } },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
const hasIssue = res.issues.some(
(issue) =>
issue.path === "plugins.entries.bad-plugin.config" &&
issue.message.includes("invalid config"),
);
expect(hasIssue).toBe(true);
}
});
});
it("accepts known plugin ids", async () => {
await withTempHome(async (home) => {
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { discord: { enabled: true } } },
});
expect(res.ok).toBe(true);
});
});
});

View File

@@ -10,5 +10,5 @@ export { migrateLegacyConfig } from "./legacy-migrate.js";
export * from "./paths.js";
export * from "./runtime-overrides.js";
export * from "./types.js";
export { validateConfigObject } from "./validation.js";
export { validateConfigObject, validateConfigObjectWithPlugins } from "./validation.js";
export { ClawdbotSchema } from "./zod-schema.js";

View File

@@ -30,8 +30,7 @@ import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import { validateConfigObject } from "./validation.js";
import { ClawdbotSchema } from "./zod-schema.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
import { compareClawdbotVersions } from "./version.js";
// Re-export for backwards compatibility
@@ -233,21 +232,34 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const resolvedConfig = substituted;
warnOnConfigMiskeys(resolvedConfig, deps.logger);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
const validated = ClawdbotSchema.safeParse(resolvedConfig);
if (!validated.success) {
deps.logger.error("Invalid config:");
for (const iss of validated.error.issues) {
deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`);
}
return {};
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as ClawdbotConfig, {
env: deps.env,
homedir: deps.homedir,
});
if (preValidationDuplicates.length > 0) {
throw new DuplicateAgentDirError(preValidationDuplicates);
}
warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger);
const validated = validateConfigObjectWithPlugins(resolvedConfig);
if (!validated.ok) {
const details = validated.issues
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n");
deps.logger.error(`Invalid config:\\n${details}`);
throw new Error("Invalid config");
}
if (validated.warnings.length > 0) {
const details = validated.warnings
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n");
deps.logger.warn(`Config warnings:\\n${details}`);
}
warnIfConfigFromFuture(validated.config, deps.logger);
const cfg = applyModelDefaults(
applyCompactionDefaults(
applyContextPruningDefaults(
applyAgentDefaults(
applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
applyLoggingDefaults(applyMessageDefaults(validated.config)),
),
),
),
@@ -310,6 +322,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config,
hash,
issues: [],
warnings: [],
legacyIssues,
};
}
@@ -328,6 +341,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: {},
hash,
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
warnings: [],
legacyIssues: [],
};
}
@@ -353,6 +367,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: coerceConfig(parsedRes.parsed),
hash,
issues: [{ path: "", message }],
warnings: [],
legacyIssues: [],
};
}
@@ -375,6 +390,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: coerceConfig(resolved),
hash,
issues: [{ path: "", message }],
warnings: [],
legacyIssues: [],
};
}
@@ -382,7 +398,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const resolvedConfigRaw = substituted;
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObject(resolvedConfigRaw);
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
if (!validated.ok) {
return {
path: configPath,
@@ -393,6 +409,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: coerceConfig(resolvedConfigRaw),
hash,
issues: validated.issues,
warnings: validated.warnings,
legacyIssues,
};
}
@@ -415,6 +432,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
),
hash,
issues: [],
warnings: validated.warnings,
legacyIssues,
};
} catch (err) {
@@ -427,6 +445,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: {},
hash: hashConfigRaw(null),
issues: [{ path: "", message: `read failed: ${String(err)}` }],
warnings: [],
legacyIssues: [],
};
}
@@ -434,6 +453,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function writeConfigFile(cfg: ClawdbotConfig) {
clearConfigCache();
const validated = validateConfigObjectWithPlugins(cfg);
if (!validated.ok) {
const issue = validated.issues[0];
const pathLabel = issue?.path ? issue.path : "<root>";
throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`);
}
if (validated.warnings.length > 0) {
const details = validated.warnings
.map((warning) => `- ${warning.path}: ${warning.message}`)
.join("\n");
deps.logger.warn(`Config warnings:\n${details}`);
}
const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)

View File

@@ -1,6 +1,6 @@
import { applyLegacyMigrations } from "./legacy.js";
import type { ClawdbotConfig } from "./types.js";
import { validateConfigObject } from "./validation.js";
import { validateConfigObjectWithPlugins } from "./validation.js";
export function migrateLegacyConfig(raw: unknown): {
config: ClawdbotConfig | null;
@@ -8,7 +8,7 @@ export function migrateLegacyConfig(raw: unknown): {
} {
const { next, changes } = applyLegacyMigrations(raw);
if (!next) return { config: null, changes: [] };
const validated = validateConfigObject(next);
const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) {
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
return { config: null, changes };

View File

@@ -105,5 +105,6 @@ export type ConfigFileSnapshot = {
config: ClawdbotConfig;
hash?: string;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[];
};

View File

@@ -1,3 +1,12 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveMemorySlotDecision,
} from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.js";
@@ -46,3 +55,183 @@ export function validateConfigObject(
),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function validateConfigObjectWithPlugins(raw: unknown):
| {
ok: true;
config: ClawdbotConfig;
warnings: ConfigValidationIssue[];
}
| {
ok: false;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
const base = validateConfigObject(raw);
if (!base.ok) {
return { ok: false, issues: base.issues, warnings: [] };
}
const config = base.config;
const issues: ConfigValidationIssue[] = [];
const warnings: ConfigValidationIssue[] = [];
const pluginsConfig = config.plugins;
const normalizedPlugins = normalizePluginsConfig(pluginsConfig);
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const registry = loadPluginManifestRegistry({
config,
workspaceDir: workspaceDir ?? undefined,
});
const knownIds = new Set(registry.plugins.map((record) => record.id));
for (const diag of registry.diagnostics) {
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
path = "plugins.load.paths";
}
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
const message = `${pluginLabel}: ${diag.message}`;
if (diag.level === "error") {
issues.push({ path, message });
} else {
warnings.push({ path, message });
}
}
const entries = pluginsConfig?.entries;
if (entries && isRecord(entries)) {
for (const pluginId of Object.keys(entries)) {
if (!knownIds.has(pluginId)) {
issues.push({
path: `plugins.entries.${pluginId}`,
message: `plugin not found: ${pluginId}`,
});
}
}
}
const allow = pluginsConfig?.allow ?? [];
for (const pluginId of allow) {
if (typeof pluginId !== "string" || !pluginId.trim()) continue;
if (!knownIds.has(pluginId)) {
issues.push({
path: "plugins.allow",
message: `plugin not found: ${pluginId}`,
});
}
}
const deny = pluginsConfig?.deny ?? [];
for (const pluginId of deny) {
if (typeof pluginId !== "string" || !pluginId.trim()) continue;
if (!knownIds.has(pluginId)) {
issues.push({
path: "plugins.deny",
message: `plugin not found: ${pluginId}`,
});
}
}
const memorySlot = normalizedPlugins.slots.memory;
if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) {
issues.push({
path: "plugins.slots.memory",
message: `plugin not found: ${memorySlot}`,
});
}
const allowedChannels = new Set<string>(["defaults", ...CHANNEL_IDS]);
for (const record of registry.plugins) {
for (const channelId of record.channels) {
allowedChannels.add(channelId);
}
}
if (config.channels && isRecord(config.channels)) {
for (const key of Object.keys(config.channels)) {
const trimmed = key.trim();
if (!trimmed) continue;
if (!allowedChannels.has(trimmed)) {
issues.push({
path: `channels.${trimmed}`,
message: `unknown channel id: ${trimmed}`,
});
}
}
}
let selectedMemoryPluginId: string | null = null;
const seenPlugins = new Set<string>();
for (const record of registry.plugins) {
const pluginId = record.id;
if (seenPlugins.has(pluginId)) {
continue;
}
seenPlugins.add(pluginId);
const entry = normalizedPlugins.entries[pluginId];
const entryHasConfig = Boolean(entry?.config);
const enableState = resolveEnableState(pluginId, record.origin, normalizedPlugins);
let enabled = enableState.enabled;
let reason = enableState.reason;
if (enabled) {
const memoryDecision = resolveMemorySlotDecision({
id: pluginId,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
enabled = false;
reason = memoryDecision.reason;
}
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = pluginId;
}
}
const shouldValidate = enabled || entryHasConfig;
if (shouldValidate) {
if (record.configSchema) {
const res = validateJsonSchemaValue({
schema: record.configSchema,
cacheKey: record.schemaCacheKey ?? record.manifestPath ?? pluginId,
value: entry?.config ?? {},
});
if (!res.ok) {
for (const error of res.errors) {
issues.push({
path: `plugins.entries.${pluginId}.config`,
message: `invalid config: ${error}`,
});
}
}
} else {
issues.push({
path: `plugins.entries.${pluginId}`,
message: `plugin schema missing for ${pluginId}`,
});
}
}
if (!enabled && entryHasConfig) {
warnings.push({
path: `plugins.entries.${pluginId}`,
message: `plugin disabled (${reason ?? "disabled"}) but config is present`,
});
}
}
if (issues.length > 0) {
return { ok: false, issues, warnings };
}
return { ok: true, config, warnings };
}

View File

@@ -30,5 +30,5 @@ export const ChannelsSchema = z
imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(),
})
.strict()
.passthrough()
.optional();

View File

@@ -5,7 +5,7 @@ import {
parseConfigJson5,
readConfigFileSnapshot,
resolveConfigSnapshotHash,
validateConfigObject,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { applyLegacyMigrations } from "../../config/legacy.js";
@@ -170,7 +170,7 @@ export const configHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return;
}
const validated = validateConfigObject(parsedRes.parsed);
const validated = validateConfigObjectWithPlugins(parsedRes.parsed);
if (!validated.ok) {
respond(
false,
@@ -248,7 +248,7 @@ export const configHandlers: GatewayRequestHandlers = {
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
const migrated = applyLegacyMigrations(merged);
const resolved = migrated.next ?? merged;
const validated = validateConfigObject(resolved);
const validated = validateConfigObjectWithPlugins(resolved);
if (!validated.ok) {
respond(
false,
@@ -303,7 +303,7 @@ export const configHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return;
}
const validated = validateConfigObject(parsedRes.parsed);
const validated = validateConfigObjectWithPlugins(parsedRes.parsed);
if (!validated.ok) {
respond(
false,

View File

@@ -41,7 +41,7 @@ describe("gateway config.apply", () => {
id,
method: "config.apply",
params: {
raw: '{ "agent": { "workspace": "~/clawd" } }',
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},

View File

@@ -6,7 +6,7 @@ import {
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
validateConfigObject,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../config/config.js";
import { runCommandWithTimeout } from "../process/exec.js";
@@ -244,7 +244,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
},
};
const validated = validateConfigObject(nextConfig);
const validated = validateConfigObjectWithPlugins(nextConfig);
if (!validated.ok) {
throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`);
}

126
src/plugins/config-state.ts Normal file
View File

@@ -0,0 +1,126 @@
import type { ClawdbotConfig } from "../config/config.js";
import { defaultSlotIdForKey } from "./slots.js";
import type { PluginRecord } from "./registry.js";
export type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<string, { enabled?: boolean; config?: unknown }>;
};
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.toLowerCase() === "none") return null;
return trimmed;
};
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
if (!key.trim()) continue;
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[key] = {};
continue;
}
const entry = value as Record<string, unknown>;
normalized[key] = {
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
config: "config" in entry ? entry.config : undefined,
};
}
return normalized;
};
export const normalizePluginsConfig = (
config?: ClawdbotConfig["plugins"],
): NormalizedPluginsConfig => {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow),
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot ?? defaultSlotIdForKey("memory"),
},
entries: normalizePluginEntries(config?.entries),
};
};
export function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
}
if (config.deny.includes(id)) {
return { enabled: false, reason: "blocked by denylist" };
}
if (config.allow.length > 0 && !config.allow.includes(id)) {
return { enabled: false, reason: "not in allowlist" };
}
if (config.slots.memory === id) {
return { enabled: true };
}
const entry = config.entries[id];
if (entry?.enabled === true) {
return { enabled: true };
}
if (entry?.enabled === false) {
return { enabled: false, reason: "disabled in config" };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
return { enabled: true };
}
if (origin === "bundled") {
return { enabled: false, reason: "bundled (disabled by default)" };
}
return { enabled: true };
}
export function resolveMemorySlotDecision(params: {
id: string;
kind?: string;
slot: string | null | undefined;
selectedId: string | null;
}): { enabled: boolean; reason?: string; selected?: boolean } {
if (params.kind !== "memory") return { enabled: true };
if (params.slot === null) {
return { enabled: false, reason: "memory slot disabled" };
}
if (typeof params.slot === "string") {
if (params.slot === params.id) {
return { enabled: true, selected: true };
}
return {
enabled: false,
reason: `memory slot set to "${params.slot}"`,
};
}
if (params.selectedId && params.selectedId !== params.id) {
return {
enabled: false,
reason: `memory slot already filled by "${params.selectedId}"`,
};
}
return { enabled: true, selected: true };
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
@@ -10,6 +10,7 @@ const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
export type PluginCandidate = {
idHint: string;
source: string;
rootDir: string;
origin: PluginOrigin;
workspaceDir?: string;
packageName?: string;
@@ -78,6 +79,7 @@ function addCandidate(params: {
seen: Set<string>;
idHint: string;
source: string;
rootDir: string;
origin: PluginOrigin;
workspaceDir?: string;
manifest?: PackageManifest | null;
@@ -89,6 +91,7 @@ function addCandidate(params: {
params.candidates.push({
idHint: params.idHint,
source: resolved,
rootDir: path.resolve(params.rootDir),
origin: params.origin,
workspaceDir: params.workspaceDir,
packageName: manifest?.name?.trim() || undefined,
@@ -127,6 +130,7 @@ function discoverInDirectory(params: {
seen: params.seen,
idHint: path.basename(entry.name, path.extname(entry.name)),
source: fullPath,
rootDir: path.dirname(fullPath),
origin: params.origin,
workspaceDir: params.workspaceDir,
});
@@ -148,6 +152,7 @@ function discoverInDirectory(params: {
hasMultipleExtensions: extensions.length > 1,
}),
source: resolved,
rootDir: fullPath,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
@@ -166,6 +171,7 @@ function discoverInDirectory(params: {
seen: params.seen,
idHint: entry.name,
source: indexFile,
rootDir: fullPath,
origin: params.origin,
workspaceDir: params.workspaceDir,
});
@@ -184,7 +190,7 @@ function discoverFromPath(params: {
const resolved = resolveUserPath(params.rawPath);
if (!fs.existsSync(resolved)) {
params.diagnostics.push({
level: "warn",
level: "error",
message: `plugin path not found: ${resolved}`,
source: resolved,
});
@@ -195,7 +201,7 @@ function discoverFromPath(params: {
if (stat.isFile()) {
if (!isExtensionFile(resolved)) {
params.diagnostics.push({
level: "warn",
level: "error",
message: `plugin path is not a supported file: ${resolved}`,
source: resolved,
});
@@ -206,6 +212,7 @@ function discoverFromPath(params: {
seen: params.seen,
idHint: path.basename(resolved, path.extname(resolved)),
source: resolved,
rootDir: path.dirname(resolved),
origin: params.origin,
workspaceDir: params.workspaceDir,
});
@@ -228,6 +235,7 @@ function discoverFromPath(params: {
hasMultipleExtensions: extensions.length > 1,
}),
source,
rootDir: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
@@ -247,6 +255,7 @@ function discoverFromPath(params: {
seen: params.seen,
idHint: path.basename(resolved),
source: indexFile,
rootDir: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
});
@@ -301,7 +310,7 @@ export function discoverClawdbotPlugins(params: {
});
}
const globalDir = path.join(CONFIG_DIR, "extensions");
const globalDir = path.join(resolveConfigDir(), "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",

View File

@@ -10,7 +10,7 @@ type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = [];
const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR;
const EMPTY_CONFIG_SCHEMA = `configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },`;
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`);
@@ -19,10 +19,28 @@ function makeTempDir() {
return dir;
}
function writePlugin(params: { id: string; body: string }): TempPlugin {
const dir = makeTempDir();
const file = path.join(dir, `${params.id}.js`);
function writePlugin(params: {
id: string;
body: string;
dir?: string;
filename?: string;
}): TempPlugin {
const dir = params.dir ?? makeTempDir();
const filename = params.filename ?? `${params.id}.js`;
const file = path.join(dir, filename);
fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return { dir, file, id: params.id };
}
@@ -44,12 +62,12 @@ afterEach(() => {
describe("loadClawdbotPlugins", () => {
it("disables bundled plugins by default", () => {
const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "bundled.ts");
fs.writeFileSync(
bundledPath,
`export default { id: "bundled", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8",
);
writePlugin({
id: "bundled",
body: `export default { id: "bundled", register() {} };`,
dir: bundledDir,
filename: "bundled.ts",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({
@@ -102,12 +120,12 @@ describe("loadClawdbotPlugins", () => {
it("enables bundled memory plugin when selected by slot", () => {
const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "memory-core.ts");
fs.writeFileSync(
bundledPath,
`export default { id: "memory-core", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8",
);
writePlugin({
id: "memory-core",
body: `export default { id: "memory-core", kind: "memory", register() {} };`,
dir: bundledDir,
filename: "memory-core.ts",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({
@@ -140,11 +158,12 @@ describe("loadClawdbotPlugins", () => {
}),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.ts"),
`export default { id: "memory-core", kind: "memory", name: "Memory (Core)", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8",
);
writePlugin({
id: "memory-core",
body: `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };`,
dir: pluginDir,
filename: "index.ts",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
@@ -169,7 +188,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "allowed",
body: `export default { id: "allowed", ${EMPTY_CONFIG_SCHEMA} register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`,
body: `export default { id: "allowed", register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`,
});
const registry = loadClawdbotPlugins({
@@ -192,7 +211,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "blocked",
body: `export default { id: "blocked", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
body: `export default { id: "blocked", register() {} };`,
});
const registry = loadClawdbotPlugins({
@@ -215,7 +234,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "configurable",
body: `export default {\n id: "configurable",\n configSchema: {\n parse(value) {\n if (!value || typeof value !== "object" || Array.isArray(value)) {\n throw new Error("bad config");\n }\n return value;\n }\n },\n register() {}\n};`,
body: `export default { id: "configurable", register() {} };`,
});
const registry = loadClawdbotPlugins({
@@ -242,7 +261,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "channel-demo",
body: `export default { id: "channel-demo", ${EMPTY_CONFIG_SCHEMA} register(api) {
body: `export default { id: "channel-demo", register(api) {
api.registerChannel({
plugin: {
id: "demo",
@@ -283,7 +302,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "http-demo",
body: `export default { id: "http-demo", ${EMPTY_CONFIG_SCHEMA} register(api) {
body: `export default { id: "http-demo", register(api) {
api.registerHttpHandler(async () => false);
} };`,
});
@@ -309,7 +328,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({
id: "config-disable",
body: `export default { id: "config-disable", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
body: `export default { id: "config-disable", register() {} };`,
});
const registry = loadClawdbotPlugins({
@@ -332,11 +351,11 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memoryA = writePlugin({
id: "memory-a",
body: `export default { id: "memory-a", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
body: `export default { id: "memory-a", kind: "memory", register() {} };`,
});
const memoryB = writePlugin({
id: "memory-b",
body: `export default { id: "memory-b", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
body: `export default { id: "memory-b", kind: "memory", register() {} };`,
});
const registry = loadClawdbotPlugins({
@@ -359,7 +378,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memory = writePlugin({
id: "memory-off",
body: `export default { id: "memory-off", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
body: `export default { id: "memory-off", kind: "memory", register() {} };`,
});
const registry = loadClawdbotPlugins({
@@ -378,16 +397,17 @@ describe("loadClawdbotPlugins", () => {
it("prefers higher-precedence plugins with the same id", () => {
const bundledDir = makeTempDir();
fs.writeFileSync(
path.join(bundledDir, "shadow.js"),
`export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8",
);
writePlugin({
id: "shadow",
body: `export default { id: "shadow", register() {} };`,
dir: bundledDir,
filename: "shadow.js",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const override = writePlugin({
id: "shadow",
body: `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
body: `export default { id: "shadow", register() {} };`,
});
const registry = loadClawdbotPlugins({

View File

@@ -8,16 +8,21 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveMemorySlotDecision,
type NormalizedPluginsConfig,
} from "./config-state.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js";
import { defaultSlotIdForKey } from "./slots.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
import type {
ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition,
ClawdbotPluginModule,
PluginConfigUiHint,
PluginDiagnostic,
PluginLogger,
} from "./types.js";
@@ -33,73 +38,10 @@ export type PluginLoadOptions = {
mode?: "full" | "validate";
};
type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
slots: {
memory?: string | null;
};
entries: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
};
const registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins");
const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
};
const normalizeSlotValue = (value: unknown): string | null | undefined => {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.toLowerCase() === "none") return null;
return trimmed;
};
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
if (!key.trim()) continue;
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[key] = {};
continue;
}
const entry = value as Record<string, unknown>;
normalized[key] = {
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
config:
entry.config && typeof entry.config === "object" && !Array.isArray(entry.config)
? (entry.config as Record<string, unknown>)
: undefined,
};
}
return normalized;
};
const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedPluginsConfig => {
const memorySlot = normalizeSlotValue(config?.slots?.memory);
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow),
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
slots: {
memory: memorySlot ?? defaultSlotIdForKey("memory"),
},
entries: normalizePluginEntries(config?.entries),
};
};
const resolvePluginSdkAlias = (): string | null => {
try {
const modulePath = fileURLToPath(import.meta.url);
@@ -133,105 +75,25 @@ function buildCacheKey(params: {
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
}
function resolveMemorySlotDecision(params: {
id: string;
kind?: string;
slot: string | null | undefined;
selectedId: string | null;
}): { enabled: boolean; reason?: string; selected?: boolean } {
if (params.kind !== "memory") return { enabled: true };
if (params.slot === null) {
return { enabled: false, reason: "memory slot disabled" };
}
if (typeof params.slot === "string") {
if (params.slot === params.id) {
return { enabled: true, selected: true };
}
return {
enabled: false,
reason: `memory slot set to "${params.slot}"`,
};
}
if (params.selectedId && params.selectedId !== params.id) {
return {
enabled: false,
reason: `memory slot already filled by "${params.selectedId}"`,
};
}
return { enabled: true, selected: true };
}
function resolveEnableState(
id: string,
origin: PluginRecord["origin"],
config: NormalizedPluginsConfig,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
}
if (config.deny.includes(id)) {
return { enabled: false, reason: "blocked by denylist" };
}
if (config.allow.length > 0 && !config.allow.includes(id)) {
return { enabled: false, reason: "not in allowlist" };
}
if (config.slots.memory === id) {
return { enabled: true };
}
const entry = config.entries[id];
if (entry?.enabled === true) {
return { enabled: true };
}
if (entry?.enabled === false) {
return { enabled: false, reason: "disabled in config" };
}
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
return { enabled: true };
}
if (origin === "bundled") {
return { enabled: false, reason: "bundled (disabled by default)" };
}
return { enabled: true };
}
function validatePluginConfig(params: {
schema?: ClawdbotPluginConfigSchema;
value?: Record<string, unknown>;
schema?: Record<string, unknown>;
cacheKey?: string;
value?: unknown;
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
const schema = params.schema;
if (!schema) return { ok: true, value: params.value };
if (typeof schema.validate === "function") {
const result = schema.validate(params.value);
if (result.ok) {
return { ok: true, value: result.value as Record<string, unknown> };
}
return { ok: false, errors: result.errors };
if (!schema) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
}
if (typeof schema.safeParse === "function") {
const result = schema.safeParse(params.value);
if (result.success) {
return { ok: true, value: result.data as Record<string, unknown> };
}
const issues = result.error?.issues ?? [];
const errors = issues.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
return `${path}: ${issue.message}`;
});
return { ok: false, errors };
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
const result = validateJsonSchemaValue({
schema,
cacheKey,
value: params.value ?? {},
});
if (result.ok) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
}
if (typeof schema.parse === "function") {
try {
const parsed = schema.parse(params.value);
return { ok: true, value: parsed as Record<string, unknown> };
} catch (err) {
return { ok: false, errors: [String(err)] };
}
}
return { ok: true, value: params.value };
return { ok: false, errors: result.errors };
}
function resolvePluginModuleExport(moduleExport: unknown): {
@@ -326,7 +188,14 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
workspaceDir: options.workspaceDir,
extraPaths: normalized.loadPaths,
});
pushDiagnostics(registry.diagnostics, discovery.diagnostics);
const manifestRegistry = loadPluginManifestRegistry({
config: cfg,
workspaceDir: options.workspaceDir,
cache: options.cache,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
const pluginSdkAlias = resolvePluginSdkAlias();
const jiti = createJiti(import.meta.url, {
@@ -335,10 +204,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
});
const bundledIds = new Set(
discovery.candidates
.filter((candidate) => candidate.origin === "bundled")
.map((candidate) => candidate.idHint),
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);
const seenIds = new Map<string, PluginRecord["origin"]>();
@@ -347,18 +214,23 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
let memorySlotMatched = false;
for (const candidate of discovery.candidates) {
const existingOrigin = seenIds.get(candidate.idHint);
const manifestRecord = manifestByRoot.get(candidate.rootDir);
if (!manifestRecord) {
continue;
}
const pluginId = manifestRecord.id;
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) {
const record = createPluginRecord({
id: candidate.idHint,
name: candidate.packageName ?? candidate.idHint,
description: candidate.packageDescription,
version: candidate.packageVersion,
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
source: candidate.source,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: false,
configSchema: false,
configSchema: Boolean(manifestRecord.configSchema),
});
record.status = "disabled";
record.error = `overridden by ${existingOrigin} plugin`;
@@ -366,25 +238,42 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
const enableState = resolveEnableState(candidate.idHint, candidate.origin, normalized);
const entry = normalized.entries[candidate.idHint];
const enableState = resolveEnableState(pluginId, candidate.origin, normalized);
const entry = normalized.entries[pluginId];
const record = createPluginRecord({
id: candidate.idHint,
name: candidate.packageName ?? candidate.idHint,
description: candidate.packageDescription,
version: candidate.packageVersion,
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
source: candidate.source,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: enableState.enabled,
configSchema: false,
configSchema: Boolean(manifestRecord.configSchema),
});
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (!manifestRecord.configSchema) {
record.status = "error";
record.error = "missing config schema";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
@@ -396,7 +285,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
@@ -422,61 +311,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
record.kind = definition?.kind;
record.configSchema = Boolean(definition?.configSchema);
record.configUiHints =
definition?.configSchema &&
typeof definition.configSchema === "object" &&
(definition.configSchema as { uiHints?: unknown }).uiHints &&
typeof (definition.configSchema as { uiHints?: unknown }).uiHints === "object" &&
!Array.isArray((definition.configSchema as { uiHints?: unknown }).uiHints)
? ((definition.configSchema as { uiHints?: unknown }).uiHints as Record<
string,
PluginConfigUiHint
>)
: undefined;
record.configJsonSchema =
definition?.configSchema &&
typeof definition.configSchema === "object" &&
(definition.configSchema as { jsonSchema?: unknown }).jsonSchema &&
typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" &&
!Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema)
? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record<
string,
unknown
>)
: undefined;
if (!definition?.configSchema) {
const hasBundledFallback =
candidate.origin !== "bundled" && bundledIds.has(candidate.idHint);
if (hasBundledFallback) {
record.enabled = false;
record.status = "disabled";
record.error = "missing config schema (using bundled plugin)";
registry.plugins.push(record);
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
logger.error(`[plugins] ${record.id} missing config schema`);
record.status = "error";
record.error = "missing config schema";
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
const manifestKind = record.kind as string | undefined;
const exportKind = definition?.kind as string | undefined;
if (manifestKind && exportKind && exportKind !== manifestKind) {
registry.diagnostics.push({
level: "error",
level: "warn",
pluginId: record.id,
source: record.source,
message: record.error,
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
});
continue;
}
record.kind = definition?.kind ?? record.kind;
if (record.kind === "memory" && memorySlot === record.id) {
memorySlotMatched = true;
@@ -494,7 +339,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "disabled";
record.error = memoryDecision.reason;
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
continue;
}
@@ -503,7 +348,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
}
const validatedConfig = validatePluginConfig({
schema: definition?.configSchema,
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
@@ -512,7 +358,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error";
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
@@ -524,7 +370,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
if (validateOnly) {
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
continue;
}
@@ -533,7 +379,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error";
record.error = "plugin export missing register/activate";
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
@@ -559,7 +405,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
});
}
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
} catch (err) {
logger.error(
`[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`,
@@ -567,7 +413,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,

View File

@@ -0,0 +1,189 @@
import fs from "node:fs";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverClawdbotPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
export type PluginManifestRecord = {
id: string;
name?: string;
description?: string;
version?: string;
kind?: PluginKind;
channels: string[];
providers: string[];
origin: PluginOrigin;
workspaceDir?: string;
rootDir: string;
source: string;
manifestPath: string;
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
configUiHints?: Record<string, PluginConfigUiHint>;
};
export type PluginManifestRegistry = {
plugins: PluginManifestRecord[];
diagnostics: PluginDiagnostic[];
};
const registryCache = new Map<string, { expiresAt: number; registry: PluginManifestRegistry }>();
const DEFAULT_MANIFEST_CACHE_MS = 200;
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.CLAWDBOT_PLUGIN_MANIFEST_CACHE_MS?.trim();
if (raw === "" || raw === "0") return 0;
if (!raw) return DEFAULT_MANIFEST_CACHE_MS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return DEFAULT_MANIFEST_CACHE_MS;
return Math.max(0, parsed);
}
function shouldUseManifestCache(env: NodeJS.ProcessEnv): boolean {
const disabled = env.CLAWDBOT_DISABLE_PLUGIN_MANIFEST_CACHE?.trim();
if (disabled) return false;
return resolveManifestCacheMs(env) > 0;
}
function buildCacheKey(params: {
workspaceDir?: string;
plugins: NormalizedPluginsConfig;
}): string {
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
}
function safeStatMtimeMs(filePath: string): number | null {
try {
return fs.statSync(filePath).mtimeMs;
} catch {
return null;
}
}
function normalizeManifestLabel(raw: string | undefined): string | undefined {
const trimmed = raw?.trim();
return trimmed ? trimmed : undefined;
}
function buildRecord(params: {
manifest: PluginManifest;
candidate: PluginCandidate;
manifestPath: string;
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
}): PluginManifestRecord {
return {
id: params.manifest.id,
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName,
description:
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
origin: params.candidate.origin,
workspaceDir: params.candidate.workspaceDir,
rootDir: params.candidate.rootDir,
source: params.candidate.source,
manifestPath: params.manifestPath,
schemaCacheKey: params.schemaCacheKey,
configSchema: params.configSchema,
configUiHints: params.manifest.uiHints,
};
}
export function loadPluginManifestRegistry(params: {
config?: ClawdbotConfig;
workspaceDir?: string;
cache?: boolean;
env?: NodeJS.ProcessEnv;
candidates?: PluginCandidate[];
diagnostics?: PluginDiagnostic[];
}): PluginManifestRegistry {
const config = params.config ?? {};
const normalized = normalizePluginsConfig(config.plugins);
const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized });
const env = params.env ?? process.env;
const cacheEnabled = params.cache !== false && shouldUseManifestCache(env);
if (cacheEnabled) {
const cached = registryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.registry;
}
const discovery = params.candidates
? {
candidates: params.candidates,
diagnostics: params.diagnostics ?? [],
}
: discoverClawdbotPlugins({
workspaceDir: params.workspaceDir,
extraPaths: normalized.loadPaths,
});
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
const candidates: PluginCandidate[] = discovery.candidates;
const records: PluginManifestRecord[] = [];
const seenIds = new Set<string>();
for (const candidate of candidates) {
const manifestRes = loadPluginManifest(candidate.rootDir);
if (!manifestRes.ok) {
diagnostics.push({
level: "error",
message: manifestRes.error,
source: manifestRes.manifestPath,
});
continue;
}
const manifest = manifestRes.manifest;
if (candidate.idHint && candidate.idHint !== manifest.id) {
diagnostics.push({
level: "warn",
pluginId: manifest.id,
source: candidate.source,
message: `plugin id mismatch (manifest uses "${manifest.id}", entry hints "${candidate.idHint}")`,
});
}
if (seenIds.has(manifest.id)) {
diagnostics.push({
level: "warn",
pluginId: manifest.id,
source: candidate.source,
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
});
} else {
seenIds.add(manifest.id);
}
const configSchema = manifest.configSchema;
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
const schemaCacheKey = manifestMtime
? `${manifestRes.manifestPath}:${manifestMtime}`
: manifestRes.manifestPath;
records.push(
buildRecord({
manifest,
candidate,
manifestPath: manifestRes.manifestPath,
schemaCacheKey,
configSchema,
}),
);
}
const registry = { plugins: records, diagnostics };
if (cacheEnabled) {
const ttl = resolveManifestCacheMs(env);
if (ttl > 0) {
registryCache.set(cacheKey, { expiresAt: Date.now() + ttl, registry });
}
}
return registry;
}

91
src/plugins/manifest.ts Normal file
View File

@@ -0,0 +1,91 @@
import fs from "node:fs";
import path from "node:path";
import type { PluginConfigUiHint, PluginKind } from "./types.js";
export const PLUGIN_MANIFEST_FILENAME = "clawdbot.plugin.json";
export type PluginManifest = {
id: string;
configSchema: Record<string, unknown>;
kind?: PluginKind;
channels?: string[];
providers?: string[];
name?: string;
description?: string;
version?: string;
uiHints?: Record<string, PluginConfigUiHint>;
};
export type PluginManifestLoadResult =
| { ok: true; manifest: PluginManifest; manifestPath: string }
| { ok: false; error: string; manifestPath: string };
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function resolvePluginManifestPath(rootDir: string): string {
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
}
export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
const manifestPath = resolvePluginManifestPath(rootDir);
if (!fs.existsSync(manifestPath)) {
return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath };
}
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown;
} catch (err) {
return {
ok: false,
error: `failed to parse plugin manifest: ${String(err)}`,
manifestPath,
};
}
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
}
const id = typeof raw.id === "string" ? raw.id.trim() : "";
if (!id) {
return { ok: false, error: "plugin manifest requires id", manifestPath };
}
const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null;
if (!configSchema) {
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
}
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
const name = typeof raw.name === "string" ? raw.name.trim() : undefined;
const description = typeof raw.description === "string" ? raw.description.trim() : undefined;
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
const channels = normalizeStringList(raw.channels);
const providers = normalizeStringList(raw.providers);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
return {
ok: true,
manifest: {
id,
configSchema,
kind,
channels,
providers,
name,
description,
version,
uiHints,
},
manifestPath,
};
}

View File

@@ -0,0 +1,40 @@
import AjvPkg, { type ErrorObject, type ValidateFunction } from "ajv";
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
allErrors: true,
strict: false,
removeAdditional: false,
});
type CachedValidator = {
validate: ValidateFunction;
schema: Record<string, unknown>;
};
const schemaCache = new Map<string, CachedValidator>();
function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
if (!errors || errors.length === 0) return ["invalid config"];
return errors.map((error) => {
const path = error.instancePath?.replace(/^\//, "").replace(/\//g, ".") || "<root>";
const message = error.message ?? "invalid";
return `${path}: ${message}`;
});
}
export function validateJsonSchemaValue(params: {
schema: Record<string, unknown>;
cacheKey: string;
value: unknown;
}): { ok: true } | { ok: false; errors: string[] } {
let cached = schemaCache.get(params.cacheKey);
if (!cached || cached.schema !== params.schema) {
const validate = ajv.compile(params.schema) as ValidateFunction;
cached = { validate, schema: params.schema };
schemaCache.set(params.cacheKey, cached);
}
const ok = cached.validate(params.value);
if (ok) return { ok: true };
return { ok: false, errors: formatAjvErrors(cached.validate.errors) };
}

View File

@@ -10,6 +10,7 @@ import { resolvePluginTools } from "./tools.js";
type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = [];
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-tools-${randomUUID()}`);
@@ -22,6 +23,18 @@ function writePlugin(params: { id: string; body: string }): TempPlugin {
const dir = makeTempDir();
const file = path.join(dir, `${params.id}.js`);
fs.writeFileSync(file, params.body, "utf-8");
fs.writeFileSync(
path.join(dir, "clawdbot.plugin.json"),
JSON.stringify(
{
id: params.id,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
return { dir, file, id: params.id };
}
@@ -36,10 +49,8 @@ afterEach(() => {
});
describe("resolvePluginTools optional tools", () => {
const emptyConfigSchema =
'configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },';
const pluginBody = `
export default { ${emptyConfigSchema} register(api) {
export default { register(api) {
api.registerTool(
{
name: "optional_tool",
@@ -140,7 +151,7 @@ export default { ${emptyConfigSchema} register(api) {
const plugin = writePlugin({
id: "multi",
body: `
export default { ${emptyConfigSchema} register(api) {
export default { register(api) {
api.registerTool({
name: "message",
description: "conflict",