fix: enforce strict config validation

This commit is contained in:
Peter Steinberger
2026-01-19 03:38:51 +00:00
parent a9fc2ca0ef
commit d1e9490f95
53 changed files with 1025 additions and 821 deletions

View File

@@ -4,6 +4,9 @@ Docs: https://docs.clawd.bot
## 2026.1.19-1 ## 2026.1.19-1
### Breaking
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes ### Changes
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.

View File

@@ -17,6 +17,19 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations! > **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
## Strict config validation
Clawdbot only accepts configurations that fully match the schema.
Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start** for safety.
When validation fails:
- The Gateway does not boot.
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
- Run `clawdbot doctor` to see the exact issues.
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
Doctor never writes changes unless you explicitly opt into `--fix`/`--yes`.
## Schema + UI hints ## Schema + UI hints
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors. The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.

View File

@@ -326,6 +326,22 @@ Clawdbot keeps conversation history in memory.
## Common troubleshooting ## Common troubleshooting
### “Gateway wont start — configuration invalid”
Clawdbot now refuses to start when the config contains unknown keys, malformed values, or invalid types.
This is intentional for safety.
Fix it with Doctor:
```bash
clawdbot doctor
clawdbot doctor --fix
```
Notes:
- `clawdbot doctor` reports every invalid entry.
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
### “All models failed” — what should I check first? ### “All models failed” — what should I check first?
- **Credentials** present for the provider(s) being tried (auth profiles + env vars). - **Credentials** present for the provider(s) being tried (auth profiles + env vars).

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js"; import { bluebubblesPlugin } from "./src/channel.js";
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js"; import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
@@ -8,6 +9,7 @@ const plugin = {
id: "bluebubbles", id: "bluebubbles",
name: "BlueBubbles", name: "BlueBubbles",
description: "BlueBubbles channel plugin (macOS app)", description: "BlueBubbles channel plugin (macOS app)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setBlueBubblesRuntime(api.runtime); setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin }); api.registerChannel({ plugin: bluebubblesPlugin });

View File

@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
const DEFAULT_BASE_URL = "http://localhost:3000/v1"; const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a"; const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000; const DEFAULT_CONTEXT_WINDOW = 128_000;
@@ -61,6 +63,7 @@ const copilotProxyPlugin = {
id: "copilot-proxy", id: "copilot-proxy",
name: "Copilot Proxy", name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin", description: "Local Copilot Proxy (VS Code LM) provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: "copilot-proxy", id: "copilot-proxy",

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { discordPlugin } from "./src/channel.js"; import { discordPlugin } from "./src/channel.js";
import { setDiscordRuntime } from "./src/runtime.js"; import { setDiscordRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "discord", id: "discord",
name: "Discord", name: "Discord",
description: "Discord channel plugin", description: "Discord channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setDiscordRuntime(api.runtime); setDiscordRuntime(api.runtime);
api.registerChannel({ plugin: discordPlugin }); api.registerChannel({ plugin: discordPlugin });

View File

@@ -1,6 +1,7 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync // OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString(); const decode = (s: string) => Buffer.from(s, "base64").toString();
@@ -360,6 +361,7 @@ const antigravityPlugin = {
id: "google-antigravity-auth", id: "google-antigravity-auth",
name: "Google Antigravity Auth", name: "Google Antigravity Auth",
description: "OAuth flow for Google Antigravity (Cloud Code Assist)", description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: "google-antigravity", id: "google-antigravity",

View File

@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { loginGeminiCliOAuth } from "./oauth.js"; import { loginGeminiCliOAuth } from "./oauth.js";
const PROVIDER_ID = "google-gemini-cli"; const PROVIDER_ID = "google-gemini-cli";
@@ -14,6 +16,7 @@ const geminiCliPlugin = {
id: "google-gemini-cli-auth", id: "google-gemini-cli-auth",
name: "Google Gemini CLI Auth", name: "Google Gemini CLI Auth",
description: "OAuth flow for Gemini CLI (Google Code Assist)", description: "OAuth flow for Gemini CLI (Google Code Assist)",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: PROVIDER_ID, id: PROVIDER_ID,

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { imessagePlugin } from "./src/channel.js"; import { imessagePlugin } from "./src/channel.js";
import { setIMessageRuntime } from "./src/runtime.js"; import { setIMessageRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "imessage", id: "imessage",
name: "iMessage", name: "iMessage",
description: "iMessage channel plugin", description: "iMessage channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setIMessageRuntime(api.runtime); setIMessageRuntime(api.runtime);
api.registerChannel({ plugin: imessagePlugin }); api.registerChannel({ plugin: imessagePlugin });

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { matrixPlugin } from "./src/channel.js"; import { matrixPlugin } from "./src/channel.js";
import { setMatrixRuntime } from "./src/runtime.js"; import { setMatrixRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "matrix", id: "matrix",
name: "Matrix", name: "Matrix",
description: "Matrix channel plugin (matrix-js-sdk)", description: "Matrix channel plugin (matrix-js-sdk)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setMatrixRuntime(api.runtime); setMatrixRuntime(api.runtime);
api.registerChannel({ plugin: matrixPlugin }); api.registerChannel({ plugin: matrixPlugin });

View File

@@ -1,10 +1,12 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
const memoryCorePlugin = { const memoryCorePlugin = {
id: "memory-core", id: "memory-core",
name: "Memory (Core)", name: "Memory (Core)",
description: "File-backed memory search tools and CLI", description: "File-backed memory search tools and CLI",
kind: "memory", kind: "memory",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
api.registerTool( api.registerTool(
(ctx) => { (ctx) => {

View File

@@ -24,6 +24,16 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
"text-embedding-3-large": 3072, "text-embedding-3-large": 3072,
}; };
function assertAllowedKeys(
value: Record<string, unknown>,
allowed: string[],
label: string,
) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length === 0) return;
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
export function vectorDimsForModel(model: string): number { export function vectorDimsForModel(model: string): number {
const dims = EMBEDDING_DIMENSIONS[model]; const dims = EMBEDDING_DIMENSIONS[model];
if (!dims) { if (!dims) {
@@ -54,11 +64,13 @@ export const memoryConfigSchema = {
throw new Error("memory config required"); throw new Error("memory config required");
} }
const cfg = value as Record<string, unknown>; const cfg = value as Record<string, unknown>;
assertAllowedKeys(cfg, ["embedding", "dbPath", "autoCapture", "autoRecall"], "memory config");
const embedding = cfg.embedding as Record<string, unknown> | undefined; const embedding = cfg.embedding as Record<string, unknown> | undefined;
if (!embedding || typeof embedding.apiKey !== "string") { if (!embedding || typeof embedding.apiKey !== "string") {
throw new Error("embedding.apiKey is required"); throw new Error("embedding.apiKey is required");
} }
assertAllowedKeys(embedding, ["apiKey", "model"], "embedding config");
const model = resolveEmbeddingModel(embedding); const model = resolveEmbeddingModel(embedding);

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { msteamsPlugin } from "./src/channel.js"; import { msteamsPlugin } from "./src/channel.js";
import { setMSTeamsRuntime } from "./src/runtime.js"; import { setMSTeamsRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "msteams", id: "msteams",
name: "Microsoft Teams", name: "Microsoft Teams",
description: "Microsoft Teams channel plugin (Bot Framework)", description: "Microsoft Teams channel plugin (Bot Framework)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setMSTeamsRuntime(api.runtime); setMSTeamsRuntime(api.runtime);
api.registerChannel({ plugin: msteamsPlugin }); api.registerChannel({ plugin: msteamsPlugin });

View File

@@ -1,3 +1,5 @@
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { loginQwenPortalOAuth } from "./oauth.js"; import { loginQwenPortalOAuth } from "./oauth.js";
const PROVIDER_ID = "qwen-portal"; const PROVIDER_ID = "qwen-portal";
@@ -30,6 +32,7 @@ const qwenPortalPlugin = {
id: "qwen-portal-auth", id: "qwen-portal-auth",
name: "Qwen OAuth", name: "Qwen OAuth",
description: "OAuth flow for Qwen (free-tier) models", description: "OAuth flow for Qwen (free-tier) models",
configSchema: emptyPluginConfigSchema(),
register(api) { register(api) {
api.registerProvider({ api.registerProvider({
id: PROVIDER_ID, id: PROVIDER_ID,

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { signalPlugin } from "./src/channel.js"; import { signalPlugin } from "./src/channel.js";
import { setSignalRuntime } from "./src/runtime.js"; import { setSignalRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "signal", id: "signal",
name: "Signal", name: "Signal",
description: "Signal channel plugin", description: "Signal channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setSignalRuntime(api.runtime); setSignalRuntime(api.runtime);
api.registerChannel({ plugin: signalPlugin }); api.registerChannel({ plugin: signalPlugin });

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { slackPlugin } from "./src/channel.js"; import { slackPlugin } from "./src/channel.js";
import { setSlackRuntime } from "./src/runtime.js"; import { setSlackRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "slack", id: "slack",
name: "Slack", name: "Slack",
description: "Slack channel plugin", description: "Slack channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setSlackRuntime(api.runtime); setSlackRuntime(api.runtime);
api.registerChannel({ plugin: slackPlugin }); api.registerChannel({ plugin: slackPlugin });

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { telegramPlugin } from "./src/channel.js"; import { telegramPlugin } from "./src/channel.js";
import { setTelegramRuntime } from "./src/runtime.js"; import { setTelegramRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "telegram", id: "telegram",
name: "Telegram", name: "Telegram",
description: "Telegram channel plugin", description: "Telegram channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setTelegramRuntime(api.runtime); setTelegramRuntime(api.runtime);
api.registerChannel({ plugin: telegramPlugin }); api.registerChannel({ plugin: telegramPlugin });

View File

@@ -35,30 +35,36 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
// Provider-Specific Configuration // Provider-Specific Configuration
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const TelnyxConfigSchema = z.object({ export const TelnyxConfigSchema = z
.object({
/** Telnyx API v2 key */ /** Telnyx API v2 key */
apiKey: z.string().min(1).optional(), apiKey: z.string().min(1).optional(),
/** Telnyx connection ID (from Call Control app) */ /** Telnyx connection ID (from Call Control app) */
connectionId: z.string().min(1).optional(), connectionId: z.string().min(1).optional(),
/** Public key for webhook signature verification */ /** Public key for webhook signature verification */
publicKey: z.string().min(1).optional(), publicKey: z.string().min(1).optional(),
}); })
.strict();
export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>; export type TelnyxConfig = z.infer<typeof TelnyxConfigSchema>;
export const TwilioConfigSchema = z.object({ export const TwilioConfigSchema = z
.object({
/** Twilio Account SID */ /** Twilio Account SID */
accountSid: z.string().min(1).optional(), accountSid: z.string().min(1).optional(),
/** Twilio Auth Token */ /** Twilio Auth Token */
authToken: z.string().min(1).optional(), authToken: z.string().min(1).optional(),
}); })
.strict();
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>; export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
export const PlivoConfigSchema = z.object({ export const PlivoConfigSchema = z
.object({
/** Plivo Auth ID (starts with MA/SA) */ /** Plivo Auth ID (starts with MA/SA) */
authId: z.string().min(1).optional(), authId: z.string().min(1).optional(),
/** Plivo Auth Token */ /** Plivo Auth Token */
authToken: z.string().min(1).optional(), authToken: z.string().min(1).optional(),
}); })
.strict();
export type PlivoConfig = z.infer<typeof PlivoConfigSchema>; export type PlivoConfig = z.infer<typeof PlivoConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -72,6 +78,7 @@ export const SttConfigSchema = z
/** Whisper model to use */ /** Whisper model to use */
model: z.string().min(1).default("whisper-1"), model: z.string().min(1).default("whisper-1"),
}) })
.strict()
.default({ provider: "openai", model: "whisper-1" }); .default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>; export type SttConfig = z.infer<typeof SttConfigSchema>;
@@ -97,6 +104,7 @@ export const TtsConfigSchema = z
*/ */
instructions: z.string().optional(), instructions: z.string().optional(),
}) })
.strict()
.default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" }); .default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type TtsConfig = z.infer<typeof TtsConfigSchema>; export type TtsConfig = z.infer<typeof TtsConfigSchema>;
@@ -113,6 +121,7 @@ export const VoiceCallServeConfigSchema = z
/** Webhook path */ /** Webhook path */
path: z.string().min(1).default("/voice/webhook"), path: z.string().min(1).default("/voice/webhook"),
}) })
.strict()
.default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }); .default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" });
export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>; export type VoiceCallServeConfig = z.infer<typeof VoiceCallServeConfigSchema>;
@@ -128,6 +137,7 @@ export const VoiceCallTailscaleConfigSchema = z
/** Path for Tailscale serve/funnel (should usually match serve.path) */ /** Path for Tailscale serve/funnel (should usually match serve.path) */
path: z.string().min(1).default("/voice/webhook"), path: z.string().min(1).default("/voice/webhook"),
}) })
.strict()
.default({ mode: "off", path: "/voice/webhook" }); .default({ mode: "off", path: "/voice/webhook" });
export type VoiceCallTailscaleConfig = z.infer< export type VoiceCallTailscaleConfig = z.infer<
typeof VoiceCallTailscaleConfigSchema typeof VoiceCallTailscaleConfigSchema
@@ -161,6 +171,7 @@ export const VoiceCallTunnelConfigSchema = z
*/ */
allowNgrokFreeTier: z.boolean().default(true), allowNgrokFreeTier: z.boolean().default(true),
}) })
.strict()
.default({ provider: "none", allowNgrokFreeTier: true }); .default({ provider: "none", allowNgrokFreeTier: true });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>; export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
@@ -183,6 +194,7 @@ export const OutboundConfigSchema = z
/** Seconds to wait after TTS before auto-hangup in notify mode */ /** Seconds to wait after TTS before auto-hangup in notify mode */
notifyHangupDelaySec: z.number().int().nonnegative().default(3), notifyHangupDelaySec: z.number().int().nonnegative().default(3),
}) })
.strict()
.default({ defaultMode: "notify", notifyHangupDelaySec: 3 }); .default({ defaultMode: "notify", notifyHangupDelaySec: 3 });
export type OutboundConfig = z.infer<typeof OutboundConfigSchema>; export type OutboundConfig = z.infer<typeof OutboundConfigSchema>;
@@ -207,6 +219,7 @@ export const VoiceCallStreamingConfigSchema = z
/** WebSocket path for media stream connections */ /** WebSocket path for media stream connections */
streamPath: z.string().min(1).default("/voice/stream"), streamPath: z.string().min(1).default("/voice/stream"),
}) })
.strict()
.default({ .default({
enabled: false, enabled: false,
sttProvider: "openai-realtime", sttProvider: "openai-realtime",
@@ -223,7 +236,8 @@ export type VoiceCallStreamingConfig = z.infer<
// Main Voice Call Configuration // Main Voice Call Configuration
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const VoiceCallConfigSchema = z.object({ export const VoiceCallConfigSchema = z
.object({
/** Enable voice call functionality */ /** Enable voice call functionality */
enabled: z.boolean().default(false), enabled: z.boolean().default(false),
@@ -307,7 +321,8 @@ export const VoiceCallConfigSchema = z.object({
/** Timeout for response generation in ms (default 30s) */ /** Timeout for response generation in ms (default 30s) */
responseTimeoutMs: z.number().int().positive().default(30000), responseTimeoutMs: z.number().int().positive().default(30000),
}); })
.strict();
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>; export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { whatsappPlugin } from "./src/channel.js"; import { whatsappPlugin } from "./src/channel.js";
import { setWhatsAppRuntime } from "./src/runtime.js"; import { setWhatsAppRuntime } from "./src/runtime.js";
@@ -7,6 +8,7 @@ const plugin = {
id: "whatsapp", id: "whatsapp",
name: "WhatsApp", name: "WhatsApp",
description: "WhatsApp channel plugin", description: "WhatsApp channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setWhatsAppRuntime(api.runtime); setWhatsAppRuntime(api.runtime);
api.registerChannel({ plugin: whatsappPlugin }); api.registerChannel({ plugin: whatsappPlugin });

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { zaloDock, zaloPlugin } from "./src/channel.js"; import { zaloDock, zaloPlugin } from "./src/channel.js";
import { handleZaloWebhookRequest } from "./src/monitor.js"; import { handleZaloWebhookRequest } from "./src/monitor.js";
@@ -8,6 +9,7 @@ const plugin = {
id: "zalo", id: "zalo",
name: "Zalo", name: "Zalo",
description: "Zalo channel plugin (Bot API)", description: "Zalo channel plugin (Bot API)",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setZaloRuntime(api.runtime); setZaloRuntime(api.runtime);
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock }); api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });

View File

@@ -1,4 +1,5 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { zalouserPlugin } from "./src/channel.js"; import { zalouserPlugin } from "./src/channel.js";
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js"; import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
@@ -8,6 +9,7 @@ const plugin = {
id: "zalouser", id: "zalouser",
name: "Zalo Personal", name: "Zalo Personal",
description: "Zalo personal account messaging via zca-cli", description: "Zalo personal account messaging via zca-cli",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) { register(api: ClawdbotPluginApi) {
setZalouserRuntime(api.runtime); setZalouserRuntime(api.runtime);
// Register channel plugin (for onboarding & gateway) // Register channel plugin (for onboarding & gateway)

View File

@@ -12,7 +12,6 @@ type SandboxHashInput = {
function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null { function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null {
return value === null || (typeof value !== "object" && typeof value !== "function"); return value === null || (typeof value !== "object" && typeof value !== "function");
} }
function normalizeForHash(value: unknown): unknown { function normalizeForHash(value: unknown): unknown {
if (value === undefined) return undefined; if (value === undefined) return undefined;
if (Array.isArray(value)) { if (Array.isArray(value)) {

View File

@@ -43,6 +43,11 @@ vi.mock("../tui/tui.js", () => ({ runTui }));
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway, callGateway,
randomIdempotencyKey: () => "idem-test", randomIdempotencyKey: () => "idem-test",
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:1234",
urlSource: "test",
message: "Gateway target: ws://127.0.0.1:1234",
}),
})); }));
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
@@ -127,26 +132,32 @@ describe("cli program (nodes basics)", () => {
}); });
it("runs nodes describe and calls node.describe", async () => { it("runs nodes describe and calls node.describe", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
}, connected: true,
], },
}) ],
.mockResolvedValueOnce({ };
ts: Date.now(), }
nodeId: "ios-node", if (opts.method === "node.describe") {
displayName: "iOS Node", return {
caps: ["canvas", "camera"], ts: Date.now(),
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], nodeId: "ios-node",
connected: true, displayName: "iOS Node",
}); caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
connected: true,
};
}
return { ok: true };
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -154,12 +165,10 @@ describe("cli program (nodes basics)", () => {
from: "user", from: "user",
}); });
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
1,
expect.objectContaining({ method: "node.list", params: {} }), expect.objectContaining({ method: "node.list", params: {} }),
); );
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
2,
expect.objectContaining({ expect.objectContaining({
method: "node.describe", method: "node.describe",
params: { nodeId: "ios-node" }, params: { nodeId: "ios-node" },
@@ -189,24 +198,30 @@ describe("cli program (nodes basics)", () => {
}); });
it("runs nodes invoke and calls node.invoke", async () => { it("runs nodes invoke and calls node.invoke", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
}, connected: true,
], },
}) ],
.mockResolvedValueOnce({ };
ok: true, }
nodeId: "ios-node", if (opts.method === "node.invoke") {
command: "canvas.eval", return {
payload: { result: "ok" }, ok: true,
}); nodeId: "ios-node",
command: "canvas.eval",
payload: { result: "ok" },
};
}
return { ok: true };
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -224,12 +239,10 @@ describe("cli program (nodes basics)", () => {
{ from: "user" }, { from: "user" },
); );
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
1,
expect.objectContaining({ method: "node.list", params: {} }), expect.objectContaining({ method: "node.list", params: {} }),
); );
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
2,
expect.objectContaining({ expect.objectContaining({
method: "node.invoke", method: "node.invoke",
params: { params: {

View File

@@ -44,6 +44,11 @@ vi.mock("../tui/tui.js", () => ({ runTui }));
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway, callGateway,
randomIdempotencyKey: () => "idem-test", randomIdempotencyKey: () => "idem-test",
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:1234",
urlSource: "test",
message: "Gateway target: ws://127.0.0.1:1234",
}),
})); }));
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));
@@ -56,61 +61,43 @@ describe("cli program (nodes media)", () => {
}); });
it("runs nodes camera snap and prints two MEDIA paths", async () => { it("runs nodes camera snap and prints two MEDIA paths", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
}, connected: true,
], },
}) ],
.mockResolvedValueOnce({ };
ok: true, }
nodeId: "ios-node", if (opts.method === "node.invoke") {
command: "camera.snap", return {
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, ok: true,
}) nodeId: "ios-node",
.mockResolvedValueOnce({ command: "camera.snap",
ok: true, payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
nodeId: "ios-node", };
command: "camera.snap", }
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, return { ok: true };
}); });
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
await program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node"], { from: "user" }); await program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node"], { from: "user" });
expect(callGateway).toHaveBeenNthCalledWith( const invokeCalls = callGateway.mock.calls
2, .map((call) => call[0] as { method?: string; params?: Record<string, unknown> })
expect.objectContaining({ .filter((call) => call.method === "node.invoke");
method: "node.invoke", const facings = invokeCalls
params: expect.objectContaining({ .map((call) => (call.params?.params as { facing?: string } | undefined)?.facing)
nodeId: "ios-node", .filter(Boolean)
command: "camera.snap", .sort((a, b) => a.localeCompare(b));
timeoutMs: 20000, expect(facings).toEqual(["back", "front"]);
idempotencyKey: "idem-test",
params: expect.objectContaining({ facing: "front", format: "jpg" }),
}),
}),
);
expect(callGateway).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
method: "node.invoke",
params: expect.objectContaining({
nodeId: "ios-node",
command: "camera.snap",
timeoutMs: 20000,
idempotencyKey: "idem-test",
params: expect.objectContaining({ facing: "back", format: "jpg" }),
}),
}),
);
const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); const out = String(runtime.log.mock.calls[0]?.[0] ?? "");
const mediaPaths = out const mediaPaths = out
@@ -130,29 +117,35 @@ describe("cli program (nodes media)", () => {
}); });
it("runs nodes camera clip and prints one MEDIA path", async () => { it("runs nodes camera clip and prints one MEDIA path", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: true,
}, },
], };
}) }
.mockResolvedValueOnce({ return { ok: true };
ok: true, });
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: true,
},
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -161,8 +154,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" }, { from: "user" },
); );
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
2,
expect.objectContaining({ expect.objectContaining({
method: "node.invoke", method: "node.invoke",
params: expect.objectContaining({ params: expect.objectContaining({
@@ -192,24 +184,30 @@ describe("cli program (nodes media)", () => {
}); });
it("runs nodes camera snap with facing front and passes params", async () => { it("runs nodes camera snap with facing front and passes params", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
}, connected: true,
], },
}) ],
.mockResolvedValueOnce({ };
ok: true, }
nodeId: "ios-node", if (opts.method === "node.invoke") {
command: "camera.snap", return {
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, ok: true,
}); nodeId: "ios-node",
command: "camera.snap",
payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 },
};
}
return { ok: true };
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -234,8 +232,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" }, { from: "user" },
); );
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
2,
expect.objectContaining({ expect.objectContaining({
method: "node.invoke", method: "node.invoke",
params: expect.objectContaining({ params: expect.objectContaining({
@@ -265,29 +262,35 @@ describe("cli program (nodes media)", () => {
}); });
it("runs nodes camera clip with --no-audio", async () => { it("runs nodes camera clip with --no-audio", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: false,
}, },
], };
}) }
.mockResolvedValueOnce({ return { ok: true };
ok: true, });
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 3000,
hasAudio: false,
},
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -307,8 +310,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" }, { from: "user" },
); );
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
2,
expect.objectContaining({ expect.objectContaining({
method: "node.invoke", method: "node.invoke",
params: expect.objectContaining({ params: expect.objectContaining({
@@ -335,29 +337,35 @@ describe("cli program (nodes media)", () => {
}); });
it("runs nodes camera clip with human duration (10s)", async () => { it("runs nodes camera clip with human duration (10s)", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
connected: true,
},
],
};
}
if (opts.method === "node.invoke") {
return {
ok: true,
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 10_000,
hasAudio: true,
}, },
], };
}) }
.mockResolvedValueOnce({ return { ok: true };
ok: true, });
nodeId: "ios-node",
command: "camera.clip",
payload: {
format: "mp4",
base64: "aGk=",
durationMs: 10_000,
hasAudio: true,
},
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -366,8 +374,7 @@ describe("cli program (nodes media)", () => {
{ from: "user" }, { from: "user" },
); );
expect(callGateway).toHaveBeenNthCalledWith( expect(callGateway).toHaveBeenCalledWith(
2,
expect.objectContaining({ expect.objectContaining({
method: "node.invoke", method: "node.invoke",
params: expect.objectContaining({ params: expect.objectContaining({
@@ -380,24 +387,30 @@ describe("cli program (nodes media)", () => {
}); });
it("runs nodes canvas snapshot and prints MEDIA path", async () => { it("runs nodes canvas snapshot and prints MEDIA path", async () => {
callGateway callGateway.mockImplementation(async (opts: { method?: string }) => {
.mockResolvedValueOnce({ if (opts.method === "node.list") {
ts: Date.now(), return {
nodes: [ ts: Date.now(),
{ nodes: [
nodeId: "ios-node", {
displayName: "iOS Node", nodeId: "ios-node",
remoteIp: "192.168.0.88", displayName: "iOS Node",
connected: true, remoteIp: "192.168.0.88",
}, connected: true,
], },
}) ],
.mockResolvedValueOnce({ };
ok: true, }
nodeId: "ios-node", if (opts.method === "node.invoke") {
command: "canvas.snapshot", return {
payload: { format: "png", base64: "aGk=" }, ok: true,
}); nodeId: "ios-node",
command: "canvas.snapshot",
payload: { format: "png", base64: "aGk=" },
};
}
return { ok: true };
});
const program = buildProgram(); const program = buildProgram();
runtime.log.mockClear(); runtime.log.mockClear();
@@ -418,16 +431,21 @@ describe("cli program (nodes media)", () => {
}); });
it("fails nodes camera snap on invalid facing", async () => { it("fails nodes camera snap on invalid facing", async () => {
callGateway.mockResolvedValueOnce({ callGateway.mockImplementation(async (opts: { method?: string }) => {
ts: Date.now(), if (opts.method === "node.list") {
nodes: [ return {
{ ts: Date.now(),
nodeId: "ios-node", nodes: [
displayName: "iOS Node", {
remoteIp: "192.168.0.88", nodeId: "ios-node",
connected: true, displayName: "iOS Node",
}, remoteIp: "192.168.0.88",
], connected: true,
},
],
};
}
return { ok: true };
}); });
const program = buildProgram(); const program = buildProgram();
@@ -439,6 +457,8 @@ describe("cli program (nodes media)", () => {
}), }),
).rejects.toThrow(/exit/i); ).rejects.toThrow(/exit/i);
expect(runtime.error).toHaveBeenCalledWith(expect.stringMatching(/invalid facing/i)); expect(runtime.error.mock.calls.some(([msg]) => /invalid facing/i.test(String(msg)))).toBe(
true,
);
}); });
}); });

View File

@@ -43,6 +43,11 @@ vi.mock("../tui/tui.js", () => ({ runTui }));
vi.mock("../gateway/call.js", () => ({ vi.mock("../gateway/call.js", () => ({
callGateway, callGateway,
randomIdempotencyKey: () => "idem-test", randomIdempotencyKey: () => "idem-test",
buildGatewayConnectionDetails: () => ({
url: "ws://127.0.0.1:1234",
urlSource: "test",
message: "Gateway target: ws://127.0.0.1:1234",
}),
})); }));
vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) })); vi.mock("./deps.js", () => ({ createDefaultDeps: () => ({}) }));

View File

@@ -1,65 +1,67 @@
import { import { readConfigFileSnapshot } from "../../config/config.js";
isNixMode, import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
loadConfig, import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
migrateLegacyConfig, import { loadClawdbotPlugins } from "../../plugins/loader.js";
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import { danger } from "../../globals.js";
import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status", "service"]);
function formatConfigIssues(issues: Array<{ path: string; message: string }>): string[] {
return issues.map((issue) => `- ${issue.path || "<root>"}: ${issue.message}`);
}
export async function ensureConfigReady(params: { export async function ensureConfigReady(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
migrateState?: boolean; commandPath?: string[];
}): Promise<void> { }): Promise<void> {
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true },
confirm: async () => false,
});
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
if (snapshot.legacyIssues.length > 0) { const command = params.commandPath?.[0];
if (isNixMode) { const allowInvalid = command ? ALLOWED_INVALID_COMMANDS.has(command) : false;
params.runtime.error( const issues = snapshot.exists && !snapshot.valid ? formatConfigIssues(snapshot.issues) : [];
danger( const legacyIssues =
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.", snapshot.legacyIssues.length > 0
), ? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
); : [];
params.runtime.exit(1);
return; const pluginIssues: string[] = [];
} if (snapshot.valid) {
const migrated = migrateLegacyConfig(snapshot.parsed); const workspaceDir = resolveAgentWorkspaceDir(
if (migrated.config) { snapshot.config,
await writeConfigFile(migrated.config); resolveDefaultAgentId(snapshot.config),
if (migrated.changes.length > 0) { );
params.runtime.log( const registry = loadClawdbotPlugins({
`Migrated legacy config entries:\n${migrated.changes config: snapshot.config,
.map((entry) => `- ${entry}`) workspaceDir: workspaceDir ?? undefined,
.join("\n")}`, cache: false,
); mode: "validate",
} });
} else { for (const diag of registry.diagnostics) {
const issues = snapshot.legacyIssues if (diag.level !== "error") continue;
.map((issue) => `- ${issue.path}: ${issue.message}`) const id = diag.pluginId ? ` ${diag.pluginId}` : "";
.join("\n"); pluginIssues.push(`- plugin${id}: ${diag.message}`);
params.runtime.error(
danger(
`Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`,
),
);
params.runtime.exit(1);
return;
} }
} }
if (snapshot.exists && !snapshot.valid) { const invalid = snapshot.exists && (!snapshot.valid || pluginIssues.length > 0);
params.runtime.error(`Config invalid at ${snapshot.path}.`); if (!invalid) return;
for (const issue of snapshot.issues) {
params.runtime.error(`- ${issue.path || "<root>"}: ${issue.message}`); params.runtime.error(`Config invalid at ${snapshot.path}.`);
} if (issues.length > 0) {
params.runtime.error("Run `clawdbot doctor` to repair, then retry."); params.runtime.error(issues.join("\n"));
}
if (legacyIssues.length > 0) {
params.runtime.error(`Legacy config keys detected:\n${legacyIssues.join("\n")}`);
}
if (pluginIssues.length > 0) {
params.runtime.error(`Plugin config errors:\n${pluginIssues.join("\n")}`);
}
params.runtime.error("Run `clawdbot doctor --fix` to repair, then retry.");
if (!allowInvalid) {
params.runtime.exit(1); params.runtime.exit(1);
return;
}
if (params.migrateState !== false) {
const cfg = loadConfig();
await autoMigrateLegacyState({ cfg });
} }
} }

View File

@@ -1,7 +1,7 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js"; import { emitCliBanner } from "../banner.js";
import { getCommandPath, hasHelpOrVersion, shouldMigrateState } from "../argv.js"; import { getCommandPath, hasHelpOrVersion } from "../argv.js";
import { ensureConfigReady } from "./config-guard.js"; import { ensureConfigReady } from "./config-guard.js";
function setProcessTitleForCommand(actionCommand: Command) { function setProcessTitleForCommand(actionCommand: Command) {
@@ -20,9 +20,8 @@ export function registerPreActionHooks(program: Command, programVersion: string)
emitCliBanner(programVersion); emitCliBanner(programVersion);
const argv = process.argv; const argv = process.argv;
if (hasHelpOrVersion(argv)) return; if (hasHelpOrVersion(argv)) return;
const [primary] = getCommandPath(argv, 1); const commandPath = getCommandPath(argv, 2);
if (primary === "doctor") return; if (commandPath[0] === "doctor") return;
const migrateState = shouldMigrateState(argv); await ensureConfigReady({ runtime: defaultRuntime, commandPath });
await ensureConfigReady({ runtime: defaultRuntime, migrateState });
}); });
} }

View File

@@ -20,6 +20,7 @@ export function registerMaintenanceCommands(program: Command) {
.option("--no-workspace-suggestions", "Disable workspace memory system suggestions", false) .option("--no-workspace-suggestions", "Disable workspace memory system suggestions", false)
.option("--yes", "Accept defaults without prompting", false) .option("--yes", "Accept defaults without prompting", false)
.option("--repair", "Apply recommended repairs without prompting", false) .option("--repair", "Apply recommended repairs without prompting", false)
.option("--fix", "Apply recommended repairs (alias for --repair)", false)
.option("--force", "Apply aggressive repairs (overwrites custom service config)", false) .option("--force", "Apply aggressive repairs (overwrites custom service config)", false)
.option("--non-interactive", "Run without prompts (safe migrations only)", false) .option("--non-interactive", "Run without prompts (safe migrations only)", false)
.option("--generate-gateway-token", "Generate and configure a gateway token", false) .option("--generate-gateway-token", "Generate and configure a gateway token", false)
@@ -29,7 +30,7 @@ export function registerMaintenanceCommands(program: Command) {
await doctorCommand(defaultRuntime, { await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions, workspaceSuggestions: opts.workspaceSuggestions,
yes: Boolean(opts.yes), yes: Boolean(opts.yes),
repair: Boolean(opts.repair), repair: Boolean(opts.repair) || Boolean(opts.fix),
force: Boolean(opts.force), force: Boolean(opts.force),
nonInteractive: Boolean(opts.nonInteractive), nonInteractive: Boolean(opts.nonInteractive),
generateGatewayToken: Boolean(opts.generateGatewayToken), generateGatewayToken: Boolean(opts.generateGatewayToken),

View File

@@ -15,18 +15,17 @@ import {
getVerboseFlag, getVerboseFlag,
hasFlag, hasFlag,
hasHelpOrVersion, hasHelpOrVersion,
shouldMigrateStateFromPath,
} from "./argv.js"; } from "./argv.js";
import { ensureConfigReady } from "./program/config-guard.js"; import { ensureConfigReady } from "./program/config-guard.js";
import { runMemoryStatus } from "./memory-cli.js"; import { runMemoryStatus } from "./memory-cli.js";
async function prepareRoutedCommand(params: { async function prepareRoutedCommand(params: {
argv: string[]; argv: string[];
migrateState: boolean; commandPath: string[];
loadPlugins?: boolean; loadPlugins?: boolean;
}) { }) {
emitCliBanner(VERSION, { argv: params.argv }); emitCliBanner(VERSION, { argv: params.argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: params.migrateState }); await ensureConfigReady({ runtime: defaultRuntime, commandPath: params.commandPath });
if (params.loadPlugins) { if (params.loadPlugins) {
ensurePluginRegistryLoaded(); ensurePluginRegistryLoaded();
} }
@@ -39,10 +38,8 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
const path = getCommandPath(argv, 2); const path = getCommandPath(argv, 2);
const [primary, secondary] = path; const [primary, secondary] = path;
if (!primary) return false; if (!primary) return false;
const migrateState = shouldMigrateStateFromPath(path);
if (primary === "health") { if (primary === "health") {
await prepareRoutedCommand({ argv, migrateState, loadPlugins: true }); await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv, { includeDebug: true }); const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
@@ -53,7 +50,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
} }
if (primary === "status") { if (primary === "status") {
await prepareRoutedCommand({ argv, migrateState, loadPlugins: true }); await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true });
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep"); const deep = hasFlag(argv, "--deep");
const all = hasFlag(argv, "--all"); const all = hasFlag(argv, "--all");
@@ -67,7 +64,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
} }
if (primary === "sessions") { if (primary === "sessions") {
await prepareRoutedCommand({ argv, migrateState }); await prepareRoutedCommand({ argv, commandPath: path });
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv); const verbose = getVerboseFlag(argv);
const store = getFlagValue(argv, "--store"); const store = getFlagValue(argv, "--store");
@@ -80,7 +77,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
} }
if (primary === "agents" && secondary === "list") { if (primary === "agents" && secondary === "list") {
await prepareRoutedCommand({ argv, migrateState }); await prepareRoutedCommand({ argv, commandPath: path });
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings"); const bindings = hasFlag(argv, "--bindings");
await agentsListCommand({ json, bindings }, defaultRuntime); await agentsListCommand({ json, bindings }, defaultRuntime);
@@ -88,7 +85,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
} }
if (primary === "memory" && secondary === "status") { if (primary === "memory" && secondary === "status") {
await prepareRoutedCommand({ argv, migrateState }); await prepareRoutedCommand({ argv, commandPath: path });
const agent = getFlagValue(argv, "--agent"); const agent = getFlagValue(argv, "--agent");
if (agent === null) return false; if (agent === null) return false;
const json = hasFlag(argv, "--json"); const json = hasFlag(argv, "--json");

View File

@@ -4,6 +4,7 @@ import {
migrateLegacyConfig, migrateLegacyConfig,
readConfigFileSnapshot, readConfigFileSnapshot,
} from "../config/config.js"; } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js"; import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js";
import type { DoctorOptions } from "./doctor-prompter.js"; import type { DoctorOptions } from "./doctor-prompter.js";
@@ -45,6 +46,8 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
options: DoctorOptions; options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>; confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
}) { }) {
void params.confirm;
const shouldRepair = params.options.repair === true || params.options.yes === true;
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
let cfg: ClawdbotConfig = snapshot.config ?? {}; let cfg: ClawdbotConfig = snapshot.config ?? {};
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) { if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
@@ -56,25 +59,34 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"), snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"),
"Legacy config keys detected", "Legacy config keys detected",
); );
const migrate = if (shouldRepair) {
params.options.nonInteractive === true
? true
: await params.confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
});
if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom. // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed); const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
if (changes.length > 0) note(changes.join("\n"), "Doctor changes"); if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
if (migrated) cfg = migrated; if (migrated) cfg = migrated;
} else {
note('Run "clawdbot doctor --fix" to apply legacy migrations.', "Doctor");
} }
} }
const normalized = normalizeLegacyConfigValues(cfg); const normalized = normalizeLegacyConfigValues(cfg);
if (normalized.changes.length > 0) { if (normalized.changes.length > 0) {
note(normalized.changes.join("\n"), "Doctor changes"); note(normalized.changes.join("\n"), "Doctor changes");
cfg = normalized.config; if (shouldRepair) {
cfg = normalized.config;
} else {
note('Run "clawdbot doctor --fix" to apply these changes.', "Doctor");
}
}
const autoEnable = applyPluginAutoEnable({ config: cfg, env: process.env });
if (autoEnable.changes.length > 0) {
note(autoEnable.changes.join("\n"), "Doctor changes");
if (shouldRepair) {
cfg = autoEnable.config;
} else {
note('Run "clawdbot doctor --fix" to apply these changes.', "Doctor");
}
} }
noteOpencodeProviderOverrides(cfg); noteOpencodeProviderOverrides(cfg);

View File

@@ -356,7 +356,7 @@ describe("doctor command", () => {
changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."], changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."],
}); });
await doctorCommand(runtime, { nonInteractive: true }); await doctorCommand(runtime, { nonInteractive: true, repair: true });
expect(writeConfigFile).toHaveBeenCalledTimes(1); expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>; const written = writeConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;

View File

@@ -247,9 +247,13 @@ export async function doctorCommand(
healthOk, healthOk,
}); });
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) }); if (prompter.shouldRepair) {
await writeConfigFile(cfg); cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); await writeConfigFile(cfg);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
} else {
runtime.log('Run "clawdbot doctor --fix" to apply changes.');
}
if (options.workspaceSuggestions !== false) { if (options.workspaceSuggestions !== false) {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));

View File

@@ -12,7 +12,7 @@ describe("config env vars", () => {
path.join(configDir, "clawdbot.json"), path.join(configDir, "clawdbot.json"),
JSON.stringify( JSON.stringify(
{ {
env: { OPENROUTER_API_KEY: "config-key" }, env: { vars: { OPENROUTER_API_KEY: "config-key" } },
}, },
null, null,
2, 2,
@@ -36,7 +36,7 @@ describe("config env vars", () => {
path.join(configDir, "clawdbot.json"), path.join(configDir, "clawdbot.json"),
JSON.stringify( JSON.stringify(
{ {
env: { OPENROUTER_API_KEY: "config-key" }, env: { vars: { OPENROUTER_API_KEY: "config-key" } },
}, },
null, null,
2, 2,

View File

@@ -164,8 +164,6 @@ describe("config identity defaults", () => {
messages: { messages: {
messagePrefix: "[clawdbot]", messagePrefix: "[clawdbot]",
responsePrefix: "🦞", responsePrefix: "🦞",
// legacy field should be ignored (moved to providers)
textChunkLimit: 9999,
}, },
channels: { channels: {
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 }, whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },

View File

@@ -65,7 +65,7 @@ describe("legacy config detection", () => {
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ const res = validateConfigObject({
channels: { imessage: { cliPath: "imsg; rm -rf /" } }, channels: { imessage: { cliPath: "imsg; rm -rf /" } },
tools: { audio: { transcription: { args: ["--model", "base"] } } }, audio: { transcription: { command: ["whisper", "--model", "base"] } },
}); });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
if (!res.ok) { if (!res.ok) {
@@ -76,7 +76,7 @@ describe("legacy config detection", () => {
vi.resetModules(); vi.resetModules();
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ const res = validateConfigObject({
tools: { audio: { transcription: { args: ["--model", "base"] } } }, audio: { transcription: { command: ["whisper", "--model", "base"] } },
}); });
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
}); });
@@ -85,11 +85,9 @@ describe("legacy config detection", () => {
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ const res = validateConfigObject({
channels: { imessage: { cliPath: "/Applications/Imsg Tools/imsg" } }, channels: { imessage: { cliPath: "/Applications/Imsg Tools/imsg" } },
tools: { audio: {
audio: { transcription: {
transcription: { command: ["whisper", "--model"],
args: ["--model"],
},
}, },
}, },
}); });
@@ -166,7 +164,7 @@ describe("legacy config detection", () => {
expect(res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"]).toBeTruthy(); expect(res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"]).toBeTruthy();
expect(res.config?.agent).toBeUndefined(); expect(res.config?.agent).toBeUndefined();
}); });
it("auto-migrates legacy config in snapshot (no legacyIssues)", async () => { it("flags legacy config in snapshot", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -176,31 +174,23 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { readConfigFileSnapshot } = await import("./config.js");
const { readConfigFileSnapshot } = await import("./config.js"); const snap = await readConfigFileSnapshot();
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true); expect(snap.valid).toBe(false);
expect(snap.legacyIssues.length).toBe(0); expect(snap.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
const raw = await fs.readFile(configPath, "utf-8"); const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } }; routing?: { allowFrom?: string[] };
routing?: unknown; channels?: unknown;
}; };
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); expect(parsed.routing?.allowFrom).toEqual(["+15555550123"]);
expect(parsed.routing).toBeUndefined(); expect(parsed.channels).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
}); });
}); });
it("auto-migrates claude-cli auth profile mode to oauth", async () => { it("does not auto-migrate claude-cli auth profile mode on load", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -220,27 +210,19 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { loadConfig } = await import("./config.js");
const { loadConfig } = await import("./config.js"); const cfg = loadConfig();
const cfg = loadConfig(); expect(cfg.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("token");
expect(cfg.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth");
const raw = await fs.readFile(configPath, "utf-8"); const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
auth?: { profiles?: Record<string, { mode?: string }> }; auth?: { profiles?: Record<string, { mode?: string }> };
}; };
expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth"); expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("token");
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
}); });
}); });
it("auto-migrates legacy provider sections on load and writes back", async () => { it("flags legacy provider sections in snapshot", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -250,29 +232,23 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { readConfigFileSnapshot } = await import("./config.js");
const { loadConfig } = await import("./config.js"); const snap = await readConfigFileSnapshot();
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1555"]); expect(snap.valid).toBe(false);
const raw = await fs.readFile(configPath, "utf-8"); expect(snap.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true);
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } }; const raw = await fs.readFile(configPath, "utf-8");
whatsapp?: unknown; const parsed = JSON.parse(raw) as {
}; channels?: unknown;
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1555"]); whatsapp?: unknown;
expect(parsed.whatsapp).toBeUndefined(); };
expect( expect(parsed.channels).toBeUndefined();
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), expect(parsed.whatsapp).toBeTruthy();
).toBe(true);
} finally {
warnSpy.mockRestore();
}
}); });
}); });
it("auto-migrates routing.allowFrom on load and writes back", async () => { it("flags routing.allowFrom in snapshot", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -282,26 +258,23 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { readConfigFileSnapshot } = await import("./config.js");
const { loadConfig } = await import("./config.js"); const snap = await readConfigFileSnapshot();
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1666"]); expect(snap.valid).toBe(false);
const raw = await fs.readFile(configPath, "utf-8"); expect(snap.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true);
const parsed = JSON.parse(raw) as {
channels?: { whatsapp?: { allowFrom?: string[] } }; const raw = await fs.readFile(configPath, "utf-8");
routing?: unknown; const parsed = JSON.parse(raw) as {
}; channels?: unknown;
expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1666"]); routing?: { allowFrom?: string[] };
expect(parsed.routing).toBeUndefined(); };
} finally { expect(parsed.channels).toBeUndefined();
warnSpy.mockRestore(); expect(parsed.routing?.allowFrom).toEqual(["+1666"]);
}
}); });
}); });
it("auto-migrates bindings[].match.provider on load and writes back", async () => { it("rejects bindings[].match.provider on load", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -317,28 +290,21 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { readConfigFileSnapshot } = await import("./config.js");
const { loadConfig } = await import("./config.js"); const snap = await readConfigFileSnapshot();
const cfg = loadConfig();
expect(cfg.bindings?.[0]?.match?.channel).toBe("slack");
const raw = await fs.readFile(configPath, "utf-8"); expect(snap.valid).toBe(false);
const parsed = JSON.parse(raw) as { expect(snap.issues.length).toBeGreaterThan(0);
bindings?: Array<{ match?: { channel?: string; provider?: string } }>;
}; const raw = await fs.readFile(configPath, "utf-8");
expect(parsed.bindings?.[0]?.match?.channel).toBe("slack"); const parsed = JSON.parse(raw) as {
expect(parsed.bindings?.[0]?.match?.provider).toBeUndefined(); bindings?: Array<{ match?: { provider?: string } }>;
expect( };
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), expect(parsed.bindings?.[0]?.match?.provider).toBe("slack");
).toBe(true);
} finally {
warnSpy.mockRestore();
}
}); });
}); });
it("auto-migrates bindings[].match.accountID on load and writes back", async () => { it("rejects bindings[].match.accountID on load", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -354,28 +320,21 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { readConfigFileSnapshot } = await import("./config.js");
const { loadConfig } = await import("./config.js"); const snap = await readConfigFileSnapshot();
const cfg = loadConfig();
expect(cfg.bindings?.[0]?.match?.accountId).toBe("work");
const raw = await fs.readFile(configPath, "utf-8"); expect(snap.valid).toBe(false);
const parsed = JSON.parse(raw) as { expect(snap.issues.length).toBeGreaterThan(0);
bindings?: Array<{ match?: { accountId?: string; accountID?: string } }>;
}; const raw = await fs.readFile(configPath, "utf-8");
expect(parsed.bindings?.[0]?.match?.accountId).toBe("work"); const parsed = JSON.parse(raw) as {
expect(parsed.bindings?.[0]?.match?.accountID).toBeUndefined(); bindings?: Array<{ match?: { accountID?: string } }>;
expect( };
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), expect(parsed.bindings?.[0]?.match?.accountID).toBe("work");
).toBe(true);
} finally {
warnSpy.mockRestore();
}
}); });
}); });
it("auto-migrates session.sendPolicy.rules[].match.provider on load and writes back", async () => { it("rejects session.sendPolicy.rules[].match.provider on load", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -395,34 +354,21 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { readConfigFileSnapshot } = await import("./config.js");
const { loadConfig } = await import("./config.js"); const snap = await readConfigFileSnapshot();
const cfg = loadConfig();
expect(cfg.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe("telegram");
const raw = await fs.readFile(configPath, "utf-8"); expect(snap.valid).toBe(false);
const parsed = JSON.parse(raw) as { expect(snap.issues.length).toBeGreaterThan(0);
session?: {
sendPolicy?: { const raw = await fs.readFile(configPath, "utf-8");
rules?: Array<{ const parsed = JSON.parse(raw) as {
match?: { channel?: string; provider?: string }; session?: { sendPolicy?: { rules?: Array<{ match?: { provider?: string } }> } };
}>; };
}; expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.provider).toBe("telegram");
};
};
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe("telegram");
expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.provider).toBeUndefined();
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
}); });
}); });
it("auto-migrates messages.queue.byProvider on load and writes back", async () => { it("rejects messages.queue.byProvider on load", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdbot", "clawdbot.json"); const configPath = path.join(home, ".clawdbot", "clawdbot.json");
await fs.mkdir(path.dirname(configPath), { recursive: true }); await fs.mkdir(path.dirname(configPath), { recursive: true });
@@ -432,30 +378,22 @@ describe("legacy config detection", () => {
"utf-8", "utf-8",
); );
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.resetModules(); vi.resetModules();
try { const { readConfigFileSnapshot } = await import("./config.js");
const { loadConfig } = await import("./config.js"); const snap = await readConfigFileSnapshot();
const cfg = loadConfig();
expect(cfg.messages?.queue?.byChannel?.whatsapp).toBe("queue");
const raw = await fs.readFile(configPath, "utf-8"); expect(snap.valid).toBe(false);
const parsed = JSON.parse(raw) as { expect(snap.issues.length).toBeGreaterThan(0);
messages?: {
queue?: { const raw = await fs.readFile(configPath, "utf-8");
byChannel?: Record<string, unknown>; const parsed = JSON.parse(raw) as {
byProvider?: unknown; messages?: {
}; queue?: {
byProvider?: Record<string, unknown>;
}; };
}; };
expect(parsed.messages?.queue?.byChannel?.whatsapp).toBe("queue"); };
expect(parsed.messages?.queue?.byProvider).toBeUndefined(); expect(parsed.messages?.queue?.byProvider?.whatsapp).toBe("queue");
expect(
warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")),
).toBe(true);
} finally {
warnSpy.mockRestore();
}
}); });
}); });
}); });

View File

@@ -38,7 +38,7 @@ describe("multi-agent agentDir validation", () => {
{ id: "b", agentDir: "~/.clawdbot/agents/shared/agent" }, { id: "b", agentDir: "~/.clawdbot/agents/shared/agent" },
], ],
}, },
bindings: [{ agentId: "a", match: { provider: "telegram" } }], bindings: [{ agentId: "a", match: { channel: "telegram" } }],
}, },
null, null,
2, 2,

View File

@@ -3,21 +3,18 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js"; import { withTempHome } from "./test-helpers.js";
describe("config preservation on validation failure", () => { describe("config strict validation", () => {
it("preserves unknown fields via passthrough", async () => { it("rejects unknown fields", async () => {
vi.resetModules(); vi.resetModules();
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({ const res = validateConfigObject({
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
customUnknownField: { nested: "value" }, customUnknownField: { nested: "value" },
}); });
expect(res.ok).toBe(true); expect(res.ok).toBe(false);
expect((res as { config: Record<string, unknown> }).config.customUnknownField).toEqual({
nested: "value",
});
}); });
it("preserves config data when validation fails", async () => { it("flags legacy config entries without auto-migrating", async () => {
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 });
@@ -26,7 +23,6 @@ describe("config preservation on validation failure", () => {
JSON.stringify({ JSON.stringify({
agents: { list: [{ id: "pi" }] }, agents: { list: [{ id: "pi" }] },
routing: { allowFrom: ["+15555550123"] }, routing: { allowFrom: ["+15555550123"] },
customData: { preserved: true },
}), }),
"utf-8", "utf-8",
); );
@@ -35,12 +31,8 @@ describe("config preservation on validation failure", () => {
const { readConfigFileSnapshot } = await import("./config.js"); const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot(); const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true); expect(snap.valid).toBe(false);
expect(snap.legacyIssues).toHaveLength(0); expect(snap.legacyIssues).not.toHaveLength(0);
expect((snap.config as Record<string, unknown>).customData).toEqual({
preserved: true,
});
expect(snap.config.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
}); });
}); });
}); });

View File

@@ -24,10 +24,9 @@ import {
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js"; import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js"; import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { applyLegacyMigrations, findLegacyConfigIssues } from "./legacy.js"; import { findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js"; import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveStateDir } from "./paths.js"; import { resolveConfigPath, resolveStateDir } from "./paths.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.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 { validateConfigObject } from "./validation.js";
@@ -87,29 +86,6 @@ function coerceConfig(value: unknown): ClawdbotConfig {
return value as ClawdbotConfig; return value as ClawdbotConfig;
} }
function rotateConfigBackupsSync(configPath: string, ioFs: typeof fs): void {
if (CONFIG_BACKUP_COUNT <= 1) return;
const backupBase = `${configPath}.bak`;
const maxIndex = CONFIG_BACKUP_COUNT - 1;
try {
ioFs.unlinkSync(`${backupBase}.${maxIndex}`);
} catch {
// best-effort
}
for (let index = maxIndex - 1; index >= 1; index -= 1) {
try {
ioFs.renameSync(`${backupBase}.${index}`, `${backupBase}.${index + 1}`);
} catch {
// best-effort
}
}
try {
ioFs.renameSync(backupBase, `${backupBase}.1`);
} catch {
// best-effort
}
}
async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises): Promise<void> { async function rotateConfigBackups(configPath: string, ioFs: typeof fs.promises): Promise<void> {
if (CONFIG_BACKUP_COUNT <= 1) return; if (CONFIG_BACKUP_COUNT <= 1) return;
const backupBase = `${configPath}.bak`; const backupBase = `${configPath}.bak`;
@@ -147,10 +123,6 @@ function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">)
} }
} }
function formatLegacyMigrationLog(changes: string[]): string {
return `Auto-migrated config:\n${changes.map((entry) => `- ${entry}`).join("\n")}`;
}
function stampConfigVersion(cfg: ClawdbotConfig): ClawdbotConfig { function stampConfigVersion(cfg: ClawdbotConfig): ClawdbotConfig {
const now = new Date().toISOString(); const now = new Date().toISOString();
return { return {
@@ -231,56 +203,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const deps = normalizeDeps(overrides); const deps = normalizeDeps(overrides);
const configPath = resolveConfigPathForDeps(deps); const configPath = resolveConfigPathForDeps(deps);
const writeConfigFileSync = (cfg: ClawdbotConfig) => {
const dir = path.dirname(configPath);
deps.fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
.trimEnd()
.concat("\n");
const tmp = path.join(
dir,
`${path.basename(configPath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
deps.fs.writeFileSync(tmp, json, { encoding: "utf-8", mode: 0o600 });
if (deps.fs.existsSync(configPath)) {
rotateConfigBackupsSync(configPath, deps.fs);
try {
deps.fs.copyFileSync(configPath, `${configPath}.bak`);
} catch {
// best-effort
}
}
try {
deps.fs.renameSync(tmp, configPath);
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "EPERM" || code === "EEXIST") {
deps.fs.copyFileSync(tmp, configPath);
try {
deps.fs.chmodSync(configPath, 0o600);
} catch {
// best-effort
}
try {
deps.fs.unlinkSync(tmp);
} catch {
// best-effort
}
return;
}
try {
deps.fs.unlinkSync(tmp);
} catch {
// best-effort
}
throw err;
}
};
function loadConfig(): ClawdbotConfig { function loadConfig(): ClawdbotConfig {
try { try {
if (!deps.fs.existsSync(configPath)) { if (!deps.fs.existsSync(configPath)) {
@@ -307,14 +229,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
// Substitute ${VAR} env var references // Substitute ${VAR} env var references
const substituted = resolveConfigEnvVars(resolved, deps.env); const substituted = resolveConfigEnvVars(resolved, deps.env);
const migrated = applyLegacyMigrations(substituted); const resolvedConfig = substituted;
let resolvedConfig = migrated.next ?? substituted;
const autoEnable = applyPluginAutoEnable({
config: coerceConfig(resolvedConfig),
env: deps.env,
});
resolvedConfig = autoEnable.config;
const migrationChanges = [...migrated.changes, ...autoEnable.changes];
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 validated = ClawdbotSchema.safeParse(resolvedConfig);
@@ -326,14 +241,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
return {}; return {};
} }
warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger); warnIfConfigFromFuture(validated.data as ClawdbotConfig, deps.logger);
if (migrationChanges.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrationChanges));
try {
writeConfigFileSync(resolvedConfig as ClawdbotConfig);
} catch (err) {
deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`);
}
}
const cfg = applyModelDefaults( const cfg = applyModelDefaults(
applyCompactionDefaults( applyCompactionDefaults(
applyContextPruningDefaults( applyContextPruningDefaults(
@@ -467,14 +374,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}; };
} }
const migrated = applyLegacyMigrations(substituted); const resolvedConfigRaw = substituted;
let resolvedConfigRaw = migrated.next ?? substituted;
const autoEnable = applyPluginAutoEnable({
config: coerceConfig(resolvedConfigRaw),
env: deps.env,
});
resolvedConfigRaw = autoEnable.config;
const migrationChanges = [...migrated.changes, ...autoEnable.changes];
const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw);
const validated = validateConfigObject(resolvedConfigRaw); const validated = validateConfigObject(resolvedConfigRaw);
@@ -493,13 +393,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
} }
warnIfConfigFromFuture(validated.config, deps.logger); warnIfConfigFromFuture(validated.config, deps.logger);
if (migrationChanges.length > 0) {
deps.logger.warn(formatLegacyMigrationLog(migrationChanges));
await writeConfigFile(validated.config).catch((err) => {
deps.logger.warn(`Failed to write migrated config at ${configPath}: ${String(err)}`);
});
}
return { return {
path: configPath, path: configPath,
exists: true, exists: true,

View File

@@ -20,21 +20,25 @@ export const AgentDefaultsSchema = z
primary: z.string().optional(), primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(), fallbacks: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
imageModel: z imageModel: z
.object({ .object({
primary: z.string().optional(), primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(), fallbacks: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
models: z models: z
.record( .record(
z.string(), z.string(),
z.object({ z
alias: z.string().optional(), .object({
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ alias: z.string().optional(),
params: z.record(z.string(), z.unknown()).optional(), /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
}), params: z.record(z.string(), z.unknown()).optional(),
})
.strict(),
) )
.optional(), .optional(),
workspace: z.string().optional(), workspace: z.string().optional(),
@@ -62,6 +66,7 @@ export const AgentDefaultsSchema = z
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
softTrim: z softTrim: z
.object({ .object({
@@ -69,14 +74,17 @@ export const AgentDefaultsSchema = z
headChars: z.number().int().nonnegative().optional(), headChars: z.number().int().nonnegative().optional(),
tailChars: z.number().int().nonnegative().optional(), tailChars: z.number().int().nonnegative().optional(),
}) })
.strict()
.optional(), .optional(),
hardClear: z hardClear: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
placeholder: z.string().optional(), placeholder: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
compaction: z compaction: z
.object({ .object({
@@ -89,8 +97,10 @@ export const AgentDefaultsSchema = z
prompt: z.string().optional(), prompt: z.string().optional(),
systemPrompt: z.string().optional(), systemPrompt: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
thinkingDefault: z thinkingDefault: z
.union([ .union([
@@ -132,10 +142,11 @@ export const AgentDefaultsSchema = z
z.object({ z.object({
primary: z.string().optional(), primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(), fallbacks: z.array(z.string()).optional(),
}), }).strict(),
]) ])
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
sandbox: z sandbox: z
.object({ .object({
@@ -149,6 +160,8 @@ export const AgentDefaultsSchema = z
browser: SandboxBrowserSchema, browser: SandboxBrowserSchema,
prune: SandboxPruneSchema, prune: SandboxPruneSchema,
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();

View File

@@ -30,6 +30,7 @@ export const HeartbeatSchema = z
prompt: z.string().optional(), prompt: z.string().optional(),
ackMaxChars: z.number().int().nonnegative().optional(), ackMaxChars: z.number().int().nonnegative().optional(),
}) })
.strict()
.superRefine((val, ctx) => { .superRefine((val, ctx) => {
if (!val.every) return; if (!val.every) return;
try { try {
@@ -69,7 +70,7 @@ export const SandboxDockerSchema = z
z.object({ z.object({
soft: z.number().int().nonnegative().optional(), soft: z.number().int().nonnegative().optional(),
hard: z.number().int().nonnegative().optional(), hard: z.number().int().nonnegative().optional(),
}), }).strict(),
]), ]),
) )
.optional(), .optional(),
@@ -79,6 +80,7 @@ export const SandboxDockerSchema = z
extraHosts: z.array(z.string()).optional(), extraHosts: z.array(z.string()).optional(),
binds: z.array(z.string()).optional(), binds: z.array(z.string()).optional(),
}) })
.strict()
.optional(); .optional();
export const SandboxBrowserSchema = z export const SandboxBrowserSchema = z
@@ -98,6 +100,7 @@ export const SandboxBrowserSchema = z
autoStart: z.boolean().optional(), autoStart: z.boolean().optional(),
autoStartTimeoutMs: z.number().int().positive().optional(), autoStartTimeoutMs: z.number().int().positive().optional(),
}) })
.strict()
.optional(); .optional();
export const SandboxPruneSchema = z export const SandboxPruneSchema = z
@@ -105,6 +108,7 @@ export const SandboxPruneSchema = z
idleHours: z.number().int().nonnegative().optional(), idleHours: z.number().int().nonnegative().optional(),
maxAgeDays: z.number().int().nonnegative().optional(), maxAgeDays: z.number().int().nonnegative().optional(),
}) })
.strict()
.optional(); .optional();
export const ToolPolicySchema = z export const ToolPolicySchema = z
@@ -112,6 +116,7 @@ export const ToolPolicySchema = z
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(), deny: z.array(z.string()).optional(),
}) })
.strict()
.optional(); .optional();
export const ToolsWebSearchSchema = z export const ToolsWebSearchSchema = z
@@ -123,6 +128,7 @@ export const ToolsWebSearchSchema = z
timeoutSeconds: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),
cacheTtlMinutes: z.number().nonnegative().optional(), cacheTtlMinutes: z.number().nonnegative().optional(),
}) })
.strict()
.optional(); .optional();
export const ToolsWebFetchSchema = z export const ToolsWebFetchSchema = z
@@ -133,6 +139,7 @@ export const ToolsWebFetchSchema = z
cacheTtlMinutes: z.number().nonnegative().optional(), cacheTtlMinutes: z.number().nonnegative().optional(),
userAgent: z.string().optional(), userAgent: z.string().optional(),
}) })
.strict()
.optional(); .optional();
export const ToolsWebSchema = z export const ToolsWebSchema = z
@@ -140,17 +147,20 @@ export const ToolsWebSchema = z
search: ToolsWebSearchSchema, search: ToolsWebSearchSchema,
fetch: ToolsWebFetchSchema, fetch: ToolsWebFetchSchema,
}) })
.strict()
.optional(); .optional();
export const ToolProfileSchema = z export const ToolProfileSchema = z
.union([z.literal("minimal"), z.literal("coding"), z.literal("messaging"), z.literal("full")]) .union([z.literal("minimal"), z.literal("coding"), z.literal("messaging"), z.literal("full")])
.optional(); .optional();
export const ToolPolicyWithProfileSchema = z.object({ export const ToolPolicyWithProfileSchema = z
allow: z.array(z.string()).optional(), .object({
deny: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
profile: ToolProfileSchema, deny: z.array(z.string()).optional(),
}); profile: ToolProfileSchema,
})
.strict();
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers). // Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
export const ElevatedAllowFromSchema = z export const ElevatedAllowFromSchema = z
@@ -169,6 +179,7 @@ export const AgentSandboxSchema = z
browser: SandboxBrowserSchema, browser: SandboxBrowserSchema,
prune: SandboxPruneSchema, prune: SandboxPruneSchema,
}) })
.strict()
.optional(); .optional();
export const AgentToolsSchema = z export const AgentToolsSchema = z
@@ -182,6 +193,7 @@ export const AgentToolsSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
allowFrom: ElevatedAllowFromSchema, allowFrom: ElevatedAllowFromSchema,
}) })
.strict()
.optional(), .optional(),
exec: z exec: z
.object({ .object({
@@ -199,15 +211,19 @@ export const AgentToolsSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
allowModels: z.array(z.string()).optional(), allowModels: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
sandbox: z sandbox: z
.object({ .object({
tools: ToolPolicySchema, tools: ToolPolicySchema,
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();
export const MemorySearchSchema = z export const MemorySearchSchema = z
@@ -218,6 +234,7 @@ export const MemorySearchSchema = z
.object({ .object({
sessionMemory: z.boolean().optional(), sessionMemory: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
provider: z.union([z.literal("openai"), z.literal("local"), z.literal("gemini")]).optional(), provider: z.union([z.literal("openai"), z.literal("local"), z.literal("gemini")]).optional(),
remote: z remote: z
@@ -233,8 +250,10 @@ export const MemorySearchSchema = z
pollIntervalMs: z.number().int().nonnegative().optional(), pollIntervalMs: z.number().int().nonnegative().optional(),
timeoutMinutes: z.number().int().positive().optional(), timeoutMinutes: z.number().int().positive().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
fallback: z fallback: z
.union([z.literal("openai"), z.literal("gemini"), z.literal("local"), z.literal("none")]) .union([z.literal("openai"), z.literal("gemini"), z.literal("local"), z.literal("none")])
@@ -245,6 +264,7 @@ export const MemorySearchSchema = z
modelPath: z.string().optional(), modelPath: z.string().optional(),
modelCacheDir: z.string().optional(), modelCacheDir: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
store: z store: z
.object({ .object({
@@ -255,14 +275,17 @@ export const MemorySearchSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
extensionPath: z.string().optional(), extensionPath: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
chunking: z chunking: z
.object({ .object({
tokens: z.number().int().positive().optional(), tokens: z.number().int().positive().optional(),
overlap: z.number().int().nonnegative().optional(), overlap: z.number().int().nonnegative().optional(),
}) })
.strict()
.optional(), .optional(),
sync: z sync: z
.object({ .object({
@@ -272,6 +295,7 @@ export const MemorySearchSchema = z
watchDebounceMs: z.number().int().nonnegative().optional(), watchDebounceMs: z.number().int().nonnegative().optional(),
intervalMinutes: z.number().int().nonnegative().optional(), intervalMinutes: z.number().int().nonnegative().optional(),
}) })
.strict()
.optional(), .optional(),
query: z query: z
.object({ .object({
@@ -284,25 +308,32 @@ export const MemorySearchSchema = z
textWeight: z.number().min(0).max(1).optional(), textWeight: z.number().min(0).max(1).optional(),
candidateMultiplier: z.number().int().positive().optional(), candidateMultiplier: z.number().int().positive().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
cache: z cache: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
maxEntries: z.number().int().positive().optional(), maxEntries: z.number().int().positive().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();
export const AgentModelSchema = z.union([ export const AgentModelSchema = z.union([
z.string(), z.string(),
z.object({ z
primary: z.string().optional(), .object({
fallbacks: z.array(z.string()).optional(), primary: z.string().optional(),
}), fallbacks: z.array(z.string()).optional(),
})
.strict(),
]); ]);
export const AgentEntrySchema = z.object({ export const AgentEntrySchema = z
.object({
id: z.string(), id: z.string(),
default: z.boolean().optional(), default: z.boolean().optional(),
name: z.string().optional(), name: z.string().optional(),
@@ -323,14 +354,16 @@ export const AgentEntrySchema = z.object({
z.object({ z.object({
primary: z.string().optional(), primary: z.string().optional(),
fallbacks: z.array(z.string()).optional(), fallbacks: z.array(z.string()).optional(),
}), }).strict(),
]) ])
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
sandbox: AgentSandboxSchema, sandbox: AgentSandboxSchema,
tools: AgentToolsSchema, tools: AgentToolsSchema,
}); })
.strict();
export const ToolsSchema = z export const ToolsSchema = z
.object({ .object({
@@ -353,27 +386,33 @@ export const ToolsSchema = z
prefix: z.string().optional(), prefix: z.string().optional(),
suffix: z.string().optional(), suffix: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
broadcast: z broadcast: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
agentToAgent: z agentToAgent: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
elevated: z elevated: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
allowFrom: ElevatedAllowFromSchema, allowFrom: ElevatedAllowFromSchema,
}) })
.strict()
.optional(), .optional(),
exec: z exec: z
.object({ .object({
@@ -391,18 +430,23 @@ export const ToolsSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
allowModels: z.array(z.string()).optional(), allowModels: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
subagents: z subagents: z
.object({ .object({
tools: ToolPolicySchema, tools: ToolPolicySchema,
}) })
.strict()
.optional(), .optional(),
sandbox: z sandbox: z
.object({ .object({
tools: ToolPolicySchema, tools: ToolPolicySchema,
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();

View File

@@ -8,25 +8,31 @@ export const AgentsSchema = z
defaults: z.lazy(() => AgentDefaultsSchema).optional(), defaults: z.lazy(() => AgentDefaultsSchema).optional(),
list: z.array(AgentEntrySchema).optional(), list: z.array(AgentEntrySchema).optional(),
}) })
.strict()
.optional(); .optional();
export const BindingsSchema = z export const BindingsSchema = z
.array( .array(
z.object({ z
agentId: z.string(), .object({
match: z.object({ agentId: z.string(),
channel: z.string(), match: z
accountId: z.string().optional(),
peer: z
.object({ .object({
kind: z.union([z.literal("dm"), z.literal("group"), z.literal("channel")]), channel: z.string(),
id: z.string(), accountId: z.string().optional(),
peer: z
.object({
kind: z.union([z.literal("dm"), z.literal("group"), z.literal("channel")]),
id: z.string(),
})
.strict()
.optional(),
guildId: z.string().optional(),
teamId: z.string().optional(),
}) })
.optional(), .strict(),
guildId: z.string().optional(), })
teamId: z.string().optional(), .strict(),
}),
}),
) )
.optional(); .optional();
@@ -43,4 +49,5 @@ export const AudioSchema = z
.object({ .object({
transcription: TranscribeAudioSchema, transcription: TranscribeAudioSchema,
}) })
.strict()
.optional(); .optional();

View File

@@ -19,40 +19,48 @@ export const ModelCompatSchema = z
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();
export const ModelDefinitionSchema = z.object({ export const ModelDefinitionSchema = z
.object({
id: z.string().min(1), id: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
api: ModelApiSchema.optional(), api: ModelApiSchema.optional(),
reasoning: z.boolean(), reasoning: z.boolean(),
input: z.array(z.union([z.literal("text"), z.literal("image")])), input: z.array(z.union([z.literal("text"), z.literal("image")])),
cost: z.object({ cost: z
input: z.number(), .object({
output: z.number(), input: z.number(),
cacheRead: z.number(), output: z.number(),
cacheWrite: z.number(), cacheRead: z.number(),
}), cacheWrite: z.number(),
})
.strict(),
contextWindow: z.number().positive(), contextWindow: z.number().positive(),
maxTokens: z.number().positive(), maxTokens: z.number().positive(),
headers: z.record(z.string(), z.string()).optional(), headers: z.record(z.string(), z.string()).optional(),
compat: ModelCompatSchema, compat: ModelCompatSchema,
}); })
.strict();
export const ModelProviderSchema = z.object({ export const ModelProviderSchema = z
.object({
baseUrl: z.string().min(1), baseUrl: z.string().min(1),
apiKey: z.string().optional(), apiKey: z.string().optional(),
api: ModelApiSchema.optional(), api: ModelApiSchema.optional(),
headers: z.record(z.string(), z.string()).optional(), headers: z.record(z.string(), z.string()).optional(),
authHeader: z.boolean().optional(), authHeader: z.boolean().optional(),
models: z.array(ModelDefinitionSchema), models: z.array(ModelDefinitionSchema),
}); })
.strict();
export const ModelsConfigSchema = z export const ModelsConfigSchema = z
.object({ .object({
mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
providers: z.record(z.string(), ModelProviderSchema).optional(), providers: z.record(z.string(), ModelProviderSchema).optional(),
}) })
.strict()
.optional(); .optional();
export const GroupChatSchema = z export const GroupChatSchema = z
@@ -60,11 +68,14 @@ export const GroupChatSchema = z
mentionPatterns: z.array(z.string()).optional(), mentionPatterns: z.array(z.string()).optional(),
historyLimit: z.number().int().positive().optional(), historyLimit: z.number().int().positive().optional(),
}) })
.strict()
.optional(); .optional();
export const DmConfigSchema = z.object({ export const DmConfigSchema = z
historyLimit: z.number().int().min(0).optional(), .object({
}); historyLimit: z.number().int().min(0).optional(),
})
.strict();
export const IdentitySchema = z export const IdentitySchema = z
.object({ .object({
@@ -72,6 +83,7 @@ export const IdentitySchema = z
theme: z.string().optional(), theme: z.string().optional(),
emoji: z.string().optional(), emoji: z.string().optional(),
}) })
.strict()
.optional(); .optional();
export const QueueModeSchema = z.union([ export const QueueModeSchema = z.union([
@@ -98,51 +110,59 @@ export const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]); export const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
export const BlockStreamingCoalesceSchema = z.object({ export const BlockStreamingCoalesceSchema = z
minChars: z.number().int().positive().optional(), .object({
maxChars: z.number().int().positive().optional(), minChars: z.number().int().positive().optional(),
idleMs: z.number().int().nonnegative().optional(), maxChars: z.number().int().positive().optional(),
}); idleMs: z.number().int().nonnegative().optional(),
})
.strict();
export const BlockStreamingChunkSchema = z.object({ export const BlockStreamingChunkSchema = z
minChars: z.number().int().positive().optional(), .object({
maxChars: z.number().int().positive().optional(), minChars: z.number().int().positive().optional(),
breakPreference: z maxChars: z.number().int().positive().optional(),
.union([z.literal("paragraph"), z.literal("newline"), z.literal("sentence")]) breakPreference: z
.optional(), .union([z.literal("paragraph"), z.literal("newline"), z.literal("sentence")])
}); .optional(),
})
.strict();
export const HumanDelaySchema = z.object({ export const HumanDelaySchema = z
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), .object({
minMs: z.number().int().nonnegative().optional(), mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),
maxMs: z.number().int().nonnegative().optional(), minMs: z.number().int().nonnegative().optional(),
}); maxMs: z.number().int().nonnegative().optional(),
})
.strict();
export const CliBackendSchema = z.object({ export const CliBackendSchema = z
command: z.string(), .object({
args: z.array(z.string()).optional(), command: z.string(),
output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), args: z.array(z.string()).optional(),
resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
maxPromptArgChars: z.number().int().positive().optional(), input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
env: z.record(z.string(), z.string()).optional(), maxPromptArgChars: z.number().int().positive().optional(),
clearEnv: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(),
modelArg: z.string().optional(), clearEnv: z.array(z.string()).optional(),
modelAliases: z.record(z.string(), z.string()).optional(), modelArg: z.string().optional(),
sessionArg: z.string().optional(), modelAliases: z.record(z.string(), z.string()).optional(),
sessionArgs: z.array(z.string()).optional(), sessionArg: z.string().optional(),
resumeArgs: z.array(z.string()).optional(), sessionArgs: z.array(z.string()).optional(),
sessionMode: z.union([z.literal("always"), z.literal("existing"), z.literal("none")]).optional(), resumeArgs: z.array(z.string()).optional(),
sessionIdFields: z.array(z.string()).optional(), sessionMode: z.union([z.literal("always"), z.literal("existing"), z.literal("none")]).optional(),
systemPromptArg: z.string().optional(), sessionIdFields: z.array(z.string()).optional(),
systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(), systemPromptArg: z.string().optional(),
systemPromptWhen: z systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(),
.union([z.literal("first"), z.literal("always"), z.literal("never")]) systemPromptWhen: z
.optional(), .union([z.literal("first"), z.literal("always"), z.literal("never")])
imageArg: z.string().optional(), .optional(),
imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), imageArg: z.string().optional(),
serialize: z.boolean().optional(), imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(),
}); serialize: z.boolean().optional(),
})
.strict();
export const normalizeAllowFrom = (values?: Array<string | number>): string[] => export const normalizeAllowFrom = (values?: Array<string | number>): string[] =>
(values ?? []).map((v) => String(v).trim()).filter(Boolean); (values ?? []).map((v) => String(v).trim()).filter(Boolean);
@@ -173,6 +193,7 @@ export const RetryConfigSchema = z
maxDelayMs: z.number().int().min(0).optional(), maxDelayMs: z.number().int().min(0).optional(),
jitter: z.number().min(0).max(1).optional(), jitter: z.number().min(0).max(1).optional(),
}) })
.strict()
.optional(); .optional();
export const QueueModeBySurfaceSchema = z export const QueueModeBySurfaceSchema = z
@@ -186,6 +207,7 @@ export const QueueModeBySurfaceSchema = z
msteams: QueueModeSchema.optional(), msteams: QueueModeSchema.optional(),
webchat: QueueModeSchema.optional(), webchat: QueueModeSchema.optional(),
}) })
.strict()
.optional(); .optional();
export const DebounceMsBySurfaceSchema = z export const DebounceMsBySurfaceSchema = z
@@ -199,6 +221,7 @@ export const DebounceMsBySurfaceSchema = z
msteams: z.number().int().nonnegative().optional(), msteams: z.number().int().nonnegative().optional(),
webchat: z.number().int().nonnegative().optional(), webchat: z.number().int().nonnegative().optional(),
}) })
.strict()
.optional(); .optional();
export const QueueSchema = z export const QueueSchema = z
@@ -209,6 +232,7 @@ export const QueueSchema = z
cap: z.number().int().positive().optional(), cap: z.number().int().positive().optional(),
drop: QueueDropSchema.optional(), drop: QueueDropSchema.optional(),
}) })
.strict()
.optional(); .optional();
export const InboundDebounceSchema = z export const InboundDebounceSchema = z
@@ -216,6 +240,7 @@ export const InboundDebounceSchema = z
debounceMs: z.number().int().nonnegative().optional(), debounceMs: z.number().int().nonnegative().optional(),
byChannel: DebounceMsBySurfaceSchema, byChannel: DebounceMsBySurfaceSchema,
}) })
.strict()
.optional(); .optional();
export const TranscribeAudioSchema = z export const TranscribeAudioSchema = z
@@ -232,6 +257,7 @@ export const TranscribeAudioSchema = z
}), }),
timeoutSeconds: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),
}) })
.strict()
.optional(); .optional();
export const HexColorSchema = z.string().regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); export const HexColorSchema = z.string().regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
@@ -245,21 +271,25 @@ export const MediaUnderstandingScopeSchema = z
default: z.union([z.literal("allow"), z.literal("deny")]).optional(), default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
rules: z rules: z
.array( .array(
z.object({ z
action: z.union([z.literal("allow"), z.literal("deny")]), .object({
match: z action: z.union([z.literal("allow"), z.literal("deny")]),
.object({ match: z
channel: z.string().optional(), .object({
chatType: z channel: z.string().optional(),
.union([z.literal("direct"), z.literal("group"), z.literal("channel")]) chatType: z
.optional(), .union([z.literal("direct"), z.literal("group"), z.literal("channel")])
keyPrefix: z.string().optional(), .optional(),
}) keyPrefix: z.string().optional(),
.optional(), })
}), .strict()
.optional(),
})
.strict(),
) )
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();
export const MediaUnderstandingCapabilitiesSchema = z export const MediaUnderstandingCapabilitiesSchema = z
@@ -274,6 +304,7 @@ export const MediaUnderstandingAttachmentsSchema = z
.union([z.literal("first"), z.literal("last"), z.literal("path"), z.literal("url")]) .union([z.literal("first"), z.literal("last"), z.literal("path"), z.literal("url")])
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();
const DeepgramAudioSchema = z const DeepgramAudioSchema = z
@@ -282,6 +313,7 @@ const DeepgramAudioSchema = z
punctuate: z.boolean().optional(), punctuate: z.boolean().optional(),
smartFormat: z.boolean().optional(), smartFormat: z.boolean().optional(),
}) })
.strict()
.optional(); .optional();
const ProviderOptionValueSchema = z.union([z.string(), z.number(), z.boolean()]); const ProviderOptionValueSchema = z.union([z.string(), z.number(), z.boolean()]);
@@ -309,6 +341,7 @@ export const MediaUnderstandingModelSchema = z
profile: z.string().optional(), profile: z.string().optional(),
preferredProfile: z.string().optional(), preferredProfile: z.string().optional(),
}) })
.strict()
.optional(); .optional();
export const ToolsMediaUnderstandingSchema = z export const ToolsMediaUnderstandingSchema = z
@@ -327,6 +360,7 @@ export const ToolsMediaUnderstandingSchema = z
attachments: MediaUnderstandingAttachmentsSchema, attachments: MediaUnderstandingAttachmentsSchema,
models: z.array(MediaUnderstandingModelSchema).optional(), models: z.array(MediaUnderstandingModelSchema).optional(),
}) })
.strict()
.optional(); .optional();
export const ToolsMediaSchema = z export const ToolsMediaSchema = z
@@ -337,6 +371,7 @@ export const ToolsMediaSchema = z
audio: ToolsMediaUnderstandingSchema.optional(), audio: ToolsMediaUnderstandingSchema.optional(),
video: ToolsMediaUnderstandingSchema.optional(), video: ToolsMediaUnderstandingSchema.optional(),
}) })
.strict()
.optional(); .optional();
export const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]); export const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]);
@@ -346,4 +381,5 @@ export const ProviderCommandsSchema = z
native: NativeCommandsSettingSchema.optional(), native: NativeCommandsSettingSchema.optional(),
nativeSkills: NativeCommandsSettingSchema.optional(), nativeSkills: NativeCommandsSettingSchema.optional(),
}) })
.strict()
.optional(); .optional();

View File

@@ -37,22 +37,26 @@ export const HookMappingSchema = z
module: z.string(), module: z.string(),
export: z.string().optional(), export: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();
export const InternalHookHandlerSchema = z.object({ export const InternalHookHandlerSchema = z
event: z.string(), .object({
module: z.string(), event: z.string(),
export: z.string().optional(), module: z.string(),
}); export: z.string().optional(),
})
.strict();
const HookConfigSchema = z const HookConfigSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(), env: z.record(z.string(), z.string()).optional(),
}) })
.passthrough(); .strict();
const HookInstallRecordSchema = z const HookInstallRecordSchema = z
.object({ .object({
@@ -64,7 +68,7 @@ const HookInstallRecordSchema = z
installedAt: z.string().optional(), installedAt: z.string().optional(),
hooks: z.array(z.string()).optional(), hooks: z.array(z.string()).optional(),
}) })
.passthrough(); .strict();
export const InternalHooksSchema = z export const InternalHooksSchema = z
.object({ .object({
@@ -75,9 +79,11 @@ export const InternalHooksSchema = z
.object({ .object({
extraDirs: z.array(z.string()).optional(), extraDirs: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
installs: z.record(z.string(), HookInstallRecordSchema).optional(), installs: z.record(z.string(), HookInstallRecordSchema).optional(),
}) })
.strict()
.optional(); .optional();
export const HooksGmailSchema = z export const HooksGmailSchema = z
@@ -97,6 +103,7 @@ export const HooksGmailSchema = z
port: z.number().int().positive().optional(), port: z.number().int().positive().optional(),
path: z.string().optional(), path: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
tailscale: z tailscale: z
.object({ .object({
@@ -104,6 +111,7 @@ export const HooksGmailSchema = z
path: z.string().optional(), path: z.string().optional(),
target: z.string().optional(), target: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
model: z.string().optional(), model: z.string().optional(),
thinking: z thinking: z
@@ -116,4 +124,5 @@ export const HooksGmailSchema = z
]) ])
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();

View File

@@ -23,32 +23,40 @@ const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "a
const TelegramCapabilitiesSchema = z.union([ const TelegramCapabilitiesSchema = z.union([
z.array(z.string()), z.array(z.string()),
z.object({ z
inlineButtons: TelegramInlineButtonsScopeSchema.optional(), .object({
}), inlineButtons: TelegramInlineButtonsScopeSchema.optional(),
})
.strict(),
]); ]);
export const TelegramTopicSchema = z.object({ export const TelegramTopicSchema = z
requireMention: z.boolean().optional(), .object({
skills: z.array(z.string()).optional(), requireMention: z.boolean().optional(),
enabled: z.boolean().optional(), skills: z.array(z.string()).optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(), enabled: z.boolean().optional(),
systemPrompt: z.string().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
}); systemPrompt: z.string().optional(),
})
.strict();
export const TelegramGroupSchema = z.object({ export const TelegramGroupSchema = z
requireMention: z.boolean().optional(), .object({
skills: z.array(z.string()).optional(), requireMention: z.boolean().optional(),
enabled: z.boolean().optional(), skills: z.array(z.string()).optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(), enabled: z.boolean().optional(),
systemPrompt: z.string().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), systemPrompt: z.string().optional(),
}); topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
})
.strict();
const TelegramCustomCommandSchema = z.object({ const TelegramCustomCommandSchema = z
command: z.string().transform(normalizeTelegramCommandName), .object({
description: z.string().transform(normalizeTelegramCommandDescription), command: z.string().transform(normalizeTelegramCommandName),
}); description: z.string().transform(normalizeTelegramCommandDescription),
})
.strict();
const validateTelegramCustomCommands = ( const validateTelegramCustomCommands = (
value: { customCommands?: Array<{ command?: string; description?: string }> }, value: { customCommands?: Array<{ command?: string; description?: string }> },
@@ -69,7 +77,8 @@ const validateTelegramCustomCommands = (
} }
}; };
export const TelegramAccountSchemaBase = z.object({ export const TelegramAccountSchemaBase = z
.object({
name: z.string().optional(), name: z.string().optional(),
capabilities: TelegramCapabilitiesSchema.optional(), capabilities: TelegramCapabilitiesSchema.optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -105,10 +114,12 @@ export const TelegramAccountSchemaBase = z.object({
sendMessage: z.boolean().optional(), sendMessage: z.boolean().optional(),
deleteMessage: z.boolean().optional(), deleteMessage: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionNotifications: z.enum(["off", "own", "all"]).optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
}); })
.strict();
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({ requireOpenAllowFrom({
@@ -144,6 +155,7 @@ export const DiscordDmSchema = z
groupEnabled: z.boolean().optional(), groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
}) })
.strict()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
requireOpenAllowFrom({ requireOpenAllowFrom({
policy: value.policy, policy: value.policy,
@@ -155,25 +167,30 @@ export const DiscordDmSchema = z
}); });
}); });
export const DiscordGuildChannelSchema = z.object({ export const DiscordGuildChannelSchema = z
allow: z.boolean().optional(), .object({
requireMention: z.boolean().optional(), allow: z.boolean().optional(),
skills: z.array(z.string()).optional(), requireMention: z.boolean().optional(),
enabled: z.boolean().optional(), skills: z.array(z.string()).optional(),
users: z.array(z.union([z.string(), z.number()])).optional(), enabled: z.boolean().optional(),
systemPrompt: z.string().optional(), users: z.array(z.union([z.string(), z.number()])).optional(),
autoThread: z.boolean().optional(), systemPrompt: z.string().optional(),
}); autoThread: z.boolean().optional(),
})
.strict();
export const DiscordGuildSchema = z.object({ export const DiscordGuildSchema = z
slug: z.string().optional(), .object({
requireMention: z.boolean().optional(), slug: z.string().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), requireMention: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(), users: z.array(z.union([z.string(), z.number()])).optional(),
}); channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(),
})
.strict();
export const DiscordAccountSchema = z.object({ export const DiscordAccountSchema = z
.object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -212,11 +229,13 @@ export const DiscordAccountSchema = z.object({
moderation: z.boolean().optional(), moderation: z.boolean().optional(),
channels: z.boolean().optional(), channels: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
replyToMode: ReplyToModeSchema.optional(), replyToMode: ReplyToModeSchema.optional(),
dm: DiscordDmSchema.optional(), dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
}); })
.strict();
export const DiscordConfigSchema = DiscordAccountSchema.extend({ export const DiscordConfigSchema = DiscordAccountSchema.extend({
accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(), accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(),
@@ -230,6 +249,7 @@ export const SlackDmSchema = z
groupEnabled: z.boolean().optional(), groupEnabled: z.boolean().optional(),
groupChannels: z.array(z.union([z.string(), z.number()])).optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
}) })
.strict()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
requireOpenAllowFrom({ requireOpenAllowFrom({
policy: value.policy, policy: value.policy,
@@ -241,22 +261,27 @@ export const SlackDmSchema = z
}); });
}); });
export const SlackChannelSchema = z.object({ export const SlackChannelSchema = z
enabled: z.boolean().optional(), .object({
allow: z.boolean().optional(), enabled: z.boolean().optional(),
requireMention: z.boolean().optional(), allow: z.boolean().optional(),
allowBots: z.boolean().optional(), requireMention: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(), allowBots: z.boolean().optional(),
skills: z.array(z.string()).optional(), users: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(), skills: z.array(z.string()).optional(),
}); systemPrompt: z.string().optional(),
})
.strict();
export const SlackThreadSchema = z.object({ export const SlackThreadSchema = z
historyScope: z.enum(["thread", "channel"]).optional(), .object({
inheritParent: z.boolean().optional(), historyScope: z.enum(["thread", "channel"]).optional(),
}); inheritParent: z.boolean().optional(),
})
.strict();
export const SlackAccountSchema = z.object({ export const SlackAccountSchema = z
.object({
name: z.string().optional(), name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(), mode: z.enum(["socket", "http"]).optional(),
signingSecret: z.string().optional(), signingSecret: z.string().optional(),
@@ -294,6 +319,7 @@ export const SlackAccountSchema = z.object({
channelInfo: z.boolean().optional(), channelInfo: z.boolean().optional(),
emojiList: z.boolean().optional(), emojiList: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
slashCommand: z slashCommand: z
.object({ .object({
@@ -302,10 +328,12 @@ export const SlackAccountSchema = z.object({
sessionPrefix: z.string().optional(), sessionPrefix: z.string().optional(),
ephemeral: z.boolean().optional(), ephemeral: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
dm: SlackDmSchema.optional(), dm: SlackDmSchema.optional(),
channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), channels: z.record(z.string(), SlackChannelSchema.optional()).optional(),
}); })
.strict();
export const SlackConfigSchema = SlackAccountSchema.extend({ export const SlackConfigSchema = SlackAccountSchema.extend({
mode: z.enum(["socket", "http"]).optional().default("socket"), mode: z.enum(["socket", "http"]).optional().default("socket"),
@@ -339,7 +367,8 @@ export const SlackConfigSchema = SlackAccountSchema.extend({
} }
}); });
export const SignalAccountSchemaBase = z.object({ export const SignalAccountSchemaBase = z
.object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -367,7 +396,8 @@ export const SignalAccountSchemaBase = z.object({
mediaMaxMb: z.number().int().positive().optional(), mediaMaxMb: z.number().int().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
}); })
.strict();
export const SignalAccountSchema = SignalAccountSchemaBase.superRefine((value, ctx) => { export const SignalAccountSchema = SignalAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({ requireOpenAllowFrom({
@@ -391,7 +421,8 @@ export const SignalConfigSchema = SignalAccountSchemaBase.extend({
}); });
}); });
export const IMessageAccountSchemaBase = z.object({ export const IMessageAccountSchemaBase = z
.object({
name: z.string().optional(), name: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -420,10 +451,12 @@ export const IMessageAccountSchemaBase = z.object({
.object({ .object({
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
) )
.optional(), .optional(),
}); })
.strict();
export const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine((value, ctx) => { export const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({ requireOpenAllowFrom({
@@ -449,16 +482,20 @@ export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
}); });
}); });
export const MSTeamsChannelSchema = z.object({ export const MSTeamsChannelSchema = z
requireMention: z.boolean().optional(), .object({
replyStyle: MSTeamsReplyStyleSchema.optional(), requireMention: z.boolean().optional(),
}); replyStyle: MSTeamsReplyStyleSchema.optional(),
})
.strict();
export const MSTeamsTeamSchema = z.object({ export const MSTeamsTeamSchema = z
requireMention: z.boolean().optional(), .object({
replyStyle: MSTeamsReplyStyleSchema.optional(), requireMention: z.boolean().optional(),
channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(), replyStyle: MSTeamsReplyStyleSchema.optional(),
}); channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(),
})
.strict();
export const MSTeamsConfigSchema = z export const MSTeamsConfigSchema = z
.object({ .object({
@@ -473,6 +510,7 @@ export const MSTeamsConfigSchema = z
port: z.number().int().positive().optional(), port: z.number().int().positive().optional(),
path: z.string().optional(), path: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
@@ -488,6 +526,7 @@ export const MSTeamsConfigSchema = z
replyStyle: MSTeamsReplyStyleSchema.optional(), replyStyle: MSTeamsReplyStyleSchema.optional(),
teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(), teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(),
}) })
.strict()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
requireOpenAllowFrom({ requireOpenAllowFrom({
policy: value.dmPolicy, policy: value.dmPolicy,

View File

@@ -36,6 +36,7 @@ export const WhatsAppAccountSchema = z
.object({ .object({
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
) )
.optional(), .optional(),
@@ -45,9 +46,11 @@ export const WhatsAppAccountSchema = z
direct: z.boolean().optional().default(true), direct: z.boolean().optional().default(true),
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"), group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
}) })
.strict()
.optional(), .optional(),
debounceMs: z.number().int().nonnegative().optional().default(0), debounceMs: z.number().int().nonnegative().optional().default(0),
}) })
.strict()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return; if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
@@ -84,6 +87,7 @@ export const WhatsAppConfigSchema = z
sendMessage: z.boolean().optional(), sendMessage: z.boolean().optional(),
polls: z.boolean().optional(), polls: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
groups: z groups: z
.record( .record(
@@ -92,6 +96,7 @@ export const WhatsAppConfigSchema = z
.object({ .object({
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
) )
.optional(), .optional(),
@@ -101,9 +106,11 @@ export const WhatsAppConfigSchema = z
direct: z.boolean().optional().default(true), direct: z.boolean().optional().default(true),
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"), group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
}) })
.strict()
.optional(), .optional(),
debounceMs: z.number().int().nonnegative().optional().default(0), debounceMs: z.number().int().nonnegative().optional().default(0),
}) })
.strict()
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return; if (value.dmPolicy !== "open") return;
const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);

View File

@@ -20,6 +20,7 @@ export const ChannelsSchema = z
.object({ .object({
groupPolicy: GroupPolicySchema.optional(), groupPolicy: GroupPolicySchema.optional(),
}) })
.strict()
.optional(), .optional(),
whatsapp: WhatsAppConfigSchema.optional(), whatsapp: WhatsAppConfigSchema.optional(),
telegram: TelegramConfigSchema.optional(), telegram: TelegramConfigSchema.optional(),
@@ -29,5 +30,5 @@ export const ChannelsSchema = z
imessage: IMessageConfigSchema.optional(), imessage: IMessageConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(),
}) })
.catchall(z.unknown()) .strict()
.optional(); .optional();

View File

@@ -7,11 +7,13 @@ import {
QueueSchema, QueueSchema,
} from "./zod-schema.core.js"; } from "./zod-schema.core.js";
const SessionResetConfigSchema = z.object({ const SessionResetConfigSchema = z
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), .object({
atHour: z.number().int().min(0).max(23).optional(), mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
idleMinutes: z.number().int().positive().optional(), atHour: z.number().int().min(0).max(23).optional(),
}); idleMinutes: z.number().int().positive().optional(),
})
.strict();
export const SessionSchema = z export const SessionSchema = z
.object({ .object({
@@ -30,6 +32,7 @@ export const SessionSchema = z
group: SessionResetConfigSchema.optional(), group: SessionResetConfigSchema.optional(),
thread: SessionResetConfigSchema.optional(), thread: SessionResetConfigSchema.optional(),
}) })
.strict()
.optional(), .optional(),
store: z.string().optional(), store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(),
@@ -47,28 +50,34 @@ export const SessionSchema = z
default: z.union([z.literal("allow"), z.literal("deny")]).optional(), default: z.union([z.literal("allow"), z.literal("deny")]).optional(),
rules: z rules: z
.array( .array(
z.object({ z
action: z.union([z.literal("allow"), z.literal("deny")]), .object({
match: z action: z.union([z.literal("allow"), z.literal("deny")]),
.object({ match: z
channel: z.string().optional(), .object({
chatType: z channel: z.string().optional(),
.union([z.literal("direct"), z.literal("group"), z.literal("channel")]) chatType: z
.optional(), .union([z.literal("direct"), z.literal("group"), z.literal("channel")])
keyPrefix: z.string().optional(), .optional(),
}) keyPrefix: z.string().optional(),
.optional(), })
}), .strict()
.optional(),
})
.strict(),
) )
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
agentToAgent: z agentToAgent: z
.object({ .object({
maxPingPongTurns: z.number().int().min(0).max(5).optional(), maxPingPongTurns: z.number().int().min(0).max(5).optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(); .optional();
export const MessagesSchema = z export const MessagesSchema = z
@@ -82,6 +91,7 @@ export const MessagesSchema = z
ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(), ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(),
removeAckAfterReply: z.boolean().optional(), removeAckAfterReply: z.boolean().optional(),
}) })
.strict()
.optional(); .optional();
export const CommandsSchema = z export const CommandsSchema = z
@@ -96,5 +106,6 @@ export const CommandsSchema = z
restart: z.boolean().optional(), restart: z.boolean().optional(),
useAccessGroups: z.boolean().optional(), useAccessGroups: z.boolean().optional(),
}) })
.strict()
.optional() .optional()
.default({ native: "auto", nativeSkills: "auto" }); .default({ native: "auto", nativeSkills: "auto" });

View File

@@ -13,6 +13,7 @@ export const ClawdbotSchema = z
lastTouchedVersion: z.string().optional(), lastTouchedVersion: z.string().optional(),
lastTouchedAt: z.string().optional(), lastTouchedAt: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
env: z env: z
.object({ .object({
@@ -21,6 +22,7 @@ export const ClawdbotSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
timeoutMs: z.number().int().nonnegative().optional(), timeoutMs: z.number().int().nonnegative().optional(),
}) })
.strict()
.optional(), .optional(),
vars: z.record(z.string(), z.string()).optional(), vars: z.record(z.string(), z.string()).optional(),
}) })
@@ -34,6 +36,7 @@ export const ClawdbotSchema = z
lastRunCommand: z.string().optional(), lastRunCommand: z.string().optional(),
lastRunMode: z.union([z.literal("local"), z.literal("remote")]).optional(), lastRunMode: z.union([z.literal("local"), z.literal("remote")]).optional(),
}) })
.strict()
.optional(), .optional(),
logging: z logging: z
.object({ .object({
@@ -66,12 +69,14 @@ export const ClawdbotSchema = z
redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(), redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(),
redactPatterns: z.array(z.string()).optional(), redactPatterns: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
update: z update: z
.object({ .object({
channel: z.union([z.literal("stable"), z.literal("beta")]).optional(), channel: z.union([z.literal("stable"), z.literal("beta")]).optional(),
checkOnStart: z.boolean().optional(), checkOnStart: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
browser: z browser: z
.object({ .object({
@@ -99,28 +104,33 @@ export const ClawdbotSchema = z
driver: z.union([z.literal("clawd"), z.literal("extension")]).optional(), driver: z.union([z.literal("clawd"), z.literal("extension")]).optional(),
color: HexColorSchema, color: HexColorSchema,
}) })
.strict()
.refine((value) => value.cdpPort || value.cdpUrl, { .refine((value) => value.cdpPort || value.cdpUrl, {
message: "Profile must set cdpPort or cdpUrl", message: "Profile must set cdpPort or cdpUrl",
}), }),
) )
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
ui: z ui: z
.object({ .object({
seamColor: HexColorSchema.optional(), seamColor: HexColorSchema.optional(),
}) })
.strict()
.optional(), .optional(),
auth: z auth: z
.object({ .object({
profiles: z profiles: z
.record( .record(
z.string(), z.string(),
z.object({ z
provider: z.string(), .object({
mode: z.union([z.literal("api_key"), z.literal("oauth"), z.literal("token")]), provider: z.string(),
email: z.string().optional(), mode: z.union([z.literal("api_key"), z.literal("oauth"), z.literal("token")]),
}), email: z.string().optional(),
})
.strict(),
) )
.optional(), .optional(),
order: z.record(z.string(), z.array(z.string())).optional(), order: z.record(z.string(), z.array(z.string())).optional(),
@@ -131,8 +141,10 @@ export const ClawdbotSchema = z
billingMaxHours: z.number().positive().optional(), billingMaxHours: z.number().positive().optional(),
failureWindowHours: z.number().positive().optional(), failureWindowHours: z.number().positive().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
models: ModelsConfigSchema, models: ModelsConfigSchema,
agents: AgentsSchema, agents: AgentsSchema,
@@ -149,6 +161,7 @@ export const ClawdbotSchema = z
store: z.string().optional(), store: z.string().optional(),
maxConcurrentRuns: z.number().int().positive().optional(), maxConcurrentRuns: z.number().int().positive().optional(),
}) })
.strict()
.optional(), .optional(),
hooks: z hooks: z
.object({ .object({
@@ -162,6 +175,7 @@ export const ClawdbotSchema = z
gmail: HooksGmailSchema, gmail: HooksGmailSchema,
internal: InternalHooksSchema, internal: InternalHooksSchema,
}) })
.strict()
.optional(), .optional(),
web: z web: z
.object({ .object({
@@ -175,8 +189,10 @@ export const ClawdbotSchema = z
jitter: z.number().min(0).max(1).optional(), jitter: z.number().min(0).max(1).optional(),
maxAttempts: z.number().int().min(0).optional(), maxAttempts: z.number().int().min(0).optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
channels: ChannelsSchema, channels: ChannelsSchema,
bridge: z bridge: z
@@ -194,8 +210,10 @@ export const ClawdbotSchema = z
keyPath: z.string().optional(), keyPath: z.string().optional(),
caPath: z.string().optional(), caPath: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
discovery: z discovery: z
.object({ .object({
@@ -203,8 +221,10 @@ export const ClawdbotSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
canvasHost: z canvasHost: z
.object({ .object({
@@ -213,6 +233,7 @@ export const ClawdbotSchema = z
port: z.number().int().positive().optional(), port: z.number().int().positive().optional(),
liveReload: z.boolean().optional(), liveReload: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
talk: z talk: z
.object({ .object({
@@ -223,6 +244,7 @@ export const ClawdbotSchema = z
apiKey: z.string().optional(), apiKey: z.string().optional(),
interruptOnSpeech: z.boolean().optional(), interruptOnSpeech: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
gateway: z gateway: z
.object({ .object({
@@ -236,6 +258,7 @@ export const ClawdbotSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
basePath: z.string().optional(), basePath: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
auth: z auth: z
.object({ .object({
@@ -244,12 +267,14 @@ export const ClawdbotSchema = z
password: z.string().optional(), password: z.string().optional(),
allowTailscale: z.boolean().optional(), allowTailscale: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
tailscale: z tailscale: z
.object({ .object({
mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(), mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(),
resetOnExit: z.boolean().optional(), resetOnExit: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
remote: z remote: z
.object({ .object({
@@ -259,6 +284,7 @@ export const ClawdbotSchema = z
sshTarget: z.string().optional(), sshTarget: z.string().optional(),
sshIdentity: z.string().optional(), sshIdentity: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
reload: z reload: z
.object({ .object({
@@ -272,6 +298,7 @@ export const ClawdbotSchema = z
.optional(), .optional(),
debounceMs: z.number().int().min(0).optional(), debounceMs: z.number().int().min(0).optional(),
}) })
.strict()
.optional(), .optional(),
http: z http: z
.object({ .object({
@@ -281,12 +308,16 @@ export const ClawdbotSchema = z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
skills: z skills: z
.object({ .object({
@@ -297,6 +328,7 @@ export const ClawdbotSchema = z
watch: z.boolean().optional(), watch: z.boolean().optional(),
watchDebounceMs: z.number().int().min(0).optional(), watchDebounceMs: z.number().int().min(0).optional(),
}) })
.strict()
.optional(), .optional(),
install: z install: z
.object({ .object({
@@ -305,6 +337,7 @@ export const ClawdbotSchema = z
.union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn"), z.literal("bun")]) .union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn"), z.literal("bun")])
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
entries: z entries: z
.record( .record(
@@ -315,10 +348,11 @@ export const ClawdbotSchema = z
apiKey: z.string().optional(), apiKey: z.string().optional(),
env: z.record(z.string(), z.string()).optional(), env: z.record(z.string(), z.string()).optional(),
}) })
.passthrough(), .strict(),
) )
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
plugins: z plugins: z
.object({ .object({
@@ -329,11 +363,13 @@ export const ClawdbotSchema = z
.object({ .object({
paths: z.array(z.string()).optional(), paths: z.array(z.string()).optional(),
}) })
.strict()
.optional(), .optional(),
slots: z slots: z
.object({ .object({
memory: z.string().optional(), memory: z.string().optional(),
}) })
.strict()
.optional(), .optional(),
entries: z entries: z
.record( .record(
@@ -343,7 +379,7 @@ export const ClawdbotSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(), config: z.record(z.string(), z.unknown()).optional(),
}) })
.passthrough(), .strict(),
) )
.optional(), .optional(),
installs: z installs: z
@@ -358,13 +394,14 @@ export const ClawdbotSchema = z
version: z.string().optional(), version: z.string().optional(),
installedAt: z.string().optional(), installedAt: z.string().optional(),
}) })
.passthrough(), .strict(),
) )
.optional(), .optional(),
}) })
.strict()
.optional(), .optional(),
}) })
.passthrough() .strict()
.superRefine((cfg, ctx) => { .superRefine((cfg, ctx) => {
const agents = cfg.agents?.list ?? []; const agents = cfg.agents?.list ?? [];
if (agents.length === 0) return; if (agents.length === 0) return;

View File

@@ -24,7 +24,6 @@ import {
} from "../infra/skills-remote.js"; } from "../infra/skills-remote.js";
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js"; import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js";
import type { PluginServicesHandle } from "../plugins/services.js"; import type { PluginServicesHandle } from "../plugins/services.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -175,7 +174,6 @@ export async function startGatewayServer(
const cfgAtStart = loadConfig(); const cfgAtStart = loadConfig();
setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true });
initSubagentRegistry(); initSubagentRegistry();
await autoMigrateLegacyState({ cfg: cfgAtStart, log });
const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
const baseMethods = listGatewayMethods(); const baseMethods = listGatewayMethods();

View File

@@ -55,6 +55,7 @@ export type {
export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js";
export type { ClawdbotPluginApi } from "../plugins/types.js"; export type { ClawdbotPluginApi } from "../plugins/types.js";
export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { ClawdbotConfig } from "../config/config.js"; export type { ClawdbotConfig } from "../config/config.js";
export type { ChannelDock } from "../channels/dock.js"; export type { ChannelDock } from "../channels/dock.js";
export { getChatChannelMeta } from "../channels/registry.js"; export { getChatChannelMeta } from "../channels/registry.js";

View File

@@ -10,6 +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: {} } },`;
function makeTempDir() { function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`); const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`);
@@ -44,7 +45,11 @@ 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"); const bundledPath = path.join(bundledDir, "bundled.ts");
fs.writeFileSync(bundledPath, "export default function () {}", "utf-8"); fs.writeFileSync(
bundledPath,
`export default { id: "bundled", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8",
);
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -100,7 +105,7 @@ describe("loadClawdbotPlugins", () => {
const bundledPath = path.join(bundledDir, "memory-core.ts"); const bundledPath = path.join(bundledDir, "memory-core.ts");
fs.writeFileSync( fs.writeFileSync(
bundledPath, bundledPath,
'export default { id: "memory-core", kind: "memory", register() {} };', `export default { id: "memory-core", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8", "utf-8",
); );
process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir;
@@ -137,7 +142,7 @@ describe("loadClawdbotPlugins", () => {
); );
fs.writeFileSync( fs.writeFileSync(
path.join(pluginDir, "index.ts"), path.join(pluginDir, "index.ts"),
'export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };', `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8", "utf-8",
); );
@@ -164,7 +169,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 function (api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); }`, body: `export default { id: "allowed", ${EMPTY_CONFIG_SCHEMA} register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -187,7 +192,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 function () {}`, body: `export default { id: "blocked", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -237,7 +242,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 function (api) { body: `export default { id: "channel-demo", ${EMPTY_CONFIG_SCHEMA} register(api) {
api.registerChannel({ api.registerChannel({
plugin: { plugin: {
id: "demo", id: "demo",
@@ -256,7 +261,7 @@ describe("loadClawdbotPlugins", () => {
outbound: { deliveryMode: "direct" } outbound: { deliveryMode: "direct" }
} }
}); });
};`, } };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -278,9 +283,9 @@ 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 function (api) { body: `export default { id: "http-demo", ${EMPTY_CONFIG_SCHEMA} register(api) {
api.registerHttpHandler(async () => false); api.registerHttpHandler(async () => false);
};`, } };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -304,7 +309,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 function () {}`, body: `export default { id: "config-disable", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -327,11 +332,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", register() {} };`, body: `export default { id: "memory-a", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
}); });
const memoryB = writePlugin({ const memoryB = writePlugin({
id: "memory-b", id: "memory-b",
body: `export default { id: "memory-b", kind: "memory", register() {} };`, body: `export default { id: "memory-b", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -354,7 +359,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", register() {} };`, body: `export default { id: "memory-off", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({
@@ -373,12 +378,16 @@ 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(path.join(bundledDir, "shadow.js"), "export default function () {}", "utf-8"); fs.writeFileSync(
path.join(bundledDir, "shadow.js"),
`export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
"utf-8",
);
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 function () {}`, body: `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`,
}); });
const registry = loadClawdbotPlugins({ const registry = loadClawdbotPlugins({

View File

@@ -30,6 +30,7 @@ export type PluginLoadOptions = {
logger?: PluginLogger; logger?: PluginLogger;
coreGatewayHandlers?: Record<string, GatewayRequestHandler>; coreGatewayHandlers?: Record<string, GatewayRequestHandler>;
cache?: boolean; cache?: boolean;
mode?: "full" | "validate";
}; };
type NormalizedPluginsConfig = { type NormalizedPluginsConfig = {
@@ -297,6 +298,7 @@ function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnost
export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegistry { export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegistry {
const cfg = options.config ?? {}; const cfg = options.config ?? {};
const logger = options.logger ?? defaultLogger(); const logger = options.logger ?? defaultLogger();
const validateOnly = options.mode === "validate";
const normalized = normalizePluginsConfig(cfg.plugins); const normalized = normalizePluginsConfig(cfg.plugins);
const cacheKey = buildCacheKey({ const cacheKey = buildCacheKey({
workspaceDir: options.workspaceDir, workspaceDir: options.workspaceDir,
@@ -437,6 +439,21 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
>) >)
: undefined; : undefined;
if (!definition?.configSchema) {
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({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
if (record.kind === "memory" && memorySlot === record.id) { if (record.kind === "memory" && memorySlot === record.id) {
memorySlotMatched = true; memorySlotMatched = true;
} }
@@ -481,6 +498,12 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
continue; continue;
} }
if (validateOnly) {
registry.plugins.push(record);
seenIds.set(candidate.idHint, candidate.origin);
continue;
}
if (typeof register !== "function") { if (typeof register !== "function") {
logger.error(`[plugins] ${record.id} missing register/activate export`); logger.error(`[plugins] ${record.id} missing register/activate export`);
record.status = "error"; record.status = "error";

View File

@@ -36,8 +36,10 @@ 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 function (api) { export default { ${emptyConfigSchema} register(api) {
api.registerTool( api.registerTool(
{ {
name: "optional_tool", name: "optional_tool",
@@ -49,7 +51,7 @@ export default function (api) {
}, },
{ optional: true }, { optional: true },
); );
} } }
`; `;
it("skips optional tools without explicit allowlist", () => { it("skips optional tools without explicit allowlist", () => {
@@ -138,7 +140,7 @@ export default function (api) {
const plugin = writePlugin({ const plugin = writePlugin({
id: "multi", id: "multi",
body: ` body: `
export default function (api) { export default { ${emptyConfigSchema} register(api) {
api.registerTool({ api.registerTool({
name: "message", name: "message",
description: "conflict", description: "conflict",
@@ -155,7 +157,7 @@ export default function (api) {
return { content: [{ type: "text", text: "ok" }] }; return { content: [{ type: "text", text: "ok" }] };
}, },
}); });
} } }
`, `,
}); });