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