Merge pull request #1272 from clawdbot/shadow/config-plugin-validation
Config: validate plugin config
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,8 +48,11 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. **Config
|
||||
validation does not execute plugin code**; it uses the plugin manifest and JSON
|
||||
Schema instead. See [Plugin manifest](/plugins/manifest).
|
||||
|
||||
Plugins can register:
|
||||
|
||||
- Gateway RPC methods
|
||||
- Gateway HTTP handlers
|
||||
@@ -83,6 +86,10 @@ Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
|
||||
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
|
||||
but can be disabled the same way.
|
||||
|
||||
Each plugin must include a `clawdbot.plugin.json` file in its root. If a path
|
||||
points at a file, the plugin root is the file's directory and must contain the
|
||||
manifest.
|
||||
|
||||
If multiple plugins resolve to the same id, the first match in the order above
|
||||
wins and lower-precedence copies are ignored.
|
||||
|
||||
@@ -140,6 +147,14 @@ Fields:
|
||||
|
||||
Config changes **require a gateway restart**.
|
||||
|
||||
Validation rules (strict):
|
||||
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
|
||||
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
|
||||
the channel id.
|
||||
- Plugin config is validated using the JSON Schema embedded in
|
||||
`clawdbot.plugin.json` (`configSchema`).
|
||||
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
|
||||
|
||||
## Plugin slots (exclusive categories)
|
||||
|
||||
Some plugin categories are **exclusive** (only one active at a time). Use
|
||||
@@ -169,22 +184,26 @@ Clawdbot augments `uiHints` at runtime based on discovered plugins:
|
||||
`plugins.entries.<id>.config.<field>`
|
||||
|
||||
If you want your plugin config fields to show good labels/placeholders (and mark secrets as sensitive),
|
||||
provide `configSchema.uiHints`.
|
||||
provide `uiHints` alongside your JSON Schema in the plugin manifest.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
export default {
|
||||
id: "my-plugin",
|
||||
configSchema: {
|
||||
parse: (v) => v,
|
||||
uiHints: {
|
||||
"apiKey": { label: "API Key", sensitive: true },
|
||||
"region": { label: "Region", placeholder: "us-east-1" },
|
||||
},
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": { "type": "string" },
|
||||
"region": { "type": "string" }
|
||||
}
|
||||
},
|
||||
register(api) {},
|
||||
};
|
||||
"uiHints": {
|
||||
"apiKey": { "label": "API Key", "sensitive": true },
|
||||
"region": { "label": "Region", "placeholder": "us-east-1" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
63
docs/plugins/manifest.md
Normal file
63
docs/plugins/manifest.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
|
||||
read_when:
|
||||
- You are building a Clawdbot plugin
|
||||
- You need to ship a plugin config schema or debug plugin validation errors
|
||||
---
|
||||
# Plugin manifest (clawdbot.plugin.json)
|
||||
|
||||
Every plugin **must** ship a `clawdbot.plugin.json` file in the **plugin root**.
|
||||
Clawdbot uses this manifest to validate configuration **without executing plugin
|
||||
code**. Missing or invalid manifests are treated as plugin errors and block
|
||||
config validation.
|
||||
|
||||
See the full plugin system guide: [Plugins](/plugin).
|
||||
|
||||
## Required fields
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "voice-call",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required keys:
|
||||
- `id` (string): canonical plugin id.
|
||||
- `configSchema` (object): JSON Schema for plugin config (inline).
|
||||
|
||||
Optional keys:
|
||||
- `kind` (string): plugin kind (example: `"memory"`).
|
||||
- `channels` (array): channel ids registered by this plugin (example: `["matrix"]`).
|
||||
- `providers` (array): provider ids registered by this plugin.
|
||||
- `name` (string): display name for the plugin.
|
||||
- `description` (string): short plugin summary.
|
||||
- `uiHints` (object): config field labels/placeholders/sensitive flags for UI rendering.
|
||||
- `version` (string): plugin version (informational).
|
||||
|
||||
## JSON Schema requirements
|
||||
|
||||
- **Every plugin must ship a JSON Schema**, even if it accepts no config.
|
||||
- An empty schema is acceptable (for example, `{ "type": "object", "additionalProperties": false }`).
|
||||
- Schemas are validated at config read/write time, not at runtime.
|
||||
|
||||
## Validation behavior
|
||||
|
||||
- Unknown `channels.*` keys are **errors**, unless the channel id is declared by
|
||||
a plugin manifest.
|
||||
- `plugins.entries.<id>`, `plugins.allow`, `plugins.deny`, and `plugins.slots.*`
|
||||
must reference **discoverable** plugin ids. Unknown ids are **errors**.
|
||||
- If a plugin is installed but has a broken or missing manifest or schema,
|
||||
validation fails and Doctor reports the plugin error.
|
||||
- If plugin config exists but the plugin is **disabled**, the config is kept and
|
||||
a **warning** is surfaced in Doctor + logs.
|
||||
|
||||
## Notes
|
||||
|
||||
- The manifest is **required for all plugins**, including local filesystem loads.
|
||||
- Runtime still loads the plugin module separately; the manifest is only for
|
||||
discovery + validation.
|
||||
@@ -22,17 +22,20 @@ read_when:
|
||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
||||
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
||||
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
||||
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
|
||||
- Plugin manifests (`clawdbot.plugin.json`) are required for all plugins.
|
||||
|
||||
## Plugin schema enforcement
|
||||
- Each plugin provides a strict schema for its config (no passthrough).
|
||||
- Each plugin provides a strict JSON Schema for its config (inline in the manifest).
|
||||
- Plugin load flow:
|
||||
1) Resolve plugin schema by plugin id.
|
||||
1) Resolve plugin manifest + schema (`clawdbot.plugin.json`).
|
||||
2) Validate config against the schema.
|
||||
3) If missing schema or invalid config: block plugin load, record error.
|
||||
- Error message includes:
|
||||
- Plugin id
|
||||
- Reason (missing schema / invalid config)
|
||||
- Path(s) that failed validation
|
||||
- Disabled plugins keep their config, but Doctor + logs surface a warning.
|
||||
|
||||
## Doctor flow
|
||||
- Doctor runs **every time** config is loaded (dry-run by default).
|
||||
|
||||
11
extensions/bluebubbles/clawdbot.plugin.json
Normal file
11
extensions/bluebubbles/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "bluebubbles",
|
||||
"channels": [
|
||||
"bluebubbles"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/copilot-proxy/clawdbot.plugin.json
Normal file
11
extensions/copilot-proxy/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "copilot-proxy",
|
||||
"providers": [
|
||||
"copilot-proxy"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/discord/clawdbot.plugin.json
Normal file
11
extensions/discord/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "discord",
|
||||
"channels": [
|
||||
"discord"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/google-antigravity-auth/clawdbot.plugin.json
Normal file
11
extensions/google-antigravity-auth/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "google-antigravity-auth",
|
||||
"providers": [
|
||||
"google-antigravity"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/google-gemini-cli-auth/clawdbot.plugin.json
Normal file
11
extensions/google-gemini-cli-auth/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "google-gemini-cli-auth",
|
||||
"providers": [
|
||||
"google-gemini-cli"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/imessage/clawdbot.plugin.json
Normal file
11
extensions/imessage/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "imessage",
|
||||
"channels": [
|
||||
"imessage"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/matrix/clawdbot.plugin.json
Normal file
11
extensions/matrix/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "matrix",
|
||||
"channels": [
|
||||
"matrix"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
9
extensions/memory-core/clawdbot.plugin.json
Normal file
9
extensions/memory-core/clawdbot.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "memory-core",
|
||||
"kind": "memory",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
67
extensions/memory-lancedb/clawdbot.plugin.json
Normal file
67
extensions/memory-lancedb/clawdbot.plugin.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"id": "memory-lancedb",
|
||||
"kind": "memory",
|
||||
"uiHints": {
|
||||
"embedding.apiKey": {
|
||||
"label": "OpenAI API Key",
|
||||
"sensitive": true,
|
||||
"placeholder": "sk-proj-...",
|
||||
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
|
||||
},
|
||||
"embedding.model": {
|
||||
"label": "Embedding Model",
|
||||
"placeholder": "text-embedding-3-small",
|
||||
"help": "OpenAI embedding model to use"
|
||||
},
|
||||
"dbPath": {
|
||||
"label": "Database Path",
|
||||
"placeholder": "~/.clawdbot/memory/lancedb",
|
||||
"advanced": true
|
||||
},
|
||||
"autoCapture": {
|
||||
"label": "Auto-Capture",
|
||||
"help": "Automatically capture important information from conversations"
|
||||
},
|
||||
"autoRecall": {
|
||||
"label": "Auto-Recall",
|
||||
"help": "Automatically inject relevant memories into context"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"embedding": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"text-embedding-3-small",
|
||||
"text-embedding-3-large"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey"
|
||||
]
|
||||
},
|
||||
"dbPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"autoCapture": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"autoRecall": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"embedding"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
extensions/msteams/clawdbot.plugin.json
Normal file
11
extensions/msteams/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "msteams",
|
||||
"channels": [
|
||||
"msteams"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/qwen-portal-auth/clawdbot.plugin.json
Normal file
11
extensions/qwen-portal-auth/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "qwen-portal-auth",
|
||||
"providers": [
|
||||
"qwen-portal"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/signal/clawdbot.plugin.json
Normal file
11
extensions/signal/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "signal",
|
||||
"channels": [
|
||||
"signal"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/slack/clawdbot.plugin.json
Normal file
11
extensions/slack/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "slack",
|
||||
"channels": [
|
||||
"slack"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/telegram/clawdbot.plugin.json
Normal file
11
extensions/telegram/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "telegram",
|
||||
"channels": [
|
||||
"telegram"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
405
extensions/voice-call/clawdbot.plugin.json
Normal file
405
extensions/voice-call/clawdbot.plugin.json
Normal file
@@ -0,0 +1,405 @@
|
||||
{
|
||||
"id": "voice-call",
|
||||
"uiHints": {
|
||||
"provider": {
|
||||
"label": "Provider",
|
||||
"help": "Use twilio, telnyx, or mock for dev/no-network."
|
||||
},
|
||||
"fromNumber": {
|
||||
"label": "From Number",
|
||||
"placeholder": "+15550001234"
|
||||
},
|
||||
"toNumber": {
|
||||
"label": "Default To Number",
|
||||
"placeholder": "+15550001234"
|
||||
},
|
||||
"inboundPolicy": {
|
||||
"label": "Inbound Policy"
|
||||
},
|
||||
"allowFrom": {
|
||||
"label": "Inbound Allowlist"
|
||||
},
|
||||
"inboundGreeting": {
|
||||
"label": "Inbound Greeting",
|
||||
"advanced": true
|
||||
},
|
||||
"telnyx.apiKey": {
|
||||
"label": "Telnyx API Key",
|
||||
"sensitive": true
|
||||
},
|
||||
"telnyx.connectionId": {
|
||||
"label": "Telnyx Connection ID"
|
||||
},
|
||||
"telnyx.publicKey": {
|
||||
"label": "Telnyx Public Key",
|
||||
"sensitive": true
|
||||
},
|
||||
"twilio.accountSid": {
|
||||
"label": "Twilio Account SID"
|
||||
},
|
||||
"twilio.authToken": {
|
||||
"label": "Twilio Auth Token",
|
||||
"sensitive": true
|
||||
},
|
||||
"outbound.defaultMode": {
|
||||
"label": "Default Call Mode"
|
||||
},
|
||||
"outbound.notifyHangupDelaySec": {
|
||||
"label": "Notify Hangup Delay (sec)",
|
||||
"advanced": true
|
||||
},
|
||||
"serve.port": {
|
||||
"label": "Webhook Port"
|
||||
},
|
||||
"serve.bind": {
|
||||
"label": "Webhook Bind"
|
||||
},
|
||||
"serve.path": {
|
||||
"label": "Webhook Path"
|
||||
},
|
||||
"tailscale.mode": {
|
||||
"label": "Tailscale Mode",
|
||||
"advanced": true
|
||||
},
|
||||
"tailscale.path": {
|
||||
"label": "Tailscale Path",
|
||||
"advanced": true
|
||||
},
|
||||
"tunnel.provider": {
|
||||
"label": "Tunnel Provider",
|
||||
"advanced": true
|
||||
},
|
||||
"tunnel.ngrokAuthToken": {
|
||||
"label": "ngrok Auth Token",
|
||||
"sensitive": true,
|
||||
"advanced": true
|
||||
},
|
||||
"tunnel.ngrokDomain": {
|
||||
"label": "ngrok Domain",
|
||||
"advanced": true
|
||||
},
|
||||
"tunnel.allowNgrokFreeTier": {
|
||||
"label": "Allow ngrok Free Tier",
|
||||
"advanced": true
|
||||
},
|
||||
"streaming.enabled": {
|
||||
"label": "Enable Streaming",
|
||||
"advanced": true
|
||||
},
|
||||
"streaming.openaiApiKey": {
|
||||
"label": "OpenAI Realtime API Key",
|
||||
"sensitive": true,
|
||||
"advanced": true
|
||||
},
|
||||
"streaming.sttModel": {
|
||||
"label": "Realtime STT Model",
|
||||
"advanced": true
|
||||
},
|
||||
"streaming.streamPath": {
|
||||
"label": "Media Stream Path",
|
||||
"advanced": true
|
||||
},
|
||||
"tts.model": {
|
||||
"label": "TTS Model",
|
||||
"advanced": true
|
||||
},
|
||||
"tts.voice": {
|
||||
"label": "TTS Voice",
|
||||
"advanced": true
|
||||
},
|
||||
"tts.instructions": {
|
||||
"label": "TTS Instructions",
|
||||
"advanced": true
|
||||
},
|
||||
"publicUrl": {
|
||||
"label": "Public Webhook URL",
|
||||
"advanced": true
|
||||
},
|
||||
"skipSignatureVerification": {
|
||||
"label": "Skip Signature Verification",
|
||||
"advanced": true
|
||||
},
|
||||
"store": {
|
||||
"label": "Call Log Store Path",
|
||||
"advanced": true
|
||||
},
|
||||
"responseModel": {
|
||||
"label": "Response Model",
|
||||
"advanced": true
|
||||
},
|
||||
"responseSystemPrompt": {
|
||||
"label": "Response System Prompt",
|
||||
"advanced": true
|
||||
},
|
||||
"responseTimeoutMs": {
|
||||
"label": "Response Timeout (ms)",
|
||||
"advanced": true
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"telnyx",
|
||||
"twilio",
|
||||
"plivo",
|
||||
"mock"
|
||||
]
|
||||
},
|
||||
"telnyx": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"connectionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"publicKey": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"twilio": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"accountSid": {
|
||||
"type": "string"
|
||||
},
|
||||
"authToken": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plivo": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"authId": {
|
||||
"type": "string"
|
||||
},
|
||||
"authToken": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fromNumber": {
|
||||
"type": "string",
|
||||
"pattern": "^\\+[1-9]\\d{1,14}$"
|
||||
},
|
||||
"toNumber": {
|
||||
"type": "string",
|
||||
"pattern": "^\\+[1-9]\\d{1,14}$"
|
||||
},
|
||||
"inboundPolicy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"disabled",
|
||||
"allowlist",
|
||||
"pairing",
|
||||
"open"
|
||||
]
|
||||
},
|
||||
"allowFrom": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^\\+[1-9]\\d{1,14}$"
|
||||
}
|
||||
},
|
||||
"inboundGreeting": {
|
||||
"type": "string"
|
||||
},
|
||||
"outbound": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"defaultMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"notify",
|
||||
"conversation"
|
||||
]
|
||||
},
|
||||
"notifyHangupDelaySec": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"maxDurationSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"silenceTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"transcriptTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"ringTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"maxConcurrentCalls": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"serve": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"bind": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tailscale": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off",
|
||||
"serve",
|
||||
"funnel"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tunnel": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"ngrok",
|
||||
"tailscale-serve",
|
||||
"tailscale-funnel"
|
||||
]
|
||||
},
|
||||
"ngrokAuthToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"ngrokDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
"allowNgrokFreeTier": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sttProvider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"openai-realtime"
|
||||
]
|
||||
},
|
||||
"openaiApiKey": {
|
||||
"type": "string"
|
||||
},
|
||||
"sttModel": {
|
||||
"type": "string"
|
||||
},
|
||||
"silenceDurationMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"vadThreshold": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"streamPath": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"publicUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"skipSignatureVerification": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"openai"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"openai"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"voice": {
|
||||
"type": "string"
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"store": {
|
||||
"type": "string"
|
||||
},
|
||||
"responseModel": {
|
||||
"type": "string"
|
||||
},
|
||||
"responseSystemPrompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"responseTimeoutMs": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
extensions/whatsapp/clawdbot.plugin.json
Normal file
11
extensions/whatsapp/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "whatsapp",
|
||||
"channels": [
|
||||
"whatsapp"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/zalo/clawdbot.plugin.json
Normal file
11
extensions/zalo/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "zalo",
|
||||
"channels": [
|
||||
"zalo"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
11
extensions/zalouser/clawdbot.plugin.json
Normal file
11
extensions/zalouser/clawdbot.plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "zalouser",
|
||||
"channels": [
|
||||
"zalouser"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"] } };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))}`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
152
src/config/config.plugin-validation.test.ts
Normal file
152
src/config/config.plugin-validation.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { withTempHome } from "./test-helpers.js";
|
||||
|
||||
async function writePluginFixture(params: {
|
||||
dir: string;
|
||||
id: string;
|
||||
schema: Record<string, unknown>;
|
||||
}) {
|
||||
await fs.mkdir(params.dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(params.dir, "index.js"),
|
||||
`export default { id: "${params.id}", register() {} };`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(params.dir, "clawdbot.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
configSchema: params.schema,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("config plugin validation", () => {
|
||||
it("rejects missing plugin load paths", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const missingPath = path.join(home, "missing-plugin");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
plugins: { enabled: false, load: { paths: [missingPath] } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const hasIssue = res.issues.some(
|
||||
(issue) =>
|
||||
issue.path === "plugins.load.paths" && issue.message.includes("plugin path not found"),
|
||||
);
|
||||
expect(hasIssue).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing plugin ids in entries", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues).toContainEqual({
|
||||
path: "plugins.entries.missing-plugin",
|
||||
message: "plugin not found: missing-plugin",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing plugin ids in allow/deny/slots", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
plugins: {
|
||||
enabled: false,
|
||||
allow: ["missing-allow"],
|
||||
deny: ["missing-deny"],
|
||||
slots: { memory: "missing-slot" },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: "plugins.allow", message: "plugin not found: missing-allow" },
|
||||
{ path: "plugins.deny", message: "plugin not found: missing-deny" },
|
||||
{ path: "plugins.slots.memory", message: "plugin not found: missing-slot" },
|
||||
]),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces plugin config diagnostics", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
const pluginDir = path.join(home, "bad-plugin");
|
||||
await writePluginFixture({
|
||||
dir: pluginDir,
|
||||
id: "bad-plugin",
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
value: { type: "boolean" },
|
||||
},
|
||||
required: ["value"],
|
||||
},
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
plugins: {
|
||||
enabled: true,
|
||||
load: { paths: [pluginDir] },
|
||||
entries: { "bad-plugin": { config: { value: "nope" } } },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const hasIssue = res.issues.some(
|
||||
(issue) =>
|
||||
issue.path === "plugins.entries.bad-plugin.config" &&
|
||||
issue.message.includes("invalid config"),
|
||||
);
|
||||
expect(hasIssue).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts known plugin ids", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
plugins: { enabled: false, entries: { discord: { enabled: true } } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -30,8 +30,7 @@ import { normalizeConfigPaths } from "./normalize-paths.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "./paths.js";
|
||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||
import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
||||
import { validateConfigObject } from "./validation.js";
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
import { validateConfigObjectWithPlugins } from "./validation.js";
|
||||
import { compareClawdbotVersions } from "./version.js";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
@@ -233,21 +232,34 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const resolvedConfig = substituted;
|
||||
warnOnConfigMiskeys(resolvedConfig, deps.logger);
|
||||
if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {};
|
||||
const validated = ClawdbotSchema.safeParse(resolvedConfig);
|
||||
if (!validated.success) {
|
||||
deps.logger.error("Invalid config:");
|
||||
for (const iss of validated.error.issues) {
|
||||
deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`);
|
||||
}
|
||||
return {};
|
||||
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as ClawdbotConfig, {
|
||||
env: deps.env,
|
||||
homedir: deps.homedir,
|
||||
});
|
||||
if (preValidationDuplicates.length > 0) {
|
||||
throw new DuplicateAgentDirError(preValidationDuplicates);
|
||||
}
|
||||
warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger);
|
||||
const validated = validateConfigObjectWithPlugins(resolvedConfig);
|
||||
if (!validated.ok) {
|
||||
const details = validated.issues
|
||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
||||
.join("\n");
|
||||
deps.logger.error(`Invalid config:\\n${details}`);
|
||||
throw new Error("Invalid config");
|
||||
}
|
||||
if (validated.warnings.length > 0) {
|
||||
const details = validated.warnings
|
||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
||||
.join("\n");
|
||||
deps.logger.warn(`Config warnings:\\n${details}`);
|
||||
}
|
||||
warnIfConfigFromFuture(validated.config, deps.logger);
|
||||
const cfg = applyModelDefaults(
|
||||
applyCompactionDefaults(
|
||||
applyContextPruningDefaults(
|
||||
applyAgentDefaults(
|
||||
applySessionDefaults(
|
||||
applyLoggingDefaults(applyMessageDefaults(validated.data as ClawdbotConfig)),
|
||||
applyLoggingDefaults(applyMessageDefaults(validated.config)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -310,6 +322,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config,
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: [],
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
@@ -328,6 +341,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: {},
|
||||
hash,
|
||||
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -353,6 +367,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: coerceConfig(parsedRes.parsed),
|
||||
hash,
|
||||
issues: [{ path: "", message }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -375,6 +390,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: coerceConfig(resolved),
|
||||
hash,
|
||||
issues: [{ path: "", message }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -382,7 +398,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
const resolvedConfigRaw = substituted;
|
||||
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
|
||||
|
||||
const validated = validateConfigObject(resolvedConfigRaw);
|
||||
const validated = validateConfigObjectWithPlugins(resolvedConfigRaw);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
path: configPath,
|
||||
@@ -393,6 +409,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: coerceConfig(resolvedConfigRaw),
|
||||
hash,
|
||||
issues: validated.issues,
|
||||
warnings: validated.warnings,
|
||||
legacyIssues,
|
||||
};
|
||||
}
|
||||
@@ -415,6 +432,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
),
|
||||
hash,
|
||||
issues: [],
|
||||
warnings: validated.warnings,
|
||||
legacyIssues,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -427,6 +445,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
config: {},
|
||||
hash: hashConfigRaw(null),
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
};
|
||||
}
|
||||
@@ -434,6 +453,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
|
||||
async function writeConfigFile(cfg: ClawdbotConfig) {
|
||||
clearConfigCache();
|
||||
const validated = validateConfigObjectWithPlugins(cfg);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
const pathLabel = issue?.path ? issue.path : "<root>";
|
||||
throw new Error(`Config validation failed: ${pathLabel}: ${issue?.message ?? "invalid"}`);
|
||||
}
|
||||
if (validated.warnings.length > 0) {
|
||||
const details = validated.warnings
|
||||
.map((warning) => `- ${warning.path}: ${warning.message}`)
|
||||
.join("\n");
|
||||
deps.logger.warn(`Config warnings:\n${details}`);
|
||||
}
|
||||
const dir = path.dirname(configPath);
|
||||
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
|
||||
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -105,5 +105,6 @@ export type ConfigFileSnapshot = {
|
||||
config: ClawdbotConfig;
|
||||
hash?: string;
|
||||
issues: ConfigValidationIssue[];
|
||||
warnings: ConfigValidationIssue[];
|
||||
legacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import {
|
||||
normalizePluginsConfig,
|
||||
resolveEnableState,
|
||||
resolveMemorySlotDecision,
|
||||
} from "../plugins/config-state.js";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
||||
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
|
||||
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
@@ -46,3 +55,183 @@ export function validateConfigObject(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function validateConfigObjectWithPlugins(raw: unknown):
|
||||
| {
|
||||
ok: true;
|
||||
config: ClawdbotConfig;
|
||||
warnings: ConfigValidationIssue[];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
issues: ConfigValidationIssue[];
|
||||
warnings: ConfigValidationIssue[];
|
||||
} {
|
||||
const base = validateConfigObject(raw);
|
||||
if (!base.ok) {
|
||||
return { ok: false, issues: base.issues, warnings: [] };
|
||||
}
|
||||
|
||||
const config = base.config;
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const warnings: ConfigValidationIssue[] = [];
|
||||
const pluginsConfig = config.plugins;
|
||||
const normalizedPlugins = normalizePluginsConfig(pluginsConfig);
|
||||
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const registry = loadPluginManifestRegistry({
|
||||
config,
|
||||
workspaceDir: workspaceDir ?? undefined,
|
||||
});
|
||||
|
||||
const knownIds = new Set(registry.plugins.map((record) => record.id));
|
||||
|
||||
for (const diag of registry.diagnostics) {
|
||||
let path = diag.pluginId ? `plugins.entries.${diag.pluginId}` : "plugins";
|
||||
if (!diag.pluginId && diag.message.includes("plugin path not found")) {
|
||||
path = "plugins.load.paths";
|
||||
}
|
||||
const pluginLabel = diag.pluginId ? `plugin ${diag.pluginId}` : "plugin";
|
||||
const message = `${pluginLabel}: ${diag.message}`;
|
||||
if (diag.level === "error") {
|
||||
issues.push({ path, message });
|
||||
} else {
|
||||
warnings.push({ path, message });
|
||||
}
|
||||
}
|
||||
|
||||
const entries = pluginsConfig?.entries;
|
||||
if (entries && isRecord(entries)) {
|
||||
for (const pluginId of Object.keys(entries)) {
|
||||
if (!knownIds.has(pluginId)) {
|
||||
issues.push({
|
||||
path: `plugins.entries.${pluginId}`,
|
||||
message: `plugin not found: ${pluginId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allow = pluginsConfig?.allow ?? [];
|
||||
for (const pluginId of allow) {
|
||||
if (typeof pluginId !== "string" || !pluginId.trim()) continue;
|
||||
if (!knownIds.has(pluginId)) {
|
||||
issues.push({
|
||||
path: "plugins.allow",
|
||||
message: `plugin not found: ${pluginId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deny = pluginsConfig?.deny ?? [];
|
||||
for (const pluginId of deny) {
|
||||
if (typeof pluginId !== "string" || !pluginId.trim()) continue;
|
||||
if (!knownIds.has(pluginId)) {
|
||||
issues.push({
|
||||
path: "plugins.deny",
|
||||
message: `plugin not found: ${pluginId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const memorySlot = normalizedPlugins.slots.memory;
|
||||
if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) {
|
||||
issues.push({
|
||||
path: "plugins.slots.memory",
|
||||
message: `plugin not found: ${memorySlot}`,
|
||||
});
|
||||
}
|
||||
|
||||
const allowedChannels = new Set<string>(["defaults", ...CHANNEL_IDS]);
|
||||
for (const record of registry.plugins) {
|
||||
for (const channelId of record.channels) {
|
||||
allowedChannels.add(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.channels && isRecord(config.channels)) {
|
||||
for (const key of Object.keys(config.channels)) {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!allowedChannels.has(trimmed)) {
|
||||
issues.push({
|
||||
path: `channels.${trimmed}`,
|
||||
message: `unknown channel id: ${trimmed}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let selectedMemoryPluginId: string | null = null;
|
||||
const seenPlugins = new Set<string>();
|
||||
for (const record of registry.plugins) {
|
||||
const pluginId = record.id;
|
||||
if (seenPlugins.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
seenPlugins.add(pluginId);
|
||||
const entry = normalizedPlugins.entries[pluginId];
|
||||
const entryHasConfig = Boolean(entry?.config);
|
||||
|
||||
const enableState = resolveEnableState(pluginId, record.origin, normalizedPlugins);
|
||||
let enabled = enableState.enabled;
|
||||
let reason = enableState.reason;
|
||||
|
||||
if (enabled) {
|
||||
const memoryDecision = resolveMemorySlotDecision({
|
||||
id: pluginId,
|
||||
kind: record.kind,
|
||||
slot: memorySlot,
|
||||
selectedId: selectedMemoryPluginId,
|
||||
});
|
||||
if (!memoryDecision.enabled) {
|
||||
enabled = false;
|
||||
reason = memoryDecision.reason;
|
||||
}
|
||||
if (memoryDecision.selected && record.kind === "memory") {
|
||||
selectedMemoryPluginId = pluginId;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldValidate = enabled || entryHasConfig;
|
||||
if (shouldValidate) {
|
||||
if (record.configSchema) {
|
||||
const res = validateJsonSchemaValue({
|
||||
schema: record.configSchema,
|
||||
cacheKey: record.schemaCacheKey ?? record.manifestPath ?? pluginId,
|
||||
value: entry?.config ?? {},
|
||||
});
|
||||
if (!res.ok) {
|
||||
for (const error of res.errors) {
|
||||
issues.push({
|
||||
path: `plugins.entries.${pluginId}.config`,
|
||||
message: `invalid config: ${error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
issues.push({
|
||||
path: `plugins.entries.${pluginId}`,
|
||||
message: `plugin schema missing for ${pluginId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!enabled && entryHasConfig) {
|
||||
warnings.push({
|
||||
path: `plugins.entries.${pluginId}`,
|
||||
message: `plugin disabled (${reason ?? "disabled"}) but config is present`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
return { ok: false, issues, warnings };
|
||||
}
|
||||
|
||||
return { ok: true, config, warnings };
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@ export const ChannelsSchema = z
|
||||
imessage: IMessageConfigSchema.optional(),
|
||||
msteams: MSTeamsConfigSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.passthrough()
|
||||
.optional();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
loadConfig,
|
||||
readConfigFileSnapshot,
|
||||
resolveGatewayPort,
|
||||
validateConfigObject,
|
||||
validateConfigObjectWithPlugins,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
@@ -244,7 +244,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) {
|
||||
},
|
||||
};
|
||||
|
||||
const validated = validateConfigObject(nextConfig);
|
||||
const validated = validateConfigObjectWithPlugins(nextConfig);
|
||||
if (!validated.ok) {
|
||||
throw new Error(`Config validation failed: ${validated.issues[0]?.message ?? "invalid"}`);
|
||||
}
|
||||
|
||||
126
src/plugins/config-state.ts
Normal file
126
src/plugins/config-state.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import type { PluginRecord } from "./registry.js";
|
||||
|
||||
export type NormalizedPluginsConfig = {
|
||||
enabled: boolean;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
loadPaths: string[];
|
||||
slots: {
|
||||
memory?: string | null;
|
||||
};
|
||||
entries: Record<string, { enabled?: boolean; config?: unknown }>;
|
||||
};
|
||||
|
||||
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
|
||||
|
||||
const normalizeList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
};
|
||||
|
||||
const normalizeSlotValue = (value: unknown): string | null | undefined => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.toLowerCase() === "none") return null;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||
return {};
|
||||
}
|
||||
const normalized: NormalizedPluginsConfig["entries"] = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!key.trim()) continue;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
normalized[key] = {};
|
||||
continue;
|
||||
}
|
||||
const entry = value as Record<string, unknown>;
|
||||
normalized[key] = {
|
||||
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
|
||||
config: "config" in entry ? entry.config : undefined,
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const normalizePluginsConfig = (
|
||||
config?: ClawdbotConfig["plugins"],
|
||||
): NormalizedPluginsConfig => {
|
||||
const memorySlot = normalizeSlotValue(config?.slots?.memory);
|
||||
return {
|
||||
enabled: config?.enabled !== false,
|
||||
allow: normalizeList(config?.allow),
|
||||
deny: normalizeList(config?.deny),
|
||||
loadPaths: normalizeList(config?.load?.paths),
|
||||
slots: {
|
||||
memory: memorySlot ?? defaultSlotIdForKey("memory"),
|
||||
},
|
||||
entries: normalizePluginEntries(config?.entries),
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveEnableState(
|
||||
id: string,
|
||||
origin: PluginRecord["origin"],
|
||||
config: NormalizedPluginsConfig,
|
||||
): { enabled: boolean; reason?: string } {
|
||||
if (!config.enabled) {
|
||||
return { enabled: false, reason: "plugins disabled" };
|
||||
}
|
||||
if (config.deny.includes(id)) {
|
||||
return { enabled: false, reason: "blocked by denylist" };
|
||||
}
|
||||
if (config.allow.length > 0 && !config.allow.includes(id)) {
|
||||
return { enabled: false, reason: "not in allowlist" };
|
||||
}
|
||||
if (config.slots.memory === id) {
|
||||
return { enabled: true };
|
||||
}
|
||||
const entry = config.entries[id];
|
||||
if (entry?.enabled === true) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (entry?.enabled === false) {
|
||||
return { enabled: false, reason: "disabled in config" };
|
||||
}
|
||||
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (origin === "bundled") {
|
||||
return { enabled: false, reason: "bundled (disabled by default)" };
|
||||
}
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
export function resolveMemorySlotDecision(params: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
slot: string | null | undefined;
|
||||
selectedId: string | null;
|
||||
}): { enabled: boolean; reason?: string; selected?: boolean } {
|
||||
if (params.kind !== "memory") return { enabled: true };
|
||||
if (params.slot === null) {
|
||||
return { enabled: false, reason: "memory slot disabled" };
|
||||
}
|
||||
if (typeof params.slot === "string") {
|
||||
if (params.slot === params.id) {
|
||||
return { enabled: true, selected: true };
|
||||
}
|
||||
return {
|
||||
enabled: false,
|
||||
reason: `memory slot set to "${params.slot}"`,
|
||||
};
|
||||
}
|
||||
if (params.selectedId && params.selectedId !== params.id) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: `memory slot already filled by "${params.selectedId}"`,
|
||||
};
|
||||
}
|
||||
return { enabled: true, selected: true };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
import { resolveConfigDir, resolveUserPath } from "../utils.js";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
|
||||
|
||||
@@ -10,6 +10,7 @@ const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
|
||||
export type PluginCandidate = {
|
||||
idHint: string;
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
packageName?: string;
|
||||
@@ -78,6 +79,7 @@ function addCandidate(params: {
|
||||
seen: Set<string>;
|
||||
idHint: string;
|
||||
source: string;
|
||||
rootDir: string;
|
||||
origin: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
manifest?: PackageManifest | null;
|
||||
@@ -89,6 +91,7 @@ function addCandidate(params: {
|
||||
params.candidates.push({
|
||||
idHint: params.idHint,
|
||||
source: resolved,
|
||||
rootDir: path.resolve(params.rootDir),
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
packageName: manifest?.name?.trim() || undefined,
|
||||
@@ -127,6 +130,7 @@ function discoverInDirectory(params: {
|
||||
seen: params.seen,
|
||||
idHint: path.basename(entry.name, path.extname(entry.name)),
|
||||
source: fullPath,
|
||||
rootDir: path.dirname(fullPath),
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
@@ -148,6 +152,7 @@ function discoverInDirectory(params: {
|
||||
hasMultipleExtensions: extensions.length > 1,
|
||||
}),
|
||||
source: resolved,
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
@@ -166,6 +171,7 @@ function discoverInDirectory(params: {
|
||||
seen: params.seen,
|
||||
idHint: entry.name,
|
||||
source: indexFile,
|
||||
rootDir: fullPath,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
@@ -184,7 +190,7 @@ function discoverFromPath(params: {
|
||||
const resolved = resolveUserPath(params.rawPath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
level: "error",
|
||||
message: `plugin path not found: ${resolved}`,
|
||||
source: resolved,
|
||||
});
|
||||
@@ -195,7 +201,7 @@ function discoverFromPath(params: {
|
||||
if (stat.isFile()) {
|
||||
if (!isExtensionFile(resolved)) {
|
||||
params.diagnostics.push({
|
||||
level: "warn",
|
||||
level: "error",
|
||||
message: `plugin path is not a supported file: ${resolved}`,
|
||||
source: resolved,
|
||||
});
|
||||
@@ -206,6 +212,7 @@ function discoverFromPath(params: {
|
||||
seen: params.seen,
|
||||
idHint: path.basename(resolved, path.extname(resolved)),
|
||||
source: resolved,
|
||||
rootDir: path.dirname(resolved),
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
@@ -228,6 +235,7 @@ function discoverFromPath(params: {
|
||||
hasMultipleExtensions: extensions.length > 1,
|
||||
}),
|
||||
source,
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest,
|
||||
@@ -247,6 +255,7 @@ function discoverFromPath(params: {
|
||||
seen: params.seen,
|
||||
idHint: path.basename(resolved),
|
||||
source: indexFile,
|
||||
rootDir: resolved,
|
||||
origin: params.origin,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
@@ -301,7 +310,7 @@ export function discoverClawdbotPlugins(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const globalDir = path.join(CONFIG_DIR, "extensions");
|
||||
const globalDir = path.join(resolveConfigDir(), "extensions");
|
||||
discoverInDirectory({
|
||||
dir: globalDir,
|
||||
origin: "global",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -8,16 +8,21 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { discoverClawdbotPlugins } from "./discovery.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import {
|
||||
normalizePluginsConfig,
|
||||
resolveEnableState,
|
||||
resolveMemorySlotDecision,
|
||||
type NormalizedPluginsConfig,
|
||||
} from "./config-state.js";
|
||||
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
||||
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
||||
import { createPluginRuntime } from "./runtime/index.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
import { defaultSlotIdForKey } from "./slots.js";
|
||||
import { validateJsonSchemaValue } from "./schema-validator.js";
|
||||
import type {
|
||||
ClawdbotPluginConfigSchema,
|
||||
ClawdbotPluginDefinition,
|
||||
ClawdbotPluginModule,
|
||||
PluginConfigUiHint,
|
||||
PluginDiagnostic,
|
||||
PluginLogger,
|
||||
} from "./types.js";
|
||||
@@ -33,73 +38,10 @@ export type PluginLoadOptions = {
|
||||
mode?: "full" | "validate";
|
||||
};
|
||||
|
||||
type NormalizedPluginsConfig = {
|
||||
enabled: boolean;
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
loadPaths: string[];
|
||||
slots: {
|
||||
memory?: string | null;
|
||||
};
|
||||
entries: Record<string, { enabled?: boolean; config?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
const registryCache = new Map<string, PluginRegistry>();
|
||||
|
||||
const defaultLogger = () => createSubsystemLogger("plugins");
|
||||
|
||||
const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
|
||||
|
||||
const normalizeList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
};
|
||||
|
||||
const normalizeSlotValue = (value: unknown): string | null | undefined => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.toLowerCase() === "none") return null;
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entries"] => {
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||
return {};
|
||||
}
|
||||
const normalized: NormalizedPluginsConfig["entries"] = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!key.trim()) continue;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
normalized[key] = {};
|
||||
continue;
|
||||
}
|
||||
const entry = value as Record<string, unknown>;
|
||||
normalized[key] = {
|
||||
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
|
||||
config:
|
||||
entry.config && typeof entry.config === "object" && !Array.isArray(entry.config)
|
||||
? (entry.config as Record<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const normalizePluginsConfig = (config?: ClawdbotConfig["plugins"]): NormalizedPluginsConfig => {
|
||||
const memorySlot = normalizeSlotValue(config?.slots?.memory);
|
||||
return {
|
||||
enabled: config?.enabled !== false,
|
||||
allow: normalizeList(config?.allow),
|
||||
deny: normalizeList(config?.deny),
|
||||
loadPaths: normalizeList(config?.load?.paths),
|
||||
slots: {
|
||||
memory: memorySlot ?? defaultSlotIdForKey("memory"),
|
||||
},
|
||||
entries: normalizePluginEntries(config?.entries),
|
||||
};
|
||||
};
|
||||
|
||||
const resolvePluginSdkAlias = (): string | null => {
|
||||
try {
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
@@ -133,105 +75,25 @@ function buildCacheKey(params: {
|
||||
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
|
||||
}
|
||||
|
||||
function resolveMemorySlotDecision(params: {
|
||||
id: string;
|
||||
kind?: string;
|
||||
slot: string | null | undefined;
|
||||
selectedId: string | null;
|
||||
}): { enabled: boolean; reason?: string; selected?: boolean } {
|
||||
if (params.kind !== "memory") return { enabled: true };
|
||||
if (params.slot === null) {
|
||||
return { enabled: false, reason: "memory slot disabled" };
|
||||
}
|
||||
if (typeof params.slot === "string") {
|
||||
if (params.slot === params.id) {
|
||||
return { enabled: true, selected: true };
|
||||
}
|
||||
return {
|
||||
enabled: false,
|
||||
reason: `memory slot set to "${params.slot}"`,
|
||||
};
|
||||
}
|
||||
if (params.selectedId && params.selectedId !== params.id) {
|
||||
return {
|
||||
enabled: false,
|
||||
reason: `memory slot already filled by "${params.selectedId}"`,
|
||||
};
|
||||
}
|
||||
return { enabled: true, selected: true };
|
||||
}
|
||||
|
||||
function resolveEnableState(
|
||||
id: string,
|
||||
origin: PluginRecord["origin"],
|
||||
config: NormalizedPluginsConfig,
|
||||
): { enabled: boolean; reason?: string } {
|
||||
if (!config.enabled) {
|
||||
return { enabled: false, reason: "plugins disabled" };
|
||||
}
|
||||
if (config.deny.includes(id)) {
|
||||
return { enabled: false, reason: "blocked by denylist" };
|
||||
}
|
||||
if (config.allow.length > 0 && !config.allow.includes(id)) {
|
||||
return { enabled: false, reason: "not in allowlist" };
|
||||
}
|
||||
if (config.slots.memory === id) {
|
||||
return { enabled: true };
|
||||
}
|
||||
const entry = config.entries[id];
|
||||
if (entry?.enabled === true) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (entry?.enabled === false) {
|
||||
return { enabled: false, reason: "disabled in config" };
|
||||
}
|
||||
if (origin === "bundled" && BUNDLED_ENABLED_BY_DEFAULT.has(id)) {
|
||||
return { enabled: true };
|
||||
}
|
||||
if (origin === "bundled") {
|
||||
return { enabled: false, reason: "bundled (disabled by default)" };
|
||||
}
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
function validatePluginConfig(params: {
|
||||
schema?: ClawdbotPluginConfigSchema;
|
||||
value?: Record<string, unknown>;
|
||||
schema?: Record<string, unknown>;
|
||||
cacheKey?: string;
|
||||
value?: unknown;
|
||||
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
|
||||
const schema = params.schema;
|
||||
if (!schema) return { ok: true, value: params.value };
|
||||
|
||||
if (typeof schema.validate === "function") {
|
||||
const result = schema.validate(params.value);
|
||||
if (result.ok) {
|
||||
return { ok: true, value: result.value as Record<string, unknown> };
|
||||
}
|
||||
return { ok: false, errors: result.errors };
|
||||
if (!schema) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
||||
}
|
||||
|
||||
if (typeof schema.safeParse === "function") {
|
||||
const result = schema.safeParse(params.value);
|
||||
if (result.success) {
|
||||
return { ok: true, value: result.data as Record<string, unknown> };
|
||||
}
|
||||
const issues = result.error?.issues ?? [];
|
||||
const errors = issues.map((issue) => {
|
||||
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
return { ok: false, errors };
|
||||
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
|
||||
const result = validateJsonSchemaValue({
|
||||
schema,
|
||||
cacheKey,
|
||||
value: params.value ?? {},
|
||||
});
|
||||
if (result.ok) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
||||
}
|
||||
|
||||
if (typeof schema.parse === "function") {
|
||||
try {
|
||||
const parsed = schema.parse(params.value);
|
||||
return { ok: true, value: parsed as Record<string, unknown> };
|
||||
} catch (err) {
|
||||
return { ok: false, errors: [String(err)] };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, value: params.value };
|
||||
return { ok: false, errors: result.errors };
|
||||
}
|
||||
|
||||
function resolvePluginModuleExport(moduleExport: unknown): {
|
||||
@@ -326,7 +188,14 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
workspaceDir: options.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
});
|
||||
pushDiagnostics(registry.diagnostics, discovery.diagnostics);
|
||||
const manifestRegistry = loadPluginManifestRegistry({
|
||||
config: cfg,
|
||||
workspaceDir: options.workspaceDir,
|
||||
cache: options.cache,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
});
|
||||
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
||||
|
||||
const pluginSdkAlias = resolvePluginSdkAlias();
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
@@ -335,10 +204,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
|
||||
});
|
||||
|
||||
const bundledIds = new Set(
|
||||
discovery.candidates
|
||||
.filter((candidate) => candidate.origin === "bundled")
|
||||
.map((candidate) => candidate.idHint),
|
||||
const manifestByRoot = new Map(
|
||||
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
||||
);
|
||||
|
||||
const seenIds = new Map<string, PluginRecord["origin"]>();
|
||||
@@ -347,18 +214,23 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
let memorySlotMatched = false;
|
||||
|
||||
for (const candidate of discovery.candidates) {
|
||||
const existingOrigin = seenIds.get(candidate.idHint);
|
||||
const manifestRecord = manifestByRoot.get(candidate.rootDir);
|
||||
if (!manifestRecord) {
|
||||
continue;
|
||||
}
|
||||
const pluginId = manifestRecord.id;
|
||||
const existingOrigin = seenIds.get(pluginId);
|
||||
if (existingOrigin) {
|
||||
const record = createPluginRecord({
|
||||
id: candidate.idHint,
|
||||
name: candidate.packageName ?? candidate.idHint,
|
||||
description: candidate.packageDescription,
|
||||
version: candidate.packageVersion,
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
version: manifestRecord.version,
|
||||
source: candidate.source,
|
||||
origin: candidate.origin,
|
||||
workspaceDir: candidate.workspaceDir,
|
||||
enabled: false,
|
||||
configSchema: false,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
});
|
||||
record.status = "disabled";
|
||||
record.error = `overridden by ${existingOrigin} plugin`;
|
||||
@@ -366,25 +238,42 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
continue;
|
||||
}
|
||||
|
||||
const enableState = resolveEnableState(candidate.idHint, candidate.origin, normalized);
|
||||
const entry = normalized.entries[candidate.idHint];
|
||||
const enableState = resolveEnableState(pluginId, candidate.origin, normalized);
|
||||
const entry = normalized.entries[pluginId];
|
||||
const record = createPluginRecord({
|
||||
id: candidate.idHint,
|
||||
name: candidate.packageName ?? candidate.idHint,
|
||||
description: candidate.packageDescription,
|
||||
version: candidate.packageVersion,
|
||||
id: pluginId,
|
||||
name: manifestRecord.name ?? pluginId,
|
||||
description: manifestRecord.description,
|
||||
version: manifestRecord.version,
|
||||
source: candidate.source,
|
||||
origin: candidate.origin,
|
||||
workspaceDir: candidate.workspaceDir,
|
||||
enabled: enableState.enabled,
|
||||
configSchema: false,
|
||||
configSchema: Boolean(manifestRecord.configSchema),
|
||||
});
|
||||
record.kind = manifestRecord.kind;
|
||||
record.configUiHints = manifestRecord.configUiHints;
|
||||
record.configJsonSchema = manifestRecord.configSchema;
|
||||
|
||||
if (!enableState.enabled) {
|
||||
record.status = "disabled";
|
||||
record.error = enableState.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!manifestRecord.configSchema) {
|
||||
record.status = "error";
|
||||
record.error = "missing config schema";
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: record.error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -396,7 +285,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "error";
|
||||
record.error = String(err);
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -422,61 +311,17 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.name = definition?.name ?? record.name;
|
||||
record.description = definition?.description ?? record.description;
|
||||
record.version = definition?.version ?? record.version;
|
||||
record.kind = definition?.kind;
|
||||
record.configSchema = Boolean(definition?.configSchema);
|
||||
record.configUiHints =
|
||||
definition?.configSchema &&
|
||||
typeof definition.configSchema === "object" &&
|
||||
(definition.configSchema as { uiHints?: unknown }).uiHints &&
|
||||
typeof (definition.configSchema as { uiHints?: unknown }).uiHints === "object" &&
|
||||
!Array.isArray((definition.configSchema as { uiHints?: unknown }).uiHints)
|
||||
? ((definition.configSchema as { uiHints?: unknown }).uiHints as Record<
|
||||
string,
|
||||
PluginConfigUiHint
|
||||
>)
|
||||
: undefined;
|
||||
record.configJsonSchema =
|
||||
definition?.configSchema &&
|
||||
typeof definition.configSchema === "object" &&
|
||||
(definition.configSchema as { jsonSchema?: unknown }).jsonSchema &&
|
||||
typeof (definition.configSchema as { jsonSchema?: unknown }).jsonSchema === "object" &&
|
||||
!Array.isArray((definition.configSchema as { jsonSchema?: unknown }).jsonSchema)
|
||||
? ((definition.configSchema as { jsonSchema?: unknown }).jsonSchema as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: undefined;
|
||||
|
||||
if (!definition?.configSchema) {
|
||||
const hasBundledFallback =
|
||||
candidate.origin !== "bundled" && bundledIds.has(candidate.idHint);
|
||||
if (hasBundledFallback) {
|
||||
record.enabled = false;
|
||||
record.status = "disabled";
|
||||
record.error = "missing config schema (using bundled plugin)";
|
||||
registry.plugins.push(record);
|
||||
registry.diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: record.error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.error(`[plugins] ${record.id} missing config schema`);
|
||||
record.status = "error";
|
||||
record.error = "missing config schema";
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
const manifestKind = record.kind as string | undefined;
|
||||
const exportKind = definition?.kind as string | undefined;
|
||||
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
level: "warn",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: record.error,
|
||||
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
record.kind = definition?.kind ?? record.kind;
|
||||
|
||||
if (record.kind === "memory" && memorySlot === record.id) {
|
||||
memorySlotMatched = true;
|
||||
@@ -494,7 +339,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "disabled";
|
||||
record.error = memoryDecision.reason;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -503,7 +348,8 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
|
||||
const validatedConfig = validatePluginConfig({
|
||||
schema: definition?.configSchema,
|
||||
schema: manifestRecord.configSchema,
|
||||
cacheKey: manifestRecord.schemaCacheKey,
|
||||
value: entry?.config,
|
||||
});
|
||||
|
||||
@@ -512,7 +358,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "error";
|
||||
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -524,7 +370,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
|
||||
if (validateOnly) {
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -533,7 +379,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "error";
|
||||
record.error = "plugin export missing register/activate";
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
@@ -559,7 +405,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
});
|
||||
}
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`,
|
||||
@@ -567,7 +413,7 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
record.status = "error";
|
||||
record.error = String(err);
|
||||
registry.plugins.push(record);
|
||||
seenIds.set(candidate.idHint, candidate.origin);
|
||||
seenIds.set(pluginId, candidate.origin);
|
||||
registry.diagnostics.push({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
|
||||
189
src/plugins/manifest-registry.ts
Normal file
189
src/plugins/manifest-registry.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
|
||||
import { discoverClawdbotPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
|
||||
import type { PluginConfigUiHint, PluginDiagnostic, PluginKind, PluginOrigin } from "./types.js";
|
||||
|
||||
export type PluginManifestRecord = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
kind?: PluginKind;
|
||||
channels: string[];
|
||||
providers: string[];
|
||||
origin: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
rootDir: string;
|
||||
source: string;
|
||||
manifestPath: string;
|
||||
schemaCacheKey?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
};
|
||||
|
||||
export type PluginManifestRegistry = {
|
||||
plugins: PluginManifestRecord[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
const registryCache = new Map<string, { expiresAt: number; registry: PluginManifestRegistry }>();
|
||||
|
||||
const DEFAULT_MANIFEST_CACHE_MS = 200;
|
||||
|
||||
function resolveManifestCacheMs(env: NodeJS.ProcessEnv): number {
|
||||
const raw = env.CLAWDBOT_PLUGIN_MANIFEST_CACHE_MS?.trim();
|
||||
if (raw === "" || raw === "0") return 0;
|
||||
if (!raw) return DEFAULT_MANIFEST_CACHE_MS;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_MANIFEST_CACHE_MS;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function shouldUseManifestCache(env: NodeJS.ProcessEnv): boolean {
|
||||
const disabled = env.CLAWDBOT_DISABLE_PLUGIN_MANIFEST_CACHE?.trim();
|
||||
if (disabled) return false;
|
||||
return resolveManifestCacheMs(env) > 0;
|
||||
}
|
||||
|
||||
function buildCacheKey(params: {
|
||||
workspaceDir?: string;
|
||||
plugins: NormalizedPluginsConfig;
|
||||
}): string {
|
||||
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
|
||||
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
|
||||
}
|
||||
|
||||
function safeStatMtimeMs(filePath: string): number | null {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeManifestLabel(raw: string | undefined): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function buildRecord(params: {
|
||||
manifest: PluginManifest;
|
||||
candidate: PluginCandidate;
|
||||
manifestPath: string;
|
||||
schemaCacheKey?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
}): PluginManifestRecord {
|
||||
return {
|
||||
id: params.manifest.id,
|
||||
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName,
|
||||
description:
|
||||
normalizeManifestLabel(params.manifest.description) ?? params.candidate.packageDescription,
|
||||
version: normalizeManifestLabel(params.manifest.version) ?? params.candidate.packageVersion,
|
||||
kind: params.manifest.kind,
|
||||
channels: params.manifest.channels ?? [],
|
||||
providers: params.manifest.providers ?? [],
|
||||
origin: params.candidate.origin,
|
||||
workspaceDir: params.candidate.workspaceDir,
|
||||
rootDir: params.candidate.rootDir,
|
||||
source: params.candidate.source,
|
||||
manifestPath: params.manifestPath,
|
||||
schemaCacheKey: params.schemaCacheKey,
|
||||
configSchema: params.configSchema,
|
||||
configUiHints: params.manifest.uiHints,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPluginManifestRegistry(params: {
|
||||
config?: ClawdbotConfig;
|
||||
workspaceDir?: string;
|
||||
cache?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
candidates?: PluginCandidate[];
|
||||
diagnostics?: PluginDiagnostic[];
|
||||
}): PluginManifestRegistry {
|
||||
const config = params.config ?? {};
|
||||
const normalized = normalizePluginsConfig(config.plugins);
|
||||
const cacheKey = buildCacheKey({ workspaceDir: params.workspaceDir, plugins: normalized });
|
||||
const env = params.env ?? process.env;
|
||||
const cacheEnabled = params.cache !== false && shouldUseManifestCache(env);
|
||||
if (cacheEnabled) {
|
||||
const cached = registryCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.registry;
|
||||
}
|
||||
|
||||
const discovery = params.candidates
|
||||
? {
|
||||
candidates: params.candidates,
|
||||
diagnostics: params.diagnostics ?? [],
|
||||
}
|
||||
: discoverClawdbotPlugins({
|
||||
workspaceDir: params.workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
});
|
||||
const diagnostics: PluginDiagnostic[] = [...discovery.diagnostics];
|
||||
const candidates: PluginCandidate[] = discovery.candidates;
|
||||
const records: PluginManifestRecord[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const manifestRes = loadPluginManifest(candidate.rootDir);
|
||||
if (!manifestRes.ok) {
|
||||
diagnostics.push({
|
||||
level: "error",
|
||||
message: manifestRes.error,
|
||||
source: manifestRes.manifestPath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const manifest = manifestRes.manifest;
|
||||
|
||||
if (candidate.idHint && candidate.idHint !== manifest.id) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: manifest.id,
|
||||
source: candidate.source,
|
||||
message: `plugin id mismatch (manifest uses "${manifest.id}", entry hints "${candidate.idHint}")`,
|
||||
});
|
||||
}
|
||||
|
||||
if (seenIds.has(manifest.id)) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
pluginId: manifest.id,
|
||||
source: candidate.source,
|
||||
message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`,
|
||||
});
|
||||
} else {
|
||||
seenIds.add(manifest.id);
|
||||
}
|
||||
|
||||
const configSchema = manifest.configSchema;
|
||||
const manifestMtime = safeStatMtimeMs(manifestRes.manifestPath);
|
||||
const schemaCacheKey = manifestMtime
|
||||
? `${manifestRes.manifestPath}:${manifestMtime}`
|
||||
: manifestRes.manifestPath;
|
||||
|
||||
records.push(
|
||||
buildRecord({
|
||||
manifest,
|
||||
candidate,
|
||||
manifestPath: manifestRes.manifestPath,
|
||||
schemaCacheKey,
|
||||
configSchema,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const registry = { plugins: records, diagnostics };
|
||||
if (cacheEnabled) {
|
||||
const ttl = resolveManifestCacheMs(env);
|
||||
if (ttl > 0) {
|
||||
registryCache.set(cacheKey, { expiresAt: Date.now() + ttl, registry });
|
||||
}
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
91
src/plugins/manifest.ts
Normal file
91
src/plugins/manifest.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { PluginConfigUiHint, PluginKind } from "./types.js";
|
||||
|
||||
export const PLUGIN_MANIFEST_FILENAME = "clawdbot.plugin.json";
|
||||
|
||||
export type PluginManifest = {
|
||||
id: string;
|
||||
configSchema: Record<string, unknown>;
|
||||
kind?: PluginKind;
|
||||
channels?: string[];
|
||||
providers?: string[];
|
||||
name?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
uiHints?: Record<string, PluginConfigUiHint>;
|
||||
};
|
||||
|
||||
export type PluginManifestLoadResult =
|
||||
| { ok: true; manifest: PluginManifest; manifestPath: string }
|
||||
| { ok: false; error: string; manifestPath: string };
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function resolvePluginManifestPath(rootDir: string): string {
|
||||
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
|
||||
}
|
||||
|
||||
export function loadPluginManifest(rootDir: string): PluginManifestLoadResult {
|
||||
const manifestPath = resolvePluginManifestPath(rootDir);
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath };
|
||||
}
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `failed to parse plugin manifest: ${String(err)}`,
|
||||
manifestPath,
|
||||
};
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
return { ok: false, error: "plugin manifest must be an object", manifestPath };
|
||||
}
|
||||
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
||||
if (!id) {
|
||||
return { ok: false, error: "plugin manifest requires id", manifestPath };
|
||||
}
|
||||
const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null;
|
||||
if (!configSchema) {
|
||||
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
|
||||
}
|
||||
|
||||
const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined;
|
||||
const name = typeof raw.name === "string" ? raw.name.trim() : undefined;
|
||||
const description = typeof raw.description === "string" ? raw.description.trim() : undefined;
|
||||
const version = typeof raw.version === "string" ? raw.version.trim() : undefined;
|
||||
const channels = normalizeStringList(raw.channels);
|
||||
const providers = normalizeStringList(raw.providers);
|
||||
|
||||
let uiHints: Record<string, PluginConfigUiHint> | undefined;
|
||||
if (isRecord(raw.uiHints)) {
|
||||
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
manifest: {
|
||||
id,
|
||||
configSchema,
|
||||
kind,
|
||||
channels,
|
||||
providers,
|
||||
name,
|
||||
description,
|
||||
version,
|
||||
uiHints,
|
||||
},
|
||||
manifestPath,
|
||||
};
|
||||
}
|
||||
40
src/plugins/schema-validator.ts
Normal file
40
src/plugins/schema-validator.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import AjvPkg, { type ErrorObject, type ValidateFunction } from "ajv";
|
||||
|
||||
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
removeAdditional: false,
|
||||
});
|
||||
|
||||
type CachedValidator = {
|
||||
validate: ValidateFunction;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const schemaCache = new Map<string, CachedValidator>();
|
||||
|
||||
function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
|
||||
if (!errors || errors.length === 0) return ["invalid config"];
|
||||
return errors.map((error) => {
|
||||
const path = error.instancePath?.replace(/^\//, "").replace(/\//g, ".") || "<root>";
|
||||
const message = error.message ?? "invalid";
|
||||
return `${path}: ${message}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateJsonSchemaValue(params: {
|
||||
schema: Record<string, unknown>;
|
||||
cacheKey: string;
|
||||
value: unknown;
|
||||
}): { ok: true } | { ok: false; errors: string[] } {
|
||||
let cached = schemaCache.get(params.cacheKey);
|
||||
if (!cached || cached.schema !== params.schema) {
|
||||
const validate = ajv.compile(params.schema) as ValidateFunction;
|
||||
cached = { validate, schema: params.schema };
|
||||
schemaCache.set(params.cacheKey, cached);
|
||||
}
|
||||
|
||||
const ok = cached.validate(params.value);
|
||||
if (ok) return { ok: true };
|
||||
return { ok: false, errors: formatAjvErrors(cached.validate.errors) };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user