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
commit 8214ab507c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1817 additions and 377 deletions

View File

@ -6,18 +6,12 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Repo: remove the Peekaboo git submodule now that the SPM release is used. - Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Gateway: raise default lane concurrency for main and sub-agent runs. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Config: centralize default agent concurrency limits.
### Fixes ### 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). - 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. - 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. - 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 ## 2026.1.19-3

View File

@ -11,6 +11,7 @@ Manage Gateway plugins/extensions (loaded in-process).
Related: Related:
- Plugin system: [Plugins](/plugin) - Plugin system: [Plugins](/plugin)
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
- Security hardening: [Security](/gateway/security) - Security hardening: [Security](/gateway/security)
## Commands ## Commands
@ -28,6 +29,10 @@ clawdbot plugins update --all
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them. 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 ### Install
```bash ```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) - 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) - 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 Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config
register: 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 RPC methods
- Gateway HTTP handlers - 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, or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way. 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 If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored. wins and lower-precedence copies are ignored.
@ -140,6 +147,14 @@ Fields:
Config changes **require a gateway restart**. 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) ## Plugin slots (exclusive categories)
Some plugin categories are **exclusive** (only one active at a time). Use 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>` `plugins.entries.<id>.config.<field>`
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive), 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: Example:
```ts ```json
export default { {
id: "my-plugin", "id": "my-plugin",
configSchema: { "configSchema": {
parse: (v) => v, "type": "object",
uiHints: { "additionalProperties": false,
"apiKey": { label: "API Key", sensitive: true }, "properties": {
"region": { label: "Region", placeholder: "us-east-1" }, "apiKey": { "type": "string" },
}, "region": { "type": "string" }
}
}, },
register(api) {}, "uiHints": {
}; "apiKey": { "label": "API Key", "sensitive": true },
"region": { "label": "Region", "placeholder": "us-east-1" }
}
}
``` ```
## CLI ## 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). - Unknown keys are validation errors (no passthrough at root or nested).
- `plugins.entries.<id>.config` must be validated by the plugins schema. - `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. - 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 ## 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: - Plugin load flow:
1) Resolve plugin schema by plugin id. 1) Resolve plugin manifest + schema (`clawdbot.plugin.json`).
2) Validate config against the schema. 2) Validate config against the schema.
3) If missing schema or invalid config: block plugin load, record error. 3) If missing schema or invalid config: block plugin load, record error.
- Error message includes: - Error message includes:
- Plugin id - Plugin id
- Reason (missing schema / invalid config) - Reason (missing schema / invalid config)
- Path(s) that failed validation - Path(s) that failed validation
- Disabled plugins keep their config, but Doctor + logs surface a warning.
## Doctor flow ## Doctor flow
- Doctor runs **every time** config is loaded (dry-run by default). - 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_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_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json");
export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.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 { resetGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js";
const EMPTY_CONFIG_SCHEMA = `configSchema: { const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
validate: () => ({ ok: true }),
jsonSchema: { type: "object", additionalProperties: true },
uiHints: {}
}`;
function writeTempPlugin(params: { dir: string; id: string; body: string }): string { 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(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; return file;
} }
@ -63,7 +73,7 @@ describe("tool_result_persist hook", () => {
const pluginA = writeTempPlugin({ const pluginA = writeTempPlugin({
dir: tmp, dir: tmp,
id: "persist-a", 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) => { api.on("tool_result_persist", (event, ctx) => {
const msg = event.message; const msg = event.message;
// Example: remove large diagnostic payloads before persistence. // Example: remove large diagnostic payloads before persistence.
@ -76,7 +86,7 @@ describe("tool_result_persist hook", () => {
const pluginB = writeTempPlugin({ const pluginB = writeTempPlugin({
dir: tmp, dir: tmp,
id: "persist-b", 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) => { api.on("tool_result_persist", (event) => {
const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : []; const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : [];
return { message: { ...event.message, persistOrder: [...prior, "b"] } }; return { message: { ...event.message, persistOrder: [...prior, "b"] } };

View File

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

View File

@ -1,8 +1,6 @@
import { readConfigFileSnapshot } from "../../config/config.js"; import { readConfigFileSnapshot } from "../../config/config.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js"; import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js"; import { formatCliCommand } from "../command-format.js";
@ -30,26 +28,7 @@ export async function ensureConfigReady(params: {
? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`) ? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
: []; : [];
const pluginIssues: string[] = []; const invalid = snapshot.exists && !snapshot.valid;
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);
if (!invalid) return; if (!invalid) return;
const rich = isRich(); const rich = isRich();
@ -68,10 +47,6 @@ export async function ensureConfigReady(params: {
params.runtime.error(muted("Legacy config keys detected:")); params.runtime.error(muted("Legacy config keys detected:"));
params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n")); 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("");
params.runtime.error( params.runtime.error(
`${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`, `${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) { if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with best-effort config.", "Config"); 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) { if (snapshot.legacyIssues.length > 0) {
note( note(

View File

@ -1,11 +1,9 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path";
import JSON5 from "json5"; import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { applyModelDefaults } from "../config/defaults.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } 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( export async function setupCommand(
opts?: { workspace?: string }, opts?: { workspace?: string },
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,

View File

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

View File

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

View File

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

View File

@ -105,5 +105,6 @@ export type ConfigFileSnapshot = {
config: ClawdbotConfig; config: ClawdbotConfig;
hash?: string; hash?: string;
issues: ConfigValidationIssue[]; issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[]; 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 { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
import { findLegacyConfigIssues } from "./legacy.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(), imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(),
}) })
.strict() .passthrough()
.optional(); .optional();

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import {
loadConfig, loadConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
resolveGatewayPort, resolveGatewayPort,
validateConfigObject, validateConfigObjectWithPlugins,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { runCommandWithTimeout } from "../process/exec.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) { if (!validated.ok) {
throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`); 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 fs from "node:fs";
import path from "node:path"; 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 { resolveBundledPluginsDir } from "./bundled-dir.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.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 = { export type PluginCandidate = {
idHint: string; idHint: string;
source: string; source: string;
rootDir: string;
origin: PluginOrigin; origin: PluginOrigin;
workspaceDir?: string; workspaceDir?: string;
packageName?: string; packageName?: string;
@ -78,6 +79,7 @@ function addCandidate(params: {
seen: Set<string>; seen: Set<string>;
idHint: string; idHint: string;
source: string; source: string;
rootDir: string;
origin: PluginOrigin; origin: PluginOrigin;
workspaceDir?: string; workspaceDir?: string;
manifest?: PackageManifest | null; manifest?: PackageManifest | null;
@ -89,6 +91,7 @@ function addCandidate(params: {
params.candidates.push({ params.candidates.push({
idHint: params.idHint, idHint: params.idHint,
source: resolved, source: resolved,
rootDir: path.resolve(params.rootDir),
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
packageName: manifest?.name?.trim() || undefined, packageName: manifest?.name?.trim() || undefined,
@ -127,6 +130,7 @@ function discoverInDirectory(params: {
seen: params.seen, seen: params.seen,
idHint: path.basename(entry.name, path.extname(entry.name)), idHint: path.basename(entry.name, path.extname(entry.name)),
source: fullPath, source: fullPath,
rootDir: path.dirname(fullPath),
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
}); });
@ -148,6 +152,7 @@ function discoverInDirectory(params: {
hasMultipleExtensions: extensions.length > 1, hasMultipleExtensions: extensions.length > 1,
}), }),
source: resolved, source: resolved,
rootDir: fullPath,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
manifest, manifest,
@ -166,6 +171,7 @@ function discoverInDirectory(params: {
seen: params.seen, seen: params.seen,
idHint: entry.name, idHint: entry.name,
source: indexFile, source: indexFile,
rootDir: fullPath,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
}); });
@ -184,7 +190,7 @@ function discoverFromPath(params: {
const resolved = resolveUserPath(params.rawPath); const resolved = resolveUserPath(params.rawPath);
if (!fs.existsSync(resolved)) { if (!fs.existsSync(resolved)) {
params.diagnostics.push({ params.diagnostics.push({
level: "warn", level: "error",
message: `plugin path not found: ${resolved}`, message: `plugin path not found: ${resolved}`,
source: resolved, source: resolved,
}); });
@ -195,7 +201,7 @@ function discoverFromPath(params: {
if (stat.isFile()) { if (stat.isFile()) {
if (!isExtensionFile(resolved)) { if (!isExtensionFile(resolved)) {
params.diagnostics.push({ params.diagnostics.push({
level: "warn", level: "error",
message: `plugin path is not a supported file: ${resolved}`, message: `plugin path is not a supported file: ${resolved}`,
source: resolved, source: resolved,
}); });
@ -206,6 +212,7 @@ function discoverFromPath(params: {
seen: params.seen, seen: params.seen,
idHint: path.basename(resolved, path.extname(resolved)), idHint: path.basename(resolved, path.extname(resolved)),
source: resolved, source: resolved,
rootDir: path.dirname(resolved),
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
}); });
@ -228,6 +235,7 @@ function discoverFromPath(params: {
hasMultipleExtensions: extensions.length > 1, hasMultipleExtensions: extensions.length > 1,
}), }),
source, source,
rootDir: resolved,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, workspaceDir: params.workspaceDir,
manifest, manifest,
@ -247,6 +255,7 @@ function discoverFromPath(params: {
seen: params.seen, seen: params.seen,
idHint: path.basename(resolved), idHint: path.basename(resolved),
source: indexFile, source: indexFile,
rootDir: resolved,
origin: params.origin, origin: params.origin,
workspaceDir: params.workspaceDir, 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({ discoverInDirectory({
dir: globalDir, dir: globalDir,
origin: "global", origin: "global",

View File

@ -10,7 +10,7 @@ type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = []; const tempDirs: string[] = [];
const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR; 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() { function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`); const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`);
@ -19,10 +19,28 @@ function makeTempDir() {
return dir; return dir;
} }
function writePlugin(params: { id: string; body: string }): TempPlugin { function writePlugin(params: {
const dir = makeTempDir(); id: string;
const file = path.join(dir, `${params.id}.js`); 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(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 }; return { dir, file, id: params.id };
} }
@ -44,12 +62,12 @@ afterEach(() => {
describe("loadClawdbotPlugins", () => { describe("loadClawdbotPlugins", () => {
it("disables bundled plugins by default", () => { it("disables bundled plugins by default", () => {
const bundledDir = makeTempDir(); const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "bundled.ts"); writePlugin({
fs.writeFileSync( id: "bundled",
bundledPath, body: `export default { id: "bundled", register() {} };`,
`export default { id: "bundled", ${EMPTY_CONFIG_SCHEMA} register() {} };`, dir: bundledDir,
"utf-8", filename: "bundled.ts",
); });
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@ -102,12 +120,12 @@ describe("loadClawdbotPlugins", () => {
it("enables bundled memory plugin when selected by slot", () => { it("enables bundled memory plugin when selected by slot", () => {
const bundledDir = makeTempDir(); const bundledDir = makeTempDir();
const bundledPath = path.join(bundledDir, "memory-core.ts"); writePlugin({
fs.writeFileSync( id: "memory-core",
bundledPath, body: `export default { id: "memory-core", kind: "memory", register() {} };`,
`export default { id: "memory-core", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, dir: bundledDir,
"utf-8", filename: "memory-core.ts",
); });
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@ -140,11 +158,12 @@ describe("loadClawdbotPlugins", () => {
}), }),
"utf-8", "utf-8",
); );
fs.writeFileSync( writePlugin({
path.join(pluginDir, "index.ts"), id: "memory-core",
`export default { id: "memory-core", kind: "memory", name: "Memory (Core)", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };`,
"utf-8", dir: pluginDir,
); filename: "index.ts",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
@ -169,7 +188,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "allowed", 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({ const registry = loadClawdbotPlugins({
@ -192,7 +211,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "blocked", id: "blocked",
body: `export default { id: "blocked", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "blocked", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@ -215,7 +234,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "configurable", 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({ const registry = loadClawdbotPlugins({
@ -242,7 +261,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "channel-demo", id: "channel-demo",
body: `export default { id: "channel-demo", ${EMPTY_CONFIG_SCHEMA} register(api) { body: `export default { id: "channel-demo", register(api) {
api.registerChannel({ api.registerChannel({
plugin: { plugin: {
id: "demo", id: "demo",
@ -283,7 +302,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "http-demo", 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); api.registerHttpHandler(async () => false);
} };`, } };`,
}); });
@ -309,7 +328,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({ const plugin = writePlugin({
id: "config-disable", id: "config-disable",
body: `export default { id: "config-disable", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "config-disable", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@ -332,11 +351,11 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memoryA = writePlugin({ const memoryA = writePlugin({
id: "memory-a", 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({ const memoryB = writePlugin({
id: "memory-b", 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({ const registry = loadClawdbotPlugins({
@ -359,7 +378,7 @@ describe("loadClawdbotPlugins", () => {
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const memory = writePlugin({ const memory = writePlugin({
id: "memory-off", 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({ const registry = loadClawdbotPlugins({
@ -378,16 +397,17 @@ describe("loadClawdbotPlugins", () => {
it("prefers higher-precedence plugins with the same id", () => { it("prefers higher-precedence plugins with the same id", () => {
const bundledDir = makeTempDir(); const bundledDir = makeTempDir();
fs.writeFileSync( writePlugin({
path.join(bundledDir, "shadow.js"), id: "shadow",
`export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "shadow", register() {} };`,
"utf-8", dir: bundledDir,
); filename: "shadow.js",
});
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const override = writePlugin({ const override = writePlugin({
id: "shadow", id: "shadow",
body: `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`, body: `export default { id: "shadow", register() {} };`,
}); });
const registry = loadClawdbotPlugins({ 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 { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.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 { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js"; import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js"; import { setActivePluginRegistry } from "./runtime.js";
import { defaultSlotIdForKey } from "./slots.js"; import { validateJsonSchemaValue } from "./schema-validator.js";
import type { import type {
ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition, ClawdbotPluginDefinition,
ClawdbotPluginModule, ClawdbotPluginModule,
PluginConfigUiHint,
PluginDiagnostic, PluginDiagnostic,
PluginLogger, PluginLogger,
} from "./types.js"; } from "./types.js";
@ -33,73 +38,10 @@ export type PluginLoadOptions = {
mode?: "full" | "validate"; 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 registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins"); 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 => { const resolvePluginSdkAlias = (): string | null => {
try { try {
const modulePath = fileURLToPath(import.meta.url); const modulePath = fileURLToPath(import.meta.url);
@ -133,105 +75,25 @@ function buildCacheKey(params: {
return `${workspaceKey}::${JSON.stringify(params.plugins)}`; 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: { function validatePluginConfig(params: {
schema?: ClawdbotPluginConfigSchema; schema?: Record<string, unknown>;
value?: Record<string, unknown>; cacheKey?: string;
value?: unknown;
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } { }): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
const schema = params.schema; const schema = params.schema;
if (!schema) return { ok: true, value: params.value }; if (!schema) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
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 };
} }
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
if (typeof schema.safeParse === "function") { const result = validateJsonSchemaValue({
const result = schema.safeParse(params.value); schema,
if (result.success) { cacheKey,
return { ok: true, value: result.data as Record<string, unknown> }; value: params.value ?? {},
} });
const issues = result.error?.issues ?? []; if (result.ok) {
const errors = issues.map((issue) => { return { ok: true, value: params.value as Record<string, unknown> | undefined };
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
return `${path}: ${issue.message}`;
});
return { ok: false, errors };
} }
return { ok: false, errors: result.errors };
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 };
} }
function resolvePluginModuleExport(moduleExport: unknown): { function resolvePluginModuleExport(moduleExport: unknown): {
@ -326,7 +188,14 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
workspaceDir: options.workspaceDir, workspaceDir: options.workspaceDir,
extraPaths: normalized.loadPaths, 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 pluginSdkAlias = resolvePluginSdkAlias();
const jiti = createJiti(import.meta.url, { const jiti = createJiti(import.meta.url, {
@ -335,10 +204,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}), ...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
}); });
const bundledIds = new Set( const manifestByRoot = new Map(
discovery.candidates manifestRegistry.plugins.map((record) => [record.rootDir, record]),
.filter((candidate) => candidate.origin === "bundled")
.map((candidate) => candidate.idHint),
); );
const seenIds = new Map<string, PluginRecord["origin"]>(); const seenIds = new Map<string, PluginRecord["origin"]>();
@ -347,18 +214,23 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
let memorySlotMatched = false; let memorySlotMatched = false;
for (const candidate of discovery.candidates) { 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) { if (existingOrigin) {
const record = createPluginRecord({ const record = createPluginRecord({
id: candidate.idHint, id: pluginId,
name: candidate.packageName ?? candidate.idHint, name: manifestRecord.name ?? pluginId,
description: candidate.packageDescription, description: manifestRecord.description,
version: candidate.packageVersion, version: manifestRecord.version,
source: candidate.source, source: candidate.source,
origin: candidate.origin, origin: candidate.origin,
workspaceDir: candidate.workspaceDir, workspaceDir: candidate.workspaceDir,
enabled: false, enabled: false,
configSchema: false, configSchema: Boolean(manifestRecord.configSchema),
}); });
record.status = "disabled"; record.status = "disabled";
record.error = `overridden by ${existingOrigin} plugin`; record.error = `overridden by ${existingOrigin} plugin`;
@ -366,25 +238,42 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
continue; continue;
} }
const enableState = resolveEnableState(candidate.idHint, candidate.origin, normalized); const enableState = resolveEnableState(pluginId, candidate.origin, normalized);
const entry = normalized.entries[candidate.idHint]; const entry = normalized.entries[pluginId];
const record = createPluginRecord({ const record = createPluginRecord({
id: candidate.idHint, id: pluginId,
name: candidate.packageName ?? candidate.idHint, name: manifestRecord.name ?? pluginId,
description: candidate.packageDescription, description: manifestRecord.description,
version: candidate.packageVersion, version: manifestRecord.version,
source: candidate.source, source: candidate.source,
origin: candidate.origin, origin: candidate.origin,
workspaceDir: candidate.workspaceDir, workspaceDir: candidate.workspaceDir,
enabled: enableState.enabled, enabled: enableState.enabled,
configSchema: false, configSchema: Boolean(manifestRecord.configSchema),
}); });
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
if (!enableState.enabled) { if (!enableState.enabled) {
record.status = "disabled"; record.status = "disabled";
record.error = enableState.reason; record.error = enableState.reason;
registry.plugins.push(record); 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; continue;
} }
@ -396,7 +285,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error"; record.status = "error";
record.error = String(err); record.error = String(err);
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
@ -422,61 +311,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.name = definition?.name ?? record.name; record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description; record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version; record.version = definition?.version ?? record.version;
record.kind = definition?.kind; const manifestKind = record.kind as string | undefined;
record.configSchema = Boolean(definition?.configSchema); const exportKind = definition?.kind as string | undefined;
record.configUiHints = if (manifestKind && exportKind && exportKind !== manifestKind) {
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);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "warn",
pluginId: record.id, pluginId: record.id,
source: record.source, 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) { if (record.kind === "memory" && memorySlot === record.id) {
memorySlotMatched = true; memorySlotMatched = true;
@ -494,7 +339,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "disabled"; record.status = "disabled";
record.error = memoryDecision.reason; record.error = memoryDecision.reason;
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
continue; continue;
} }
@ -503,7 +348,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
} }
const validatedConfig = validatePluginConfig({ const validatedConfig = validatePluginConfig({
schema: definition?.configSchema, schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config, value: entry?.config,
}); });
@ -512,7 +358,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error"; record.status = "error";
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`; record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
@ -524,7 +370,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
if (validateOnly) { if (validateOnly) {
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
continue; continue;
} }
@ -533,7 +379,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
record.status = "error"; record.status = "error";
record.error = "plugin export missing register/activate"; record.error = "plugin export missing register/activate";
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, pluginId: record.id,
@ -559,7 +405,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
}); });
} }
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
} catch (err) { } catch (err) {
logger.error( logger.error(
`[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`, `[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.status = "error";
record.error = String(err); record.error = String(err);
registry.plugins.push(record); registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin); seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({ registry.diagnostics.push({
level: "error", level: "error",
pluginId: record.id, 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 }; type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = []; const tempDirs: string[] = [];
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
function makeTempDir() { function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-tools-${randomUUID()}`); 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 dir = makeTempDir();
const file = path.join(dir, `${params.id}.js`); const file = path.join(dir, `${params.id}.js`);
fs.writeFileSync(file, params.body, "utf-8"); 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 }; return { dir, file, id: params.id };
} }
@ -36,10 +49,8 @@ afterEach(() => {
}); });
describe("resolvePluginTools optional tools", () => { describe("resolvePluginTools optional tools", () => {
const emptyConfigSchema =
'configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },';
const pluginBody = ` const pluginBody = `
export default { ${emptyConfigSchema} register(api) { export default { register(api) {
api.registerTool( api.registerTool(
{ {
name: "optional_tool", name: "optional_tool",
@ -140,7 +151,7 @@ export default { ${emptyConfigSchema} register(api) {
const plugin = writePlugin({ const plugin = writePlugin({
id: "multi", id: "multi",
body: ` body: `
export default { ${emptyConfigSchema} register(api) { export default { register(api) {
api.registerTool({ api.registerTool({
name: "message", name: "message",
description: "conflict", description: "conflict",