From 2f6d5805de1551dd4965334cfacefba5781861a4 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 19 Jan 2026 21:13:51 -0600 Subject: [PATCH] fix: enforce plugin config schemas (#1272) (thanks @thewilloftheshadow) Co-authored-by: thewilloftheshadow --- CHANGELOG.md | 8 +- docs/cli/plugins.md | 5 + docs/plugin.md | 47 +- docs/plugins/manifest.md | 63 +++ docs/refactor/strict-config.md | 7 +- extensions/bluebubbles/clawdbot.plugin.json | 11 + extensions/copilot-proxy/clawdbot.plugin.json | 11 + extensions/discord/clawdbot.plugin.json | 11 + .../clawdbot.plugin.json | 11 + .../clawdbot.plugin.json | 11 + extensions/imessage/clawdbot.plugin.json | 11 + extensions/matrix/clawdbot.plugin.json | 11 + extensions/memory-core/clawdbot.plugin.json | 9 + .../memory-lancedb/clawdbot.plugin.json | 67 +++ extensions/msteams/clawdbot.plugin.json | 11 + .../qwen-portal-auth/clawdbot.plugin.json | 11 + extensions/signal/clawdbot.plugin.json | 11 + extensions/slack/clawdbot.plugin.json | 11 + extensions/telegram/clawdbot.plugin.json | 11 + extensions/voice-call/clawdbot.plugin.json | 405 ++++++++++++++++++ extensions/whatsapp/clawdbot.plugin.json | 11 + extensions/zalo/clawdbot.plugin.json | 11 + extensions/zalouser/clawdbot.plugin.json | 11 + src/agents/sandbox/constants.ts | 3 +- ...ult-guard.tool-result-persist-hook.test.ts | 26 +- src/auto-reply/reply/commands-config.ts | 6 +- src/cli/program/config-guard.ts | 29 +- src/commands/doctor-config-flow.ts | 5 + src/commands/setup.ts | 10 +- src/config/config.backup-rotation.test.ts | 7 +- .../config.nix-integration-u3-u5-u9.test.ts | 19 + src/config/config.plugin-validation.test.ts | 152 +++++++ src/config/config.ts | 2 +- src/config/io.ts | 55 ++- src/config/legacy-migrate.ts | 4 +- src/config/types.clawdbot.ts | 1 + src/config/validation.ts | 189 ++++++++ src/config/zod-schema.providers.ts | 2 +- src/gateway/server-methods/config.ts | 8 +- src/gateway/server.config-apply.test.ts | 2 +- src/hooks/gmail-ops.ts | 4 +- src/plugins/config-state.ts | 126 ++++++ src/plugins/discovery.ts | 17 +- src/plugins/loader.test.ts | 92 ++-- src/plugins/loader.ts | 320 ++++---------- src/plugins/manifest-registry.ts | 189 ++++++++ src/plugins/manifest.ts | 91 ++++ src/plugins/schema-validator.ts | 40 ++ src/plugins/tools.optional.test.ts | 19 +- 49 files changed, 1817 insertions(+), 377 deletions(-) create mode 100644 docs/plugins/manifest.md create mode 100644 extensions/bluebubbles/clawdbot.plugin.json create mode 100644 extensions/copilot-proxy/clawdbot.plugin.json create mode 100644 extensions/discord/clawdbot.plugin.json create mode 100644 extensions/google-antigravity-auth/clawdbot.plugin.json create mode 100644 extensions/google-gemini-cli-auth/clawdbot.plugin.json create mode 100644 extensions/imessage/clawdbot.plugin.json create mode 100644 extensions/matrix/clawdbot.plugin.json create mode 100644 extensions/memory-core/clawdbot.plugin.json create mode 100644 extensions/memory-lancedb/clawdbot.plugin.json create mode 100644 extensions/msteams/clawdbot.plugin.json create mode 100644 extensions/qwen-portal-auth/clawdbot.plugin.json create mode 100644 extensions/signal/clawdbot.plugin.json create mode 100644 extensions/slack/clawdbot.plugin.json create mode 100644 extensions/telegram/clawdbot.plugin.json create mode 100644 extensions/voice-call/clawdbot.plugin.json create mode 100644 extensions/whatsapp/clawdbot.plugin.json create mode 100644 extensions/zalo/clawdbot.plugin.json create mode 100644 extensions/zalouser/clawdbot.plugin.json create mode 100644 src/config/config.plugin-validation.test.ts create mode 100644 src/plugins/config-state.ts create mode 100644 src/plugins/manifest-registry.ts create mode 100644 src/plugins/manifest.ts create mode 100644 src/plugins/schema-validator.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c4aa57199..3648aee59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,12 @@ Docs: https://docs.clawd.bot ### Changes - Repo: remove the Peekaboo git submodule now that the SPM release is used. -- Gateway: raise default lane concurrency for main and sub-agent runs. -- Config: centralize default agent concurrency limits. +- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. ### Fixes -- Cron: serialize scheduler operations per store path to prevent duplicate runs across hot reloads. (#1216) — thanks @carlulsoe. - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). -- Agents: treat OAuth refresh failures as auth errors to trigger model fallback. (#1261) — thanks @zknicker. - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - CLI: avoid duplicating --profile/--dev flags when formatting commands. -- Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik. -- Agents: avoid misclassifying context-window-too-small errors as context overflow. (#1266) — thanks @humanwritten. -- Slack: resolve Bolt default-export shapes for monitor startup. (#1208) — thanks @24601. ## 2026.1.19-3 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 593996984..7cd275781 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -11,6 +11,7 @@ Manage Gateway plugins/extensions (loaded in-process). Related: - Plugin system: [Plugins](/plugin) +- Plugin manifest + schema: [Plugin manifest](/plugins/manifest) - Security hardening: [Security](/gateway/security) ## Commands @@ -28,6 +29,10 @@ clawdbot plugins update --all Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to activate them. +All plugins must ship a `clawdbot.plugin.json` file with an inline JSON Schema +(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent +the plugin from loading and fail config validation. + ### Install ```bash diff --git a/docs/plugin.md b/docs/plugin.md index 4897e0738..bea964041 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -48,8 +48,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin. - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default) - Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default) -Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can -register: +Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config +validation does not execute plugin code**; it uses the plugin manifest and JSON +Schema instead. See [Plugin manifest](/plugins/manifest). + +Plugins can register: - Gateway RPC methods - Gateway HTTP handlers @@ -83,6 +86,10 @@ Bundled plugins must be enabled explicitly via `plugins.entries..enabled` or `clawdbot plugins enable `. Installed plugins are enabled by default, but can be disabled the same way. +Each plugin must include a `clawdbot.plugin.json` file in its root. If a path +points at a file, the plugin root is the file's directory and must contain the +manifest. + If multiple plugins resolve to the same id, the first match in the order above wins and lower-precedence copies are ignored. @@ -140,6 +147,14 @@ Fields: Config changes **require a gateway restart**. +Validation rules (strict): +- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**. +- Unknown `channels.` keys are **errors** unless a plugin manifest declares + the channel id. +- Plugin config is validated using the JSON Schema embedded in + `clawdbot.plugin.json` (`configSchema`). +- If a plugin is disabled, its config is preserved and a **warning** is emitted. + ## Plugin slots (exclusive categories) Some plugin categories are **exclusive** (only one active at a time). Use @@ -169,22 +184,26 @@ Clawdbot augments `uiHints` at runtime based on discovered plugins: `plugins.entries..config.` If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive), -provide `configSchema.uiHints`. +provide `uiHints` alongside your JSON Schema in the plugin manifest. Example: -```ts -export default { - id: "my-plugin", - configSchema: { - parse: (v) => v, - uiHints: { - "apiKey": { label: "API Key", sensitive: true }, - "region": { label: "Region", placeholder: "us-east-1" }, - }, +```json +{ + "id": "my-plugin", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { "type": "string" }, + "region": { "type": "string" } + } }, - register(api) {}, -}; + "uiHints": { + "apiKey": { "label": "API Key", "sensitive": true }, + "region": { "label": "Region", "placeholder": "us-east-1" } + } +} ``` ## CLI diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md new file mode 100644 index 000000000..97ea69aa9 --- /dev/null +++ b/docs/plugins/manifest.md @@ -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.`, `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. diff --git a/docs/refactor/strict-config.md b/docs/refactor/strict-config.md index 03298055b..cfdc0ca7b 100644 --- a/docs/refactor/strict-config.md +++ b/docs/refactor/strict-config.md @@ -22,17 +22,20 @@ read_when: - Unknown keys are validation errors (no passthrough at root or nested). - `plugins.entries..config` must be validated by the plugin’s schema. - If a plugin lacks a schema, **reject plugin load** and surface a clear error. +- Unknown `channels.` keys are errors unless a plugin manifest declares the channel id. +- Plugin manifests (`clawdbot.plugin.json`) are required for all plugins. ## Plugin schema enforcement -- Each plugin provides a strict schema for its config (no passthrough). +- Each plugin provides a strict JSON Schema for its config (inline in the manifest). - Plugin load flow: - 1) Resolve plugin schema by plugin id. + 1) Resolve plugin manifest + schema (`clawdbot.plugin.json`). 2) Validate config against the schema. 3) If missing schema or invalid config: block plugin load, record error. - Error message includes: - Plugin id - Reason (missing schema / invalid config) - Path(s) that failed validation +- Disabled plugins keep their config, but Doctor + logs surface a warning. ## Doctor flow - Doctor runs **every time** config is loaded (dry-run by default). diff --git a/extensions/bluebubbles/clawdbot.plugin.json b/extensions/bluebubbles/clawdbot.plugin.json new file mode 100644 index 000000000..80e2a7bdb --- /dev/null +++ b/extensions/bluebubbles/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "bluebubbles", + "channels": [ + "bluebubbles" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/copilot-proxy/clawdbot.plugin.json b/extensions/copilot-proxy/clawdbot.plugin.json new file mode 100644 index 000000000..c27a03f7d --- /dev/null +++ b/extensions/copilot-proxy/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "copilot-proxy", + "providers": [ + "copilot-proxy" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/discord/clawdbot.plugin.json b/extensions/discord/clawdbot.plugin.json new file mode 100644 index 000000000..0ffc52160 --- /dev/null +++ b/extensions/discord/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "discord", + "channels": [ + "discord" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/google-antigravity-auth/clawdbot.plugin.json b/extensions/google-antigravity-auth/clawdbot.plugin.json new file mode 100644 index 000000000..95dcf051a --- /dev/null +++ b/extensions/google-antigravity-auth/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "google-antigravity-auth", + "providers": [ + "google-antigravity" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/google-gemini-cli-auth/clawdbot.plugin.json b/extensions/google-gemini-cli-auth/clawdbot.plugin.json new file mode 100644 index 000000000..ff5e5ec95 --- /dev/null +++ b/extensions/google-gemini-cli-auth/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "google-gemini-cli-auth", + "providers": [ + "google-gemini-cli" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/imessage/clawdbot.plugin.json b/extensions/imessage/clawdbot.plugin.json new file mode 100644 index 000000000..a6b3229a0 --- /dev/null +++ b/extensions/imessage/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "imessage", + "channels": [ + "imessage" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/matrix/clawdbot.plugin.json b/extensions/matrix/clawdbot.plugin.json new file mode 100644 index 000000000..30ce39257 --- /dev/null +++ b/extensions/matrix/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "matrix", + "channels": [ + "matrix" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/memory-core/clawdbot.plugin.json b/extensions/memory-core/clawdbot.plugin.json new file mode 100644 index 000000000..483e2d26f --- /dev/null +++ b/extensions/memory-core/clawdbot.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "memory-core", + "kind": "memory", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/memory-lancedb/clawdbot.plugin.json b/extensions/memory-lancedb/clawdbot.plugin.json new file mode 100644 index 000000000..94cd679ca --- /dev/null +++ b/extensions/memory-lancedb/clawdbot.plugin.json @@ -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" + ] + } +} diff --git a/extensions/msteams/clawdbot.plugin.json b/extensions/msteams/clawdbot.plugin.json new file mode 100644 index 000000000..cad40576f --- /dev/null +++ b/extensions/msteams/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "msteams", + "channels": [ + "msteams" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/qwen-portal-auth/clawdbot.plugin.json b/extensions/qwen-portal-auth/clawdbot.plugin.json new file mode 100644 index 000000000..ff668fd8e --- /dev/null +++ b/extensions/qwen-portal-auth/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "qwen-portal-auth", + "providers": [ + "qwen-portal" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/signal/clawdbot.plugin.json b/extensions/signal/clawdbot.plugin.json new file mode 100644 index 000000000..7a8c77ba0 --- /dev/null +++ b/extensions/signal/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "signal", + "channels": [ + "signal" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/slack/clawdbot.plugin.json b/extensions/slack/clawdbot.plugin.json new file mode 100644 index 000000000..9699be5f4 --- /dev/null +++ b/extensions/slack/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "slack", + "channels": [ + "slack" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/telegram/clawdbot.plugin.json b/extensions/telegram/clawdbot.plugin.json new file mode 100644 index 000000000..bfb4681d1 --- /dev/null +++ b/extensions/telegram/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "telegram", + "channels": [ + "telegram" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/voice-call/clawdbot.plugin.json b/extensions/voice-call/clawdbot.plugin.json new file mode 100644 index 000000000..fca4a1ea0 --- /dev/null +++ b/extensions/voice-call/clawdbot.plugin.json @@ -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 + } + } + } +} diff --git a/extensions/whatsapp/clawdbot.plugin.json b/extensions/whatsapp/clawdbot.plugin.json new file mode 100644 index 000000000..a3023169b --- /dev/null +++ b/extensions/whatsapp/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "whatsapp", + "channels": [ + "whatsapp" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/zalo/clawdbot.plugin.json b/extensions/zalo/clawdbot.plugin.json new file mode 100644 index 000000000..98c86d180 --- /dev/null +++ b/extensions/zalo/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "zalo", + "channels": [ + "zalo" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/zalouser/clawdbot.plugin.json b/extensions/zalouser/clawdbot.plugin.json new file mode 100644 index 000000000..6c5b98734 --- /dev/null +++ b/extensions/zalouser/clawdbot.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "zalouser", + "channels": [ + "zalouser" + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 7b6cb9739..c6391789a 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -48,6 +48,7 @@ export const DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS = 12_000; export const SANDBOX_AGENT_WORKSPACE_MOUNT = "/agent"; -export const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDBOT, "sandbox"); +const resolvedSandboxStateDir = STATE_DIR_CLAWDBOT ?? path.join(os.homedir(), ".clawdbot"); +export const SANDBOX_STATE_DIR = path.join(resolvedSandboxStateDir, "sandbox"); export const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); export const SANDBOX_BROWSER_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "browsers.json"); diff --git a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts index ac6aea396..133ba57b8 100644 --- a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts +++ b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts @@ -10,15 +10,25 @@ import { loadClawdbotPlugins } from "../plugins/loader.js"; import { resetGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { guardSessionManager } from "./session-tool-result-guard-wrapper.js"; -const EMPTY_CONFIG_SCHEMA = `configSchema: { - validate: () => ({ ok: true }), - jsonSchema: { type: "object", additionalProperties: true }, - uiHints: {} -}`; +const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; function writeTempPlugin(params: { dir: string; id: string; body: string }): string { - const file = path.join(params.dir, `${params.id}.mjs`); + const pluginDir = path.join(params.dir, params.id); + fs.mkdirSync(pluginDir, { recursive: true }); + const file = path.join(pluginDir, `${params.id}.mjs`); fs.writeFileSync(file, params.body, "utf-8"); + fs.writeFileSync( + path.join(pluginDir, "clawdbot.plugin.json"), + JSON.stringify( + { + id: params.id, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); return file; } @@ -63,7 +73,7 @@ describe("tool_result_persist hook", () => { const pluginA = writeTempPlugin({ dir: tmp, id: "persist-a", - body: `export default { id: "persist-a", ${EMPTY_CONFIG_SCHEMA}, register(api) { + body: `export default { id: "persist-a", register(api) { api.on("tool_result_persist", (event, ctx) => { const msg = event.message; // Example: remove large diagnostic payloads before persistence. @@ -76,7 +86,7 @@ describe("tool_result_persist hook", () => { const pluginB = writeTempPlugin({ dir: tmp, id: "persist-b", - body: `export default { id: "persist-b", ${EMPTY_CONFIG_SCHEMA}, register(api) { + body: `export default { id: "persist-b", register(api) { api.on("tool_result_persist", (event) => { const prior = (event.message && event.message.persistOrder) ? event.message.persistOrder : []; return { message: { ...event.message, persistOrder: [...prior, "b"] } }; diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index 1d68cb58f..4090f1281 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -1,6 +1,6 @@ import { readConfigFileSnapshot, - validateConfigObject, + validateConfigObjectWithPlugins, writeConfigFile, } from "../../config/config.js"; import { @@ -120,7 +120,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, }; } - const validated = validateConfigObject(parsedBase); + const validated = validateConfigObjectWithPlugins(parsedBase); if (!validated.ok) { const issue = validated.issues[0]; return { @@ -146,7 +146,7 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma }; } setConfigValueAtPath(parsedBase, parsedPath.path, configCommand.value); - const validated = validateConfigObject(parsedBase); + const validated = validateConfigObjectWithPlugins(parsedBase); if (!validated.ok) { const issue = validated.issues[0]; return { diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index bc3d4be8b..0d984bcaf 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -1,8 +1,6 @@ import { readConfigFileSnapshot } from "../../config/config.js"; -import { colorize, isRich, theme } from "../../terminal/theme.js"; import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { loadClawdbotPlugins } from "../../plugins/loader.js"; +import { colorize, isRich, theme } from "../../terminal/theme.js"; import type { RuntimeEnv } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; @@ -30,26 +28,7 @@ export async function ensureConfigReady(params: { ? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`) : []; - const pluginIssues: string[] = []; - if (snapshot.valid) { - const workspaceDir = resolveAgentWorkspaceDir( - snapshot.config, - resolveDefaultAgentId(snapshot.config), - ); - const registry = loadClawdbotPlugins({ - config: snapshot.config, - workspaceDir: workspaceDir ?? undefined, - cache: false, - mode: "validate", - }); - for (const diag of registry.diagnostics) { - if (diag.level !== "error") continue; - const id = diag.pluginId ? ` ${diag.pluginId}` : ""; - pluginIssues.push(`- plugin${id}: ${diag.message}`); - } - } - - const invalid = snapshot.exists && (!snapshot.valid || pluginIssues.length > 0); + const invalid = snapshot.exists && !snapshot.valid; if (!invalid) return; const rich = isRich(); @@ -68,10 +47,6 @@ export async function ensureConfigReady(params: { params.runtime.error(muted("Legacy config keys detected:")); params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n")); } - if (pluginIssues.length > 0) { - params.runtime.error(muted("Plugin config errors:")); - params.runtime.error(pluginIssues.map((issue) => ` ${error(issue)}`).join("\n")); - } params.runtime.error(""); params.runtime.error( `${muted("Run:")} ${commandText(formatCliCommand("clawdbot doctor --fix"))}`, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 13b07af67..d5bfdbca3 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -128,6 +128,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) { note("Config invalid; doctor will run with best-effort config.", "Config"); } + const warnings = snapshot.warnings ?? []; + if (warnings.length > 0) { + const lines = warnings.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"); + note(lines, "Config warnings"); + } if (snapshot.legacyIssues.length > 0) { note( diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 21969604b..4c853322e 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -1,11 +1,9 @@ import fs from "node:fs/promises"; -import path from "node:path"; import JSON5 from "json5"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; -import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT } from "../config/config.js"; -import { applyModelDefaults } from "../config/defaults.js"; +import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -26,12 +24,6 @@ async function readConfigFileRaw(): Promise<{ } } -async function writeConfigFile(cfg: ClawdbotConfig) { - await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true }); - const json = JSON.stringify(applyModelDefaults(cfg), null, 2).trimEnd().concat("\n"); - await fs.writeFile(CONFIG_PATH_CLAWDBOT, json, "utf-8"); -} - export async function setupCommand( opts?: { workspace?: string }, runtime: RuntimeEnv = defaultRuntime, diff --git a/src/config/config.backup-rotation.test.ts b/src/config/config.backup-rotation.test.ts index b98688578..5e4676f75 100644 --- a/src/config/config.backup-rotation.test.ts +++ b/src/config/config.backup-rotation.test.ts @@ -12,7 +12,7 @@ describe("config backup rotation", () => { const configPath = resolveConfigPath(); const buildConfig = (version: number): ClawdbotConfig => ({ - identity: { name: `v${version}` }, + agents: { list: [{ id: `v${version}` }] }, }) as ClawdbotConfig; for (let version = 0; version <= 6; version += 1) { @@ -21,7 +21,10 @@ describe("config backup rotation", () => { const readName = async (suffix = "") => { const raw = await fs.readFile(`${configPath}${suffix}`, "utf-8"); - return (JSON.parse(raw) as { identity?: { name?: string } }).identity?.name ?? null; + return ( + (JSON.parse(raw) as { agents?: { list?: Array<{ id?: string }> } }).agents?.list?.[0] + ?.id ?? null + ); }; await expect(readName()).resolves.toBe("v6"); diff --git a/src/config/config.nix-integration-u3-u5-u9.test.ts b/src/config/config.nix-integration-u3-u5-u9.test.ts index 1614de723..ac43b29b5 100644 --- a/src/config/config.nix-integration-u3-u5-u9.test.ts +++ b/src/config/config.nix-integration-u3-u5-u9.test.ts @@ -96,6 +96,25 @@ describe("Nix integration (U3, U5, U9)", () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); await fs.mkdir(configDir, { recursive: true }); + const pluginDir = path.join(home, "plugins", "demo-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, "index.js"), + 'export default { id: "demo-plugin", register() {} };', + "utf-8", + ); + await fs.writeFile( + path.join(pluginDir, "clawdbot.plugin.json"), + JSON.stringify( + { + id: "demo-plugin", + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + null, + 2, + ), + "utf-8", + ); await fs.writeFile( path.join(configDir, "clawdbot.json"), JSON.stringify( diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts new file mode 100644 index 000000000..e4eba84db --- /dev/null +++ b/src/config/config.plugin-validation.test.ts @@ -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; +}) { + 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); + }); + }); +}); diff --git a/src/config/config.ts b/src/config/config.ts index 02d924e20..f3ece82c8 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -10,5 +10,5 @@ export { migrateLegacyConfig } from "./legacy-migrate.js"; export * from "./paths.js"; export * from "./runtime-overrides.js"; export * from "./types.js"; -export { validateConfigObject } from "./validation.js"; +export { validateConfigObject, validateConfigObjectWithPlugins } from "./validation.js"; export { ClawdbotSchema } from "./zod-schema.js"; diff --git a/src/config/io.ts b/src/config/io.ts index 6fdc5f16d..bff886c4e 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -30,8 +30,7 @@ import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveStateDir } from "./paths.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; -import { validateConfigObject } from "./validation.js"; -import { ClawdbotSchema } from "./zod-schema.js"; +import { validateConfigObjectWithPlugins } from "./validation.js"; import { compareClawdbotVersions } from "./version.js"; // Re-export for backwards compatibility @@ -233,21 +232,34 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const resolvedConfig = substituted; warnOnConfigMiskeys(resolvedConfig, deps.logger); if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {}; - const validated = ClawdbotSchema.safeParse(resolvedConfig); - if (!validated.success) { - deps.logger.error("Invalid config:"); - for (const iss of validated.error.issues) { - deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`); - } - return {}; + const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as ClawdbotConfig, { + env: deps.env, + homedir: deps.homedir, + }); + if (preValidationDuplicates.length > 0) { + throw new DuplicateAgentDirError(preValidationDuplicates); } - warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger); + const validated = validateConfigObjectWithPlugins(resolvedConfig); + if (!validated.ok) { + const details = validated.issues + .map((iss) => `- ${iss.path || ""}: ${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 || ""}: ${iss.message}`) + .join("\n"); + deps.logger.warn(`Config warnings:\\n${details}`); + } + warnIfConfigFromFuture(validated.config, deps.logger); const cfg = applyModelDefaults( applyCompactionDefaults( applyContextPruningDefaults( applyAgentDefaults( applySessionDefaults( - applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)), + applyLoggingDefaults(applyMessageDefaults(validated.config)), ), ), ), @@ -310,6 +322,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { config, hash, issues: [], + warnings: [], legacyIssues, }; } @@ -328,6 +341,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { config: {}, hash, issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], + warnings: [], legacyIssues: [], }; } @@ -353,6 +367,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { config: coerceConfig(parsedRes.parsed), hash, issues: [{ path: "", message }], + warnings: [], legacyIssues: [], }; } @@ -375,6 +390,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { config: coerceConfig(resolved), hash, issues: [{ path: "", message }], + warnings: [], legacyIssues: [], }; } @@ -382,7 +398,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const resolvedConfigRaw = substituted; const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); - const validated = validateConfigObject(resolvedConfigRaw); + const validated = validateConfigObjectWithPlugins(resolvedConfigRaw); if (!validated.ok) { return { path: configPath, @@ -393,6 +409,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { config: coerceConfig(resolvedConfigRaw), hash, issues: validated.issues, + warnings: validated.warnings, legacyIssues, }; } @@ -415,6 +432,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { ), hash, issues: [], + warnings: validated.warnings, legacyIssues, }; } catch (err) { @@ -427,6 +445,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { config: {}, hash: hashConfigRaw(null), issues: [{ path: "", message: `read failed: ${String(err)}` }], + warnings: [], legacyIssues: [], }; } @@ -434,6 +453,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { async function writeConfigFile(cfg: ClawdbotConfig) { clearConfigCache(); + const validated = validateConfigObjectWithPlugins(cfg); + if (!validated.ok) { + const issue = validated.issues[0]; + const pathLabel = issue?.path ? issue.path : ""; + throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`); + } + if (validated.warnings.length > 0) { + const details = validated.warnings + .map((warning) => `- ${warning.path}: ${warning.message}`) + .join("\n"); + deps.logger.warn(`Config warnings:\n${details}`); + } const dir = path.dirname(configPath); await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2) diff --git a/src/config/legacy-migrate.ts b/src/config/legacy-migrate.ts index 7b3ec09dc..54e9ffd72 100644 --- a/src/config/legacy-migrate.ts +++ b/src/config/legacy-migrate.ts @@ -1,6 +1,6 @@ import { applyLegacyMigrations } from "./legacy.js"; import type { ClawdbotConfig } from "./types.js"; -import { validateConfigObject } from "./validation.js"; +import { validateConfigObjectWithPlugins } from "./validation.js"; export function migrateLegacyConfig(raw: unknown): { config: ClawdbotConfig | null; @@ -8,7 +8,7 @@ export function migrateLegacyConfig(raw: unknown): { } { const { next, changes } = applyLegacyMigrations(raw); if (!next) return { config: null, changes: [] }; - const validated = validateConfigObject(next); + const validated = validateConfigObjectWithPlugins(next); if (!validated.ok) { changes.push("Migration applied, but config still invalid; fix remaining issues manually."); return { config: null, changes }; diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index dd88354cf..c77dc86c5 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -105,5 +105,6 @@ export type ConfigFileSnapshot = { config: ClawdbotConfig; hash?: string; issues: ConfigValidationIssue[]; + warnings: ConfigValidationIssue[]; legacyIssues: LegacyConfigIssue[]; }; diff --git a/src/config/validation.ts b/src/config/validation.ts index 4b7108db0..7915d8e19 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,3 +1,12 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { CHANNEL_IDS } from "../channels/registry.js"; +import { + normalizePluginsConfig, + resolveEnableState, + resolveMemorySlotDecision, +} from "../plugins/config-state.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; @@ -46,3 +55,183 @@ export function validateConfigObject( ), }; } + +function isRecord(value: unknown): value is Record { + 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(["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(); + 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 }; +} diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 171f2ae27..f0524631d 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -30,5 +30,5 @@ export const ChannelsSchema = z imessage: IMessageConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(), }) - .strict() + .passthrough() .optional(); diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index dbd43d88f..ae746a48c 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -5,7 +5,7 @@ import { parseConfigJson5, readConfigFileSnapshot, resolveConfigSnapshotHash, - validateConfigObject, + validateConfigObjectWithPlugins, writeConfigFile, } from "../../config/config.js"; import { applyLegacyMigrations } from "../../config/legacy.js"; @@ -170,7 +170,7 @@ export const configHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } - const validated = validateConfigObject(parsedRes.parsed); + const validated = validateConfigObjectWithPlugins(parsedRes.parsed); if (!validated.ok) { respond( false, @@ -248,7 +248,7 @@ export const configHandlers: GatewayRequestHandlers = { const merged = applyMergePatch(snapshot.config, parsedRes.parsed); const migrated = applyLegacyMigrations(merged); const resolved = migrated.next ?? merged; - const validated = validateConfigObject(resolved); + const validated = validateConfigObjectWithPlugins(resolved); if (!validated.ok) { respond( false, @@ -303,7 +303,7 @@ export const configHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error)); return; } - const validated = validateConfigObject(parsedRes.parsed); + const validated = validateConfigObjectWithPlugins(parsedRes.parsed); if (!validated.ok) { respond( false, diff --git a/src/gateway/server.config-apply.test.ts b/src/gateway/server.config-apply.test.ts index a0d56d822..7e68e9170 100644 --- a/src/gateway/server.config-apply.test.ts +++ b/src/gateway/server.config-apply.test.ts @@ -41,7 +41,7 @@ describe("gateway config.apply", () => { id, method: "config.apply", params: { - raw: '{ "agent": { "workspace": "~/clawd" } }', + raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }', sessionKey: "agent:main:whatsapp:dm:+15555550123", restartDelayMs: 0, }, diff --git a/src/hooks/gmail-ops.ts b/src/hooks/gmail-ops.ts index abff6ee76..d92b008b8 100644 --- a/src/hooks/gmail-ops.ts +++ b/src/hooks/gmail-ops.ts @@ -6,7 +6,7 @@ import { loadConfig, readConfigFileSnapshot, resolveGatewayPort, - validateConfigObject, + validateConfigObjectWithPlugins, writeConfigFile, } from "../config/config.js"; import { runCommandWithTimeout } from "../process/exec.js"; @@ -244,7 +244,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) { }, }; - const validated = validateConfigObject(nextConfig); + const validated = validateConfigObjectWithPlugins(nextConfig); if (!validated.ok) { throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`); } diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts new file mode 100644 index 000000000..0d156a407 --- /dev/null +++ b/src/plugins/config-state.ts @@ -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; +}; + +export const BUNDLED_ENABLED_BY_DEFAULT = new Set(); + +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; + 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 }; +} diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 06d93e40d..76cc18b26 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { CONFIG_DIR, resolveUserPath } from "../utils.js"; +import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import type { PluginDiagnostic, PluginOrigin } from "./types.js"; @@ -10,6 +10,7 @@ const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); export type PluginCandidate = { idHint: string; source: string; + rootDir: string; origin: PluginOrigin; workspaceDir?: string; packageName?: string; @@ -78,6 +79,7 @@ function addCandidate(params: { seen: Set; idHint: string; source: string; + rootDir: string; origin: PluginOrigin; workspaceDir?: string; manifest?: PackageManifest | null; @@ -89,6 +91,7 @@ function addCandidate(params: { params.candidates.push({ idHint: params.idHint, source: resolved, + rootDir: path.resolve(params.rootDir), origin: params.origin, workspaceDir: params.workspaceDir, packageName: manifest?.name?.trim() || undefined, @@ -127,6 +130,7 @@ function discoverInDirectory(params: { seen: params.seen, idHint: path.basename(entry.name, path.extname(entry.name)), source: fullPath, + rootDir: path.dirname(fullPath), origin: params.origin, workspaceDir: params.workspaceDir, }); @@ -148,6 +152,7 @@ function discoverInDirectory(params: { hasMultipleExtensions: extensions.length > 1, }), source: resolved, + rootDir: fullPath, origin: params.origin, workspaceDir: params.workspaceDir, manifest, @@ -166,6 +171,7 @@ function discoverInDirectory(params: { seen: params.seen, idHint: entry.name, source: indexFile, + rootDir: fullPath, origin: params.origin, workspaceDir: params.workspaceDir, }); @@ -184,7 +190,7 @@ function discoverFromPath(params: { const resolved = resolveUserPath(params.rawPath); if (!fs.existsSync(resolved)) { params.diagnostics.push({ - level: "warn", + level: "error", message: `plugin path not found: ${resolved}`, source: resolved, }); @@ -195,7 +201,7 @@ function discoverFromPath(params: { if (stat.isFile()) { if (!isExtensionFile(resolved)) { params.diagnostics.push({ - level: "warn", + level: "error", message: `plugin path is not a supported file: ${resolved}`, source: resolved, }); @@ -206,6 +212,7 @@ function discoverFromPath(params: { seen: params.seen, idHint: path.basename(resolved, path.extname(resolved)), source: resolved, + rootDir: path.dirname(resolved), origin: params.origin, workspaceDir: params.workspaceDir, }); @@ -228,6 +235,7 @@ function discoverFromPath(params: { hasMultipleExtensions: extensions.length > 1, }), source, + rootDir: resolved, origin: params.origin, workspaceDir: params.workspaceDir, manifest, @@ -247,6 +255,7 @@ function discoverFromPath(params: { seen: params.seen, idHint: path.basename(resolved), source: indexFile, + rootDir: resolved, origin: params.origin, workspaceDir: params.workspaceDir, }); @@ -301,7 +310,7 @@ export function discoverClawdbotPlugins(params: { }); } - const globalDir = path.join(CONFIG_DIR, "extensions"); + const globalDir = path.join(resolveConfigDir(), "extensions"); discoverInDirectory({ dir: globalDir, origin: "global", diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 5d549b042..b0aa1614a 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -10,7 +10,7 @@ type TempPlugin = { dir: string; file: string; id: string }; const tempDirs: string[] = []; const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR; -const EMPTY_CONFIG_SCHEMA = `configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },`; +const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; function makeTempDir() { const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`); @@ -19,10 +19,28 @@ function makeTempDir() { return dir; } -function writePlugin(params: { id: string; body: string }): TempPlugin { - const dir = makeTempDir(); - const file = path.join(dir, `${params.id}.js`); +function writePlugin(params: { + id: string; + body: string; + dir?: string; + filename?: string; +}): TempPlugin { + const dir = params.dir ?? makeTempDir(); + const filename = params.filename ?? `${params.id}.js`; + const file = path.join(dir, filename); fs.writeFileSync(file, params.body, "utf-8"); + fs.writeFileSync( + path.join(dir, "clawdbot.plugin.json"), + JSON.stringify( + { + id: params.id, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); return { dir, file, id: params.id }; } @@ -44,12 +62,12 @@ afterEach(() => { describe("loadClawdbotPlugins", () => { it("disables bundled plugins by default", () => { const bundledDir = makeTempDir(); - const bundledPath = path.join(bundledDir, "bundled.ts"); - fs.writeFileSync( - bundledPath, - `export default { id: "bundled", ${EMPTY_CONFIG_SCHEMA} register() {} };`, - "utf-8", - ); + writePlugin({ + id: "bundled", + body: `export default { id: "bundled", register() {} };`, + dir: bundledDir, + filename: "bundled.ts", + }); process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; const registry = loadClawdbotPlugins({ @@ -102,12 +120,12 @@ describe("loadClawdbotPlugins", () => { it("enables bundled memory plugin when selected by slot", () => { const bundledDir = makeTempDir(); - const bundledPath = path.join(bundledDir, "memory-core.ts"); - fs.writeFileSync( - bundledPath, - `export default { id: "memory-core", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, - "utf-8", - ); + writePlugin({ + id: "memory-core", + body: `export default { id: "memory-core", kind: "memory", register() {} };`, + dir: bundledDir, + filename: "memory-core.ts", + }); process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; const registry = loadClawdbotPlugins({ @@ -140,11 +158,12 @@ describe("loadClawdbotPlugins", () => { }), "utf-8", ); - fs.writeFileSync( - path.join(pluginDir, "index.ts"), - `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", ${EMPTY_CONFIG_SCHEMA} register() {} };`, - "utf-8", - ); + writePlugin({ + id: "memory-core", + body: `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };`, + dir: pluginDir, + filename: "index.ts", + }); process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; @@ -169,7 +188,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "allowed", - body: `export default { id: "allowed", ${EMPTY_CONFIG_SCHEMA} register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`, + body: `export default { id: "allowed", register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`, }); const registry = loadClawdbotPlugins({ @@ -192,7 +211,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "blocked", - body: `export default { id: "blocked", ${EMPTY_CONFIG_SCHEMA} register() {} };`, + body: `export default { id: "blocked", register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -215,7 +234,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "configurable", - body: `export default {\n id: "configurable",\n configSchema: {\n parse(value) {\n if (!value || typeof value !== "object" || Array.isArray(value)) {\n throw new Error("bad config");\n }\n return value;\n }\n },\n register() {}\n};`, + body: `export default { id: "configurable", register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -242,7 +261,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "channel-demo", - body: `export default { id: "channel-demo", ${EMPTY_CONFIG_SCHEMA} register(api) { + body: `export default { id: "channel-demo", register(api) { api.registerChannel({ plugin: { id: "demo", @@ -283,7 +302,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "http-demo", - body: `export default { id: "http-demo", ${EMPTY_CONFIG_SCHEMA} register(api) { + body: `export default { id: "http-demo", register(api) { api.registerHttpHandler(async () => false); } };`, }); @@ -309,7 +328,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "config-disable", - body: `export default { id: "config-disable", ${EMPTY_CONFIG_SCHEMA} register() {} };`, + body: `export default { id: "config-disable", register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -332,11 +351,11 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memoryA = writePlugin({ id: "memory-a", - body: `export default { id: "memory-a", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, + body: `export default { id: "memory-a", kind: "memory", register() {} };`, }); const memoryB = writePlugin({ id: "memory-b", - body: `export default { id: "memory-b", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, + body: `export default { id: "memory-b", kind: "memory", register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -359,7 +378,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memory = writePlugin({ id: "memory-off", - body: `export default { id: "memory-off", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, + body: `export default { id: "memory-off", kind: "memory", register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -378,16 +397,17 @@ describe("loadClawdbotPlugins", () => { it("prefers higher-precedence plugins with the same id", () => { const bundledDir = makeTempDir(); - fs.writeFileSync( - path.join(bundledDir, "shadow.js"), - `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`, - "utf-8", - ); + writePlugin({ + id: "shadow", + body: `export default { id: "shadow", register() {} };`, + dir: bundledDir, + filename: "shadow.js", + }); process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; const override = writePlugin({ id: "shadow", - body: `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`, + body: `export default { id: "shadow", register() {} };`, }); const registry = loadClawdbotPlugins({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 2b6cc2134..1406e5ef5 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -8,16 +8,21 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; import { discoverClawdbotPlugins } from "./discovery.js"; +import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { + normalizePluginsConfig, + resolveEnableState, + resolveMemorySlotDecision, + type NormalizedPluginsConfig, +} from "./config-state.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRuntime } from "./runtime/index.js"; import { setActivePluginRegistry } from "./runtime.js"; -import { defaultSlotIdForKey } from "./slots.js"; +import { validateJsonSchemaValue } from "./schema-validator.js"; import type { - ClawdbotPluginConfigSchema, ClawdbotPluginDefinition, ClawdbotPluginModule, - PluginConfigUiHint, PluginDiagnostic, PluginLogger, } from "./types.js"; @@ -33,73 +38,10 @@ export type PluginLoadOptions = { mode?: "full" | "validate"; }; -type NormalizedPluginsConfig = { - enabled: boolean; - allow: string[]; - deny: string[]; - loadPaths: string[]; - slots: { - memory?: string | null; - }; - entries: Record }>; -}; - const registryCache = new Map(); const defaultLogger = () => createSubsystemLogger("plugins"); -const BUNDLED_ENABLED_BY_DEFAULT = new Set(); - -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; - 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) - : undefined, - }; - } - return normalized; -}; - -const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedPluginsConfig => { - const memorySlot = normalizeSlotValue(config?.slots?.memory); - return { - enabled: config?.enabled !== false, - allow: normalizeList(config?.allow), - deny: normalizeList(config?.deny), - loadPaths: normalizeList(config?.load?.paths), - slots: { - memory: memorySlot ?? defaultSlotIdForKey("memory"), - }, - entries: normalizePluginEntries(config?.entries), - }; -}; - const resolvePluginSdkAlias = (): string | null => { try { const modulePath = fileURLToPath(import.meta.url); @@ -133,105 +75,25 @@ function buildCacheKey(params: { return `${workspaceKey}::${JSON.stringify(params.plugins)}`; } -function resolveMemorySlotDecision(params: { - id: string; - kind?: string; - slot: string | null | undefined; - selectedId: string | null; -}): { enabled: boolean; reason?: string; selected?: boolean } { - if (params.kind !== "memory") return { enabled: true }; - if (params.slot === null) { - return { enabled: false, reason: "memory slot disabled" }; - } - if (typeof params.slot === "string") { - if (params.slot === params.id) { - return { enabled: true, selected: true }; - } - return { - enabled: false, - reason: `memory slot set to "${params.slot}"`, - }; - } - if (params.selectedId && params.selectedId !== params.id) { - return { - enabled: false, - reason: `memory slot already filled by "${params.selectedId}"`, - }; - } - return { enabled: true, selected: true }; -} - -function resolveEnableState( - id: string, - origin: PluginRecord["origin"], - config: NormalizedPluginsConfig, -): { enabled: boolean; reason?: string } { - if (!config.enabled) { - return { enabled: false, reason: "plugins disabled" }; - } - if (config.deny.includes(id)) { - return { enabled: false, reason: "blocked by denylist" }; - } - if (config.allow.length > 0 && !config.allow.includes(id)) { - return { enabled: false, reason: "not in allowlist" }; - } - if (config.slots.memory === id) { - return { enabled: true }; - } - const entry = config.entries[id]; - if (entry?.enabled === true) { - return { enabled: true }; - } - if (entry?.enabled === false) { - return { enabled: false, reason: "disabled in config" }; - } - if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) { - return { enabled: true }; - } - if (origin === "bundled") { - return { enabled: false, reason: "bundled (disabled by default)" }; - } - return { enabled: true }; -} - function validatePluginConfig(params: { - schema?: ClawdbotPluginConfigSchema; - value?: Record; + schema?: Record; + cacheKey?: string; + value?: unknown; }): { ok: boolean; value?: Record; errors?: string[] } { const schema = params.schema; - if (!schema) return { ok: true, value: params.value }; - - if (typeof schema.validate === "function") { - const result = schema.validate(params.value); - if (result.ok) { - return { ok: true, value: result.value as Record }; - } - return { ok: false, errors: result.errors }; + if (!schema) { + return { ok: true, value: params.value as Record | undefined }; } - - if (typeof schema.safeParse === "function") { - const result = schema.safeParse(params.value); - if (result.success) { - return { ok: true, value: result.data as Record }; - } - const issues = result.error?.issues ?? []; - const errors = issues.map((issue) => { - const path = issue.path.length > 0 ? issue.path.join(".") : ""; - return `${path}: ${issue.message}`; - }); - return { ok: false, errors }; + const cacheKey = params.cacheKey ?? JSON.stringify(schema); + const result = validateJsonSchemaValue({ + schema, + cacheKey, + value: params.value ?? {}, + }); + if (result.ok) { + return { ok: true, value: params.value as Record | undefined }; } - - if (typeof schema.parse === "function") { - try { - const parsed = schema.parse(params.value); - return { ok: true, value: parsed as Record }; - } catch (err) { - return { ok: false, errors: [String(err)] }; - } - } - - return { ok: true, value: params.value }; + return { ok: false, errors: result.errors }; } function resolvePluginModuleExport(moduleExport: unknown): { @@ -326,7 +188,14 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi workspaceDir: options.workspaceDir, extraPaths: normalized.loadPaths, }); - pushDiagnostics(registry.diagnostics, discovery.diagnostics); + const manifestRegistry = loadPluginManifestRegistry({ + config: cfg, + workspaceDir: options.workspaceDir, + cache: options.cache, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); + pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); const pluginSdkAlias = resolvePluginSdkAlias(); const jiti = createJiti(import.meta.url, { @@ -335,10 +204,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi ...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}), }); - const bundledIds = new Set( - discovery.candidates - .filter((candidate) => candidate.origin === "bundled") - .map((candidate) => candidate.idHint), + const manifestByRoot = new Map( + manifestRegistry.plugins.map((record) => [record.rootDir, record]), ); const seenIds = new Map(); @@ -347,18 +214,23 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi let memorySlotMatched = false; for (const candidate of discovery.candidates) { - const existingOrigin = seenIds.get(candidate.idHint); + const manifestRecord = manifestByRoot.get(candidate.rootDir); + if (!manifestRecord) { + continue; + } + const pluginId = manifestRecord.id; + const existingOrigin = seenIds.get(pluginId); if (existingOrigin) { const record = createPluginRecord({ - id: candidate.idHint, - name: candidate.packageName ?? candidate.idHint, - description: candidate.packageDescription, - version: candidate.packageVersion, + id: pluginId, + name: manifestRecord.name ?? pluginId, + description: manifestRecord.description, + version: manifestRecord.version, source: candidate.source, origin: candidate.origin, workspaceDir: candidate.workspaceDir, enabled: false, - configSchema: false, + configSchema: Boolean(manifestRecord.configSchema), }); record.status = "disabled"; record.error = `overridden by ${existingOrigin} plugin`; @@ -366,25 +238,42 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - const enableState = resolveEnableState(candidate.idHint, candidate.origin, normalized); - const entry = normalized.entries[candidate.idHint]; + const enableState = resolveEnableState(pluginId, candidate.origin, normalized); + const entry = normalized.entries[pluginId]; const record = createPluginRecord({ - id: candidate.idHint, - name: candidate.packageName ?? candidate.idHint, - description: candidate.packageDescription, - version: candidate.packageVersion, + id: pluginId, + name: manifestRecord.name ?? pluginId, + description: manifestRecord.description, + version: manifestRecord.version, source: candidate.source, origin: candidate.origin, workspaceDir: candidate.workspaceDir, enabled: enableState.enabled, - configSchema: false, + configSchema: Boolean(manifestRecord.configSchema), }); + record.kind = manifestRecord.kind; + record.configUiHints = manifestRecord.configUiHints; + record.configJsonSchema = manifestRecord.configSchema; if (!enableState.enabled) { record.status = "disabled"; record.error = enableState.reason; registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); + continue; + } + + if (!manifestRecord.configSchema) { + record.status = "error"; + record.error = "missing config schema"; + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + registry.diagnostics.push({ + level: "error", + pluginId: record.id, + source: record.source, + message: record.error, + }); continue; } @@ -396,7 +285,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi record.status = "error"; record.error = String(err); registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ level: "error", pluginId: record.id, @@ -422,61 +311,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi record.name = definition?.name ?? record.name; record.description = definition?.description ?? record.description; record.version = definition?.version ?? record.version; - record.kind = definition?.kind; - record.configSchema = Boolean(definition?.configSchema); - record.configUiHints = - definition?.configSchema && - typeof definition.configSchema === "object" && - (definition.configSchema as { uiHints?: unknown }).uiHints && - typeof (definition.configSchema as { uiHints?: unknown }).uiHints === "object" && - !Array.isArray((definition.configSchema as { uiHints?: unknown }).uiHints) - ? ((definition.configSchema as { uiHints?: unknown }).uiHints as Record< - string, - PluginConfigUiHint - >) - : undefined; - record.configJsonSchema = - definition?.configSchema && - typeof definition.configSchema === "object" && - (definition.configSchema as { jsonSchema?: unknown }).jsonSchema && - typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" && - !Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema) - ? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record< - string, - unknown - >) - : undefined; - - if (!definition?.configSchema) { - const hasBundledFallback = - candidate.origin !== "bundled" && bundledIds.has(candidate.idHint); - if (hasBundledFallback) { - record.enabled = false; - record.status = "disabled"; - record.error = "missing config schema (using bundled plugin)"; - registry.plugins.push(record); - registry.diagnostics.push({ - level: "warn", - pluginId: record.id, - source: record.source, - message: record.error, - }); - continue; - } - - logger.error(`[plugins] ${record.id} missing config schema`); - record.status = "error"; - record.error = "missing config schema"; - registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + const manifestKind = record.kind as string | undefined; + const exportKind = definition?.kind as string | undefined; + if (manifestKind && exportKind && exportKind !== manifestKind) { registry.diagnostics.push({ - level: "error", + level: "warn", pluginId: record.id, source: record.source, - message: record.error, + message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`, }); - continue; } + record.kind = definition?.kind ?? record.kind; if (record.kind === "memory" && memorySlot === record.id) { memorySlotMatched = true; @@ -494,7 +339,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi record.status = "disabled"; record.error = memoryDecision.reason; registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); continue; } @@ -503,7 +348,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi } const validatedConfig = validatePluginConfig({ - schema: definition?.configSchema, + schema: manifestRecord.configSchema, + cacheKey: manifestRecord.schemaCacheKey, value: entry?.config, }); @@ -512,7 +358,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi record.status = "error"; record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`; registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ level: "error", pluginId: record.id, @@ -524,7 +370,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi if (validateOnly) { registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); continue; } @@ -533,7 +379,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi record.status = "error"; record.error = "plugin export missing register/activate"; registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ level: "error", pluginId: record.id, @@ -559,7 +405,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi }); } registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); } catch (err) { logger.error( `[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`, @@ -567,7 +413,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi record.status = "error"; record.error = String(err); registry.plugins.push(record); - seenIds.set(candidate.idHint, candidate.origin); + seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ level: "error", pluginId: record.id, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts new file mode 100644 index 000000000..aa12ca1d6 --- /dev/null +++ b/src/plugins/manifest-registry.ts @@ -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; + configUiHints?: Record; +}; + +export type PluginManifestRegistry = { + plugins: PluginManifestRecord[]; + diagnostics: PluginDiagnostic[]; +}; + +const registryCache = new Map(); + +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; +}): 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(); + + 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; +} diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts new file mode 100644 index 000000000..40f4d1e03 --- /dev/null +++ b/src/plugins/manifest.ts @@ -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; + kind?: PluginKind; + channels?: string[]; + providers?: string[]; + name?: string; + description?: string; + version?: string; + uiHints?: Record; +}; + +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 { + 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 | undefined; + if (isRecord(raw.uiHints)) { + uiHints = raw.uiHints as Record; + } + + return { + ok: true, + manifest: { + id, + configSchema, + kind, + channels, + providers, + name, + description, + version, + uiHints, + }, + manifestPath, + }; +} diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts new file mode 100644 index 000000000..97adb9b3b --- /dev/null +++ b/src/plugins/schema-validator.ts @@ -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; +}; + +const schemaCache = new Map(); + +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, ".") || ""; + const message = error.message ?? "invalid"; + return `${path}: ${message}`; + }); +} + +export function validateJsonSchemaValue(params: { + schema: Record; + 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) }; +} diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 1705367e0..44de89093 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -10,6 +10,7 @@ import { resolvePluginTools } from "./tools.js"; type TempPlugin = { dir: string; file: string; id: string }; const tempDirs: string[] = []; +const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; function makeTempDir() { const dir = path.join(os.tmpdir(), `clawdbot-plugin-tools-${randomUUID()}`); @@ -22,6 +23,18 @@ function writePlugin(params: { id: string; body: string }): TempPlugin { const dir = makeTempDir(); const file = path.join(dir, `${params.id}.js`); fs.writeFileSync(file, params.body, "utf-8"); + fs.writeFileSync( + path.join(dir, "clawdbot.plugin.json"), + JSON.stringify( + { + id: params.id, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); return { dir, file, id: params.id }; } @@ -36,10 +49,8 @@ afterEach(() => { }); describe("resolvePluginTools optional tools", () => { - const emptyConfigSchema = - 'configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },'; const pluginBody = ` -export default { ${emptyConfigSchema} register(api) { +export default { register(api) { api.registerTool( { name: "optional_tool", @@ -140,7 +151,7 @@ export default { ${emptyConfigSchema} register(api) { const plugin = writePlugin({ id: "multi", body: ` -export default { ${emptyConfigSchema} register(api) { +export default { register(api) { api.registerTool({ name: "message", description: "conflict",