diff --git a/CHANGELOG.md b/CHANGELOG.md index ae15780a1..e676e2359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Docs: https://docs.clawd.bot ## 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 - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 2179db263..0d7f7ce74 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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! +## 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 The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index ebe52050a..0b3c6496a 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -326,6 +326,22 @@ Clawdbot keeps conversation history in memory. ## Common troubleshooting +### “Gateway won’t 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? - **Credentials** present for the provider(s) being tried (auth profiles + env vars). diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts index 79b3cad7a..b4d355a12 100644 --- a/extensions/bluebubbles/index.ts +++ b/extensions/bluebubbles/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { bluebubblesPlugin } from "./src/channel.js"; import { handleBlueBubblesWebhookRequest } from "./src/monitor.js"; @@ -8,6 +9,7 @@ const plugin = { id: "bluebubbles", name: "BlueBubbles", description: "BlueBubbles channel plugin (macOS app)", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setBlueBubblesRuntime(api.runtime); api.registerChannel({ plugin: bluebubblesPlugin }); diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts index 16fa6910e..7c68410e7 100644 --- a/extensions/copilot-proxy/index.ts +++ b/extensions/copilot-proxy/index.ts @@ -1,3 +1,5 @@ +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + const DEFAULT_BASE_URL = "http://localhost:3000/v1"; const DEFAULT_API_KEY = "n/a"; const DEFAULT_CONTEXT_WINDOW = 128_000; @@ -61,6 +63,7 @@ const copilotProxyPlugin = { id: "copilot-proxy", name: "Copilot Proxy", description: "Local Copilot Proxy (VS Code LM) provider plugin", + configSchema: emptyPluginConfigSchema(), register(api) { api.registerProvider({ id: "copilot-proxy", diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts index e93ca873e..12834916a 100644 --- a/extensions/discord/index.ts +++ b/extensions/discord/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { discordPlugin } from "./src/channel.js"; import { setDiscordRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "discord", name: "Discord", description: "Discord channel plugin", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setDiscordRuntime(api.runtime); api.registerChannel({ plugin: discordPlugin }); diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts index e4050d24e..d6902bffe 100644 --- a/extensions/google-antigravity-auth/index.ts +++ b/extensions/google-antigravity-auth/index.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; 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 const decode = (s: string) => Buffer.from(s, "base64").toString(); @@ -360,6 +361,7 @@ const antigravityPlugin = { id: "google-antigravity-auth", name: "Google Antigravity Auth", description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), register(api) { api.registerProvider({ id: "google-antigravity", diff --git a/extensions/google-gemini-cli-auth/index.ts b/extensions/google-gemini-cli-auth/index.ts index b4ccf585b..56251dabd 100644 --- a/extensions/google-gemini-cli-auth/index.ts +++ b/extensions/google-gemini-cli-auth/index.ts @@ -1,3 +1,5 @@ +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + import { loginGeminiCliOAuth } from "./oauth.js"; const PROVIDER_ID = "google-gemini-cli"; @@ -14,6 +16,7 @@ const geminiCliPlugin = { id: "google-gemini-cli-auth", name: "Google Gemini CLI Auth", description: "OAuth flow for Gemini CLI (Google Code Assist)", + configSchema: emptyPluginConfigSchema(), register(api) { api.registerProvider({ id: PROVIDER_ID, diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts index 2bc53759d..fc27584ba 100644 --- a/extensions/imessage/index.ts +++ b/extensions/imessage/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { imessagePlugin } from "./src/channel.js"; import { setIMessageRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "imessage", name: "iMessage", description: "iMessage channel plugin", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setIMessageRuntime(api.runtime); api.registerChannel({ plugin: imessagePlugin }); diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index eaee15222..082aea5b9 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { matrixPlugin } from "./src/channel.js"; import { setMatrixRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "matrix", name: "Matrix", description: "Matrix channel plugin (matrix-js-sdk)", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setMatrixRuntime(api.runtime); api.registerChannel({ plugin: matrixPlugin }); diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts index 04e738c0d..99148b18e 100644 --- a/extensions/memory-core/index.ts +++ b/extensions/memory-core/index.ts @@ -1,10 +1,12 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; const memoryCorePlugin = { id: "memory-core", name: "Memory (Core)", description: "File-backed memory search tools and CLI", kind: "memory", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { api.registerTool( (ctx) => { diff --git a/extensions/memory-lancedb/config.ts b/extensions/memory-lancedb/config.ts index d63a06e8a..c0382392f 100644 --- a/extensions/memory-lancedb/config.ts +++ b/extensions/memory-lancedb/config.ts @@ -24,6 +24,16 @@ const EMBEDDING_DIMENSIONS: Record = { "text-embedding-3-large": 3072, }; +function assertAllowedKeys( + value: Record, + 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 { const dims = EMBEDDING_DIMENSIONS[model]; if (!dims) { @@ -54,11 +64,13 @@ export const memoryConfigSchema = { throw new Error("memory config required"); } const cfg = value as Record; + assertAllowedKeys(cfg, ["embedding", "dbPath", "autoCapture", "autoRecall"], "memory config"); const embedding = cfg.embedding as Record | undefined; if (!embedding || typeof embedding.apiKey !== "string") { throw new Error("embedding.apiKey is required"); } + assertAllowedKeys(embedding, ["apiKey", "model"], "embedding config"); const model = resolveEmbeddingModel(embedding); diff --git a/extensions/msteams/index.ts b/extensions/msteams/index.ts index fbf6bb3a5..a4290c799 100644 --- a/extensions/msteams/index.ts +++ b/extensions/msteams/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { msteamsPlugin } from "./src/channel.js"; import { setMSTeamsRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "msteams", name: "Microsoft Teams", description: "Microsoft Teams channel plugin (Bot Framework)", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setMSTeamsRuntime(api.runtime); api.registerChannel({ plugin: msteamsPlugin }); diff --git a/extensions/qwen-portal-auth/index.ts b/extensions/qwen-portal-auth/index.ts index bf198aac0..bacb48a50 100644 --- a/extensions/qwen-portal-auth/index.ts +++ b/extensions/qwen-portal-auth/index.ts @@ -1,3 +1,5 @@ +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + import { loginQwenPortalOAuth } from "./oauth.js"; const PROVIDER_ID = "qwen-portal"; @@ -30,6 +32,7 @@ const qwenPortalPlugin = { id: "qwen-portal-auth", name: "Qwen OAuth", description: "OAuth flow for Qwen (free-tier) models", + configSchema: emptyPluginConfigSchema(), register(api) { api.registerProvider({ id: PROVIDER_ID, diff --git a/extensions/signal/index.ts b/extensions/signal/index.ts index 39f24840d..59964787c 100644 --- a/extensions/signal/index.ts +++ b/extensions/signal/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { signalPlugin } from "./src/channel.js"; import { setSignalRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "signal", name: "Signal", description: "Signal channel plugin", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setSignalRuntime(api.runtime); api.registerChannel({ plugin: signalPlugin }); diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 9fc8984be..7e65d981a 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { slackPlugin } from "./src/channel.js"; import { setSlackRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "slack", name: "Slack", description: "Slack channel plugin", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setSlackRuntime(api.runtime); api.registerChannel({ plugin: slackPlugin }); diff --git a/extensions/telegram/index.ts b/extensions/telegram/index.ts index 95062b622..1720dac8e 100644 --- a/extensions/telegram/index.ts +++ b/extensions/telegram/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { telegramPlugin } from "./src/channel.js"; import { setTelegramRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "telegram", name: "Telegram", description: "Telegram channel plugin", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setTelegramRuntime(api.runtime); api.registerChannel({ plugin: telegramPlugin }); diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index ede197ac9..832e692ca 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -35,30 +35,36 @@ export type InboundPolicy = z.infer; // Provider-Specific Configuration // ----------------------------------------------------------------------------- -export const TelnyxConfigSchema = z.object({ +export const TelnyxConfigSchema = z + .object({ /** Telnyx API v2 key */ apiKey: z.string().min(1).optional(), /** Telnyx connection ID (from Call Control app) */ connectionId: z.string().min(1).optional(), /** Public key for webhook signature verification */ publicKey: z.string().min(1).optional(), -}); +}) + .strict(); export type TelnyxConfig = z.infer; -export const TwilioConfigSchema = z.object({ +export const TwilioConfigSchema = z + .object({ /** Twilio Account SID */ accountSid: z.string().min(1).optional(), /** Twilio Auth Token */ authToken: z.string().min(1).optional(), -}); +}) + .strict(); export type TwilioConfig = z.infer; -export const PlivoConfigSchema = z.object({ +export const PlivoConfigSchema = z + .object({ /** Plivo Auth ID (starts with MA/SA) */ authId: z.string().min(1).optional(), /** Plivo Auth Token */ authToken: z.string().min(1).optional(), -}); +}) + .strict(); export type PlivoConfig = z.infer; // ----------------------------------------------------------------------------- @@ -72,6 +78,7 @@ export const SttConfigSchema = z /** Whisper model to use */ model: z.string().min(1).default("whisper-1"), }) + .strict() .default({ provider: "openai", model: "whisper-1" }); export type SttConfig = z.infer; @@ -97,6 +104,7 @@ export const TtsConfigSchema = z */ instructions: z.string().optional(), }) + .strict() .default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" }); export type TtsConfig = z.infer; @@ -113,6 +121,7 @@ export const VoiceCallServeConfigSchema = z /** Webhook path */ path: z.string().min(1).default("/voice/webhook"), }) + .strict() .default({ port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }); export type VoiceCallServeConfig = z.infer; @@ -128,6 +137,7 @@ export const VoiceCallTailscaleConfigSchema = z /** Path for Tailscale serve/funnel (should usually match serve.path) */ path: z.string().min(1).default("/voice/webhook"), }) + .strict() .default({ mode: "off", path: "/voice/webhook" }); export type VoiceCallTailscaleConfig = z.infer< typeof VoiceCallTailscaleConfigSchema @@ -161,6 +171,7 @@ export const VoiceCallTunnelConfigSchema = z */ allowNgrokFreeTier: z.boolean().default(true), }) + .strict() .default({ provider: "none", allowNgrokFreeTier: true }); export type VoiceCallTunnelConfig = z.infer; @@ -183,6 +194,7 @@ export const OutboundConfigSchema = z /** Seconds to wait after TTS before auto-hangup in notify mode */ notifyHangupDelaySec: z.number().int().nonnegative().default(3), }) + .strict() .default({ defaultMode: "notify", notifyHangupDelaySec: 3 }); export type OutboundConfig = z.infer; @@ -207,6 +219,7 @@ export const VoiceCallStreamingConfigSchema = z /** WebSocket path for media stream connections */ streamPath: z.string().min(1).default("/voice/stream"), }) + .strict() .default({ enabled: false, sttProvider: "openai-realtime", @@ -223,7 +236,8 @@ export type VoiceCallStreamingConfig = z.infer< // Main Voice Call Configuration // ----------------------------------------------------------------------------- -export const VoiceCallConfigSchema = z.object({ +export const VoiceCallConfigSchema = z + .object({ /** Enable voice call functionality */ enabled: z.boolean().default(false), @@ -307,7 +321,8 @@ export const VoiceCallConfigSchema = z.object({ /** Timeout for response generation in ms (default 30s) */ responseTimeoutMs: z.number().int().positive().default(30000), -}); +}) + .strict(); export type VoiceCallConfig = z.infer; diff --git a/extensions/whatsapp/index.ts b/extensions/whatsapp/index.ts index 451d9bb4b..a8b3c719f 100644 --- a/extensions/whatsapp/index.ts +++ b/extensions/whatsapp/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { whatsappPlugin } from "./src/channel.js"; import { setWhatsAppRuntime } from "./src/runtime.js"; @@ -7,6 +8,7 @@ const plugin = { id: "whatsapp", name: "WhatsApp", description: "WhatsApp channel plugin", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setWhatsAppRuntime(api.runtime); api.registerChannel({ plugin: whatsappPlugin }); diff --git a/extensions/zalo/index.ts b/extensions/zalo/index.ts index 5e5a44512..3f705dfe6 100644 --- a/extensions/zalo/index.ts +++ b/extensions/zalo/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { zaloDock, zaloPlugin } from "./src/channel.js"; import { handleZaloWebhookRequest } from "./src/monitor.js"; @@ -8,6 +9,7 @@ const plugin = { id: "zalo", name: "Zalo", description: "Zalo channel plugin (Bot API)", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setZaloRuntime(api.runtime); api.registerChannel({ plugin: zaloPlugin, dock: zaloDock }); diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index d4e1a7de0..03aa05a17 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -1,4 +1,5 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; import { zalouserPlugin } from "./src/channel.js"; import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js"; @@ -8,6 +9,7 @@ const plugin = { id: "zalouser", name: "Zalo Personal", description: "Zalo personal account messaging via zca-cli", + configSchema: emptyPluginConfigSchema(), register(api: ClawdbotPluginApi) { setZalouserRuntime(api.runtime); // Register channel plugin (for onboarding & gateway) diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index faedef7a7..cf26712d7 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -12,7 +12,6 @@ type SandboxHashInput = { function isPrimitive(value: unknown): value is string | number | boolean | bigint | symbol | null { return value === null || (typeof value !== "object" && typeof value !== "function"); } - function normalizeForHash(value: unknown): unknown { if (value === undefined) return undefined; if (Array.isArray(value)) { diff --git a/src/cli/program.nodes-basic.test.ts b/src/cli/program.nodes-basic.test.ts index fb6499f16..65f1b4681 100644 --- a/src/cli/program.nodes-basic.test.ts +++ b/src/cli/program.nodes-basic.test.ts @@ -43,6 +43,11 @@ vi.mock("../tui/tui.js", () => ({ runTui })); vi.mock("../gateway/call.js", () => ({ callGateway, 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: () => ({}) })); @@ -127,26 +132,32 @@ describe("cli program (nodes basics)", () => { }); it("runs nodes describe and calls node.describe", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }) - .mockResolvedValueOnce({ - ts: Date.now(), - nodeId: "ios-node", - displayName: "iOS Node", - caps: ["canvas", "camera"], - commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], - connected: true, - }); + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }; + } + if (opts.method === "node.describe") { + return { + ts: Date.now(), + nodeId: "ios-node", + displayName: "iOS Node", + caps: ["canvas", "camera"], + commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], + connected: true, + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); @@ -154,12 +165,10 @@ describe("cli program (nodes basics)", () => { from: "user", }); - expect(callGateway).toHaveBeenNthCalledWith( - 1, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.list", params: {} }), ); - expect(callGateway).toHaveBeenNthCalledWith( - 2, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.describe", params: { nodeId: "ios-node" }, @@ -189,24 +198,30 @@ describe("cli program (nodes basics)", () => { }); it("runs nodes invoke and calls node.invoke", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - nodeId: "ios-node", - command: "canvas.eval", - payload: { result: "ok" }, - }); + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }; + } + if (opts.method === "node.invoke") { + return { + ok: true, + nodeId: "ios-node", + command: "canvas.eval", + payload: { result: "ok" }, + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); @@ -224,12 +239,10 @@ describe("cli program (nodes basics)", () => { { from: "user" }, ); - expect(callGateway).toHaveBeenNthCalledWith( - 1, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.list", params: {} }), ); - expect(callGateway).toHaveBeenNthCalledWith( - 2, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.invoke", params: { diff --git a/src/cli/program.nodes-media.test.ts b/src/cli/program.nodes-media.test.ts index 58cc2ffd6..60513b8e1 100644 --- a/src/cli/program.nodes-media.test.ts +++ b/src/cli/program.nodes-media.test.ts @@ -44,6 +44,11 @@ vi.mock("../tui/tui.js", () => ({ runTui })); vi.mock("../gateway/call.js", () => ({ callGateway, 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: () => ({}) })); @@ -56,61 +61,43 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera snap and prints two MEDIA paths", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - nodeId: "ios-node", - command: "camera.snap", - payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, - }) - .mockResolvedValueOnce({ - ok: true, - nodeId: "ios-node", - command: "camera.snap", - payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, - }); + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }; + } + if (opts.method === "node.invoke") { + return { + ok: true, + nodeId: "ios-node", + command: "camera.snap", + payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); await program.parseAsync(["nodes", "camera", "snap", "--node", "ios-node"], { from: "user" }); - expect(callGateway).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - method: "node.invoke", - params: expect.objectContaining({ - nodeId: "ios-node", - command: "camera.snap", - timeoutMs: 20000, - 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 invokeCalls = callGateway.mock.calls + .map((call) => call[0] as { method?: string; params?: Record }) + .filter((call) => call.method === "node.invoke"); + const facings = invokeCalls + .map((call) => (call.params?.params as { facing?: string } | undefined)?.facing) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)); + expect(facings).toEqual(["back", "front"]); const out = String(runtime.log.mock.calls[0]?.[0] ?? ""); const mediaPaths = out @@ -130,29 +117,35 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera clip and prints one MEDIA path", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + 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({ - ok: true, - nodeId: "ios-node", - command: "camera.clip", - payload: { - format: "mp4", - base64: "aGk=", - durationMs: 3000, - hasAudio: true, - }, - }); + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); @@ -161,8 +154,7 @@ describe("cli program (nodes media)", () => { { from: "user" }, ); - expect(callGateway).toHaveBeenNthCalledWith( - 2, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ @@ -192,24 +184,30 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera snap with facing front and passes params", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - nodeId: "ios-node", - command: "camera.snap", - payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, - }); + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }; + } + if (opts.method === "node.invoke") { + return { + ok: true, + nodeId: "ios-node", + command: "camera.snap", + payload: { format: "jpg", base64: "aGk=", width: 1, height: 1 }, + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); @@ -234,8 +232,7 @@ describe("cli program (nodes media)", () => { { from: "user" }, ); - expect(callGateway).toHaveBeenNthCalledWith( - 2, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ @@ -265,29 +262,35 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera clip with --no-audio", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + 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({ - ok: true, - nodeId: "ios-node", - command: "camera.clip", - payload: { - format: "mp4", - base64: "aGk=", - durationMs: 3000, - hasAudio: false, - }, - }); + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); @@ -307,8 +310,7 @@ describe("cli program (nodes media)", () => { { from: "user" }, ); - expect(callGateway).toHaveBeenNthCalledWith( - 2, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ @@ -335,29 +337,35 @@ describe("cli program (nodes media)", () => { }); it("runs nodes camera clip with human duration (10s)", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + 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({ - ok: true, - nodeId: "ios-node", - command: "camera.clip", - payload: { - format: "mp4", - base64: "aGk=", - durationMs: 10_000, - hasAudio: true, - }, - }); + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); @@ -366,8 +374,7 @@ describe("cli program (nodes media)", () => { { from: "user" }, ); - expect(callGateway).toHaveBeenNthCalledWith( - 2, + expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ method: "node.invoke", params: expect.objectContaining({ @@ -380,24 +387,30 @@ describe("cli program (nodes media)", () => { }); it("runs nodes canvas snapshot and prints MEDIA path", async () => { - callGateway - .mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], - }) - .mockResolvedValueOnce({ - ok: true, - nodeId: "ios-node", - command: "canvas.snapshot", - payload: { format: "png", base64: "aGk=" }, - }); + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }; + } + if (opts.method === "node.invoke") { + return { + ok: true, + nodeId: "ios-node", + command: "canvas.snapshot", + payload: { format: "png", base64: "aGk=" }, + }; + } + return { ok: true }; + }); const program = buildProgram(); runtime.log.mockClear(); @@ -418,16 +431,21 @@ describe("cli program (nodes media)", () => { }); it("fails nodes camera snap on invalid facing", async () => { - callGateway.mockResolvedValueOnce({ - ts: Date.now(), - nodes: [ - { - nodeId: "ios-node", - displayName: "iOS Node", - remoteIp: "192.168.0.88", - connected: true, - }, - ], + callGateway.mockImplementation(async (opts: { method?: string }) => { + if (opts.method === "node.list") { + return { + ts: Date.now(), + nodes: [ + { + nodeId: "ios-node", + displayName: "iOS Node", + remoteIp: "192.168.0.88", + connected: true, + }, + ], + }; + } + return { ok: true }; }); const program = buildProgram(); @@ -439,6 +457,8 @@ describe("cli program (nodes media)", () => { }), ).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, + ); }); }); diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index a2270b7ea..107678bb9 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -43,6 +43,11 @@ vi.mock("../tui/tui.js", () => ({ runTui })); vi.mock("../gateway/call.js", () => ({ callGateway, 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: () => ({}) })); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 33edba173..d2a408070 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -1,65 +1,67 @@ -import { - isNixMode, - loadConfig, - migrateLegacyConfig, - readConfigFileSnapshot, - writeConfigFile, -} from "../../config/config.js"; -import { danger } from "../../globals.js"; -import { autoMigrateLegacyState } from "../../infra/state-migrations.js"; +import { readConfigFileSnapshot } from "../../config/config.js"; +import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { loadClawdbotPlugins } from "../../plugins/loader.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 || ""}: ${issue.message}`); +} + export async function ensureConfigReady(params: { runtime: RuntimeEnv; - migrateState?: boolean; + commandPath?: string[]; }): Promise { + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true }, + confirm: async () => false, + }); + const snapshot = await readConfigFileSnapshot(); - if (snapshot.legacyIssues.length > 0) { - if (isNixMode) { - params.runtime.error( - danger( - "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.", - ), - ); - params.runtime.exit(1); - return; - } - const migrated = migrateLegacyConfig(snapshot.parsed); - if (migrated.config) { - await writeConfigFile(migrated.config); - if (migrated.changes.length > 0) { - params.runtime.log( - `Migrated legacy config entries:\n${migrated.changes - .map((entry) => `- ${entry}`) - .join("\n")}`, - ); - } - } else { - const issues = snapshot.legacyIssues - .map((issue) => `- ${issue.path}: ${issue.message}`) - .join("\n"); - params.runtime.error( - danger( - `Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`, - ), - ); - params.runtime.exit(1); - return; + const command = params.commandPath?.[0]; + const allowInvalid = command ? ALLOWED_INVALID_COMMANDS.has(command) : false; + const issues = snapshot.exists && !snapshot.valid ? formatConfigIssues(snapshot.issues) : []; + const legacyIssues = + snapshot.legacyIssues.length > 0 + ? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`) + : []; + + const pluginIssues: string[] = []; + if (snapshot.valid) { + const workspaceDir = resolveAgentWorkspaceDir( + snapshot.config, + resolveDefaultAgentId(snapshot.config), + ); + const registry = loadClawdbotPlugins({ + config: snapshot.config, + workspaceDir: workspaceDir ?? undefined, + cache: false, + mode: "validate", + }); + for (const diag of registry.diagnostics) { + if (diag.level !== "error") continue; + const id = diag.pluginId ? ` ${diag.pluginId}` : ""; + pluginIssues.push(`- plugin${id}: ${diag.message}`); } } - if (snapshot.exists && !snapshot.valid) { - params.runtime.error(`Config invalid at ${snapshot.path}.`); - for (const issue of snapshot.issues) { - params.runtime.error(`- ${issue.path || ""}: ${issue.message}`); - } - params.runtime.error("Run `clawdbot doctor` to repair, then retry."); + const invalid = snapshot.exists && (!snapshot.valid || pluginIssues.length > 0); + if (!invalid) return; + + params.runtime.error(`Config invalid at ${snapshot.path}.`); + if (issues.length > 0) { + 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); - return; - } - - if (params.migrateState !== false) { - const cfg = loadConfig(); - await autoMigrateLegacyState({ cfg }); } } diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 319a7f19e..165e4d22e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.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"; function setProcessTitleForCommand(actionCommand: Command) { @@ -20,9 +20,8 @@ export function registerPreActionHooks(program: Command, programVersion: string) emitCliBanner(programVersion); const argv = process.argv; if (hasHelpOrVersion(argv)) return; - const [primary] = getCommandPath(argv, 1); - if (primary === "doctor") return; - const migrateState = shouldMigrateState(argv); - await ensureConfigReady({ runtime: defaultRuntime, migrateState }); + const commandPath = getCommandPath(argv, 2); + if (commandPath[0] === "doctor") return; + await ensureConfigReady({ runtime: defaultRuntime, commandPath }); }); } diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 8d045de64..023965c5c 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -20,6 +20,7 @@ export function registerMaintenanceCommands(program: Command) { .option("--no-workspace-suggestions", "Disable workspace memory system suggestions", false) .option("--yes", "Accept defaults 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("--non-interactive", "Run without prompts (safe migrations only)", false) .option("--generate-gateway-token", "Generate and configure a gateway token", false) @@ -29,7 +30,7 @@ export function registerMaintenanceCommands(program: Command) { await doctorCommand(defaultRuntime, { workspaceSuggestions: opts.workspaceSuggestions, yes: Boolean(opts.yes), - repair: Boolean(opts.repair), + repair: Boolean(opts.repair) || Boolean(opts.fix), force: Boolean(opts.force), nonInteractive: Boolean(opts.nonInteractive), generateGatewayToken: Boolean(opts.generateGatewayToken), diff --git a/src/cli/route.ts b/src/cli/route.ts index 83adad068..34dcbbb54 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -15,18 +15,17 @@ import { getVerboseFlag, hasFlag, hasHelpOrVersion, - shouldMigrateStateFromPath, } from "./argv.js"; import { ensureConfigReady } from "./program/config-guard.js"; import { runMemoryStatus } from "./memory-cli.js"; async function prepareRoutedCommand(params: { argv: string[]; - migrateState: boolean; + commandPath: string[]; loadPlugins?: boolean; }) { emitCliBanner(VERSION, { argv: params.argv }); - await ensureConfigReady({ runtime: defaultRuntime, migrateState: params.migrateState }); + await ensureConfigReady({ runtime: defaultRuntime, commandPath: params.commandPath }); if (params.loadPlugins) { ensurePluginRegistryLoaded(); } @@ -39,10 +38,8 @@ export async function tryRouteCli(argv: string[]): Promise { const path = getCommandPath(argv, 2); const [primary, secondary] = path; if (!primary) return false; - const migrateState = shouldMigrateStateFromPath(path); - if (primary === "health") { - await prepareRoutedCommand({ argv, migrateState, loadPlugins: true }); + await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true }); const json = hasFlag(argv, "--json"); const verbose = getVerboseFlag(argv, { includeDebug: true }); const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); @@ -53,7 +50,7 @@ export async function tryRouteCli(argv: string[]): Promise { } if (primary === "status") { - await prepareRoutedCommand({ argv, migrateState, loadPlugins: true }); + await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: true }); const json = hasFlag(argv, "--json"); const deep = hasFlag(argv, "--deep"); const all = hasFlag(argv, "--all"); @@ -67,7 +64,7 @@ export async function tryRouteCli(argv: string[]): Promise { } if (primary === "sessions") { - await prepareRoutedCommand({ argv, migrateState }); + await prepareRoutedCommand({ argv, commandPath: path }); const json = hasFlag(argv, "--json"); const verbose = getVerboseFlag(argv); const store = getFlagValue(argv, "--store"); @@ -80,7 +77,7 @@ export async function tryRouteCli(argv: string[]): Promise { } if (primary === "agents" && secondary === "list") { - await prepareRoutedCommand({ argv, migrateState }); + await prepareRoutedCommand({ argv, commandPath: path }); const json = hasFlag(argv, "--json"); const bindings = hasFlag(argv, "--bindings"); await agentsListCommand({ json, bindings }, defaultRuntime); @@ -88,7 +85,7 @@ export async function tryRouteCli(argv: string[]): Promise { } if (primary === "memory" && secondary === "status") { - await prepareRoutedCommand({ argv, migrateState }); + await prepareRoutedCommand({ argv, commandPath: path }); const agent = getFlagValue(argv, "--agent"); if (agent === null) return false; const json = hasFlag(argv, "--json"); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index e8b665ec1..b9deff472 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -4,6 +4,7 @@ import { migrateLegacyConfig, readConfigFileSnapshot, } from "../config/config.js"; +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { note } from "../terminal/note.js"; import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js"; import type { DoctorOptions } from "./doctor-prompter.js"; @@ -45,6 +46,8 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { options: DoctorOptions; confirm: (p: { message: string; initialValue: boolean }) => Promise; }) { + void params.confirm; + const shouldRepair = params.options.repair === true || params.options.yes === true; const snapshot = await readConfigFileSnapshot(); let cfg: ClawdbotConfig = snapshot.config ?? {}; 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"), "Legacy config keys detected", ); - const migrate = - params.options.nonInteractive === true - ? true - : await params.confirm({ - message: "Migrate legacy config entries now?", - initialValue: true, - }); - if (migrate) { + if (shouldRepair) { // 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); if (changes.length > 0) note(changes.join("\n"), "Doctor changes"); if (migrated) cfg = migrated; + } else { + note('Run "clawdbot doctor --fix" to apply legacy migrations.', "Doctor"); } } const normalized = normalizeLegacyConfigValues(cfg); if (normalized.changes.length > 0) { 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); diff --git a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts index 5262e4597..a2be5ef7d 100644 --- a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts +++ b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts @@ -356,7 +356,7 @@ describe("doctor command", () => { changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."], }); - await doctorCommand(runtime, { nonInteractive: true }); + await doctorCommand(runtime, { nonInteractive: true, repair: true }); expect(writeConfigFile).toHaveBeenCalledTimes(1); const written = writeConfigFile.mock.calls[0]?.[0] as Record; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ca0d82bc0..c98c234e2 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -247,9 +247,13 @@ export async function doctorCommand( healthOk, }); - cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) }); - await writeConfigFile(cfg); - runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + if (prompter.shouldRepair) { + cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) }); + await writeConfigFile(cfg); + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + } else { + runtime.log('Run "clawdbot doctor --fix" to apply changes.'); + } if (options.workspaceSuggestions !== false) { const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 81e226877..2de4dc04e 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -12,7 +12,7 @@ describe("config env vars", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - env: { OPENROUTER_API_KEY: "config-key" }, + env: { vars: { OPENROUTER_API_KEY: "config-key" } }, }, null, 2, @@ -36,7 +36,7 @@ describe("config env vars", () => { path.join(configDir, "clawdbot.json"), JSON.stringify( { - env: { OPENROUTER_API_KEY: "config-key" }, + env: { vars: { OPENROUTER_API_KEY: "config-key" } }, }, null, 2, diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index 07c826a38..2792ca229 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -164,8 +164,6 @@ describe("config identity defaults", () => { messages: { messagePrefix: "[clawdbot]", responsePrefix: "🦞", - // legacy field should be ignored (moved to providers) - textChunkLimit: 9999, }, channels: { whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 }, diff --git a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts index 5b2f4b088..58bf62425 100644 --- a/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts +++ b/src/config/config.legacy-config-detection.accepts-imessage-dmpolicy.test.ts @@ -65,7 +65,7 @@ describe("legacy config detection", () => { const { validateConfigObject } = await import("./config.js"); const res = validateConfigObject({ channels: { imessage: { cliPath: "imsg; rm -rf /" } }, - tools: { audio: { transcription: { args: ["--model", "base"] } } }, + audio: { transcription: { command: ["whisper", "--model", "base"] } }, }); expect(res.ok).toBe(false); if (!res.ok) { @@ -76,7 +76,7 @@ describe("legacy config detection", () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); const res = validateConfigObject({ - tools: { audio: { transcription: { args: ["--model", "base"] } } }, + audio: { transcription: { command: ["whisper", "--model", "base"] } }, }); expect(res.ok).toBe(true); }); @@ -85,11 +85,9 @@ describe("legacy config detection", () => { const { validateConfigObject } = await import("./config.js"); const res = validateConfigObject({ channels: { imessage: { cliPath: "/Applications/Imsg Tools/imsg" } }, - tools: { - audio: { - transcription: { - args: ["--model"], - }, + audio: { + transcription: { + command: ["whisper", "--model"], }, }, }); @@ -166,7 +164,7 @@ describe("legacy config detection", () => { expect(res.config?.agents?.defaults?.models?.["openai/gpt-4.1-mini"]).toBeTruthy(); 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) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -176,31 +174,23 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { readConfigFileSnapshot } = await import("./config.js"); - const snap = await readConfigFileSnapshot(); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); - expect(snap.valid).toBe(true); - expect(snap.legacyIssues.length).toBe(0); + expect(snap.valid).toBe(false); + expect(snap.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - channels?: { whatsapp?: { allowFrom?: string[] } }; - routing?: unknown; - }; - expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); - expect(parsed.routing).toBeUndefined(); - expect( - warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), - ).toBe(true); - } finally { - warnSpy.mockRestore(); - } + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + routing?: { allowFrom?: string[] }; + channels?: unknown; + }; + expect(parsed.routing?.allowFrom).toEqual(["+15555550123"]); + expect(parsed.channels).toBeUndefined(); }); }); - 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) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -220,27 +210,19 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - expect(cfg.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth"); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + expect(cfg.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("token"); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - auth?: { profiles?: Record }; - }; - expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("oauth"); - expect( - warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), - ).toBe(true); - } finally { - warnSpy.mockRestore(); - } + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + auth?: { profiles?: Record }; + }; + expect(parsed.auth?.profiles?.["anthropic:claude-cli"]?.mode).toBe("token"); }); }); - it("auto-migrates legacy provider sections on load and writes back", async () => { + it("flags legacy provider sections in snapshot", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -250,29 +232,23 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); - expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1555"]); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - channels?: { whatsapp?: { allowFrom?: string[] } }; - whatsapp?: unknown; - }; - expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1555"]); - expect(parsed.whatsapp).toBeUndefined(); - expect( - warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), - ).toBe(true); - } finally { - warnSpy.mockRestore(); - } + expect(snap.valid).toBe(false); + expect(snap.legacyIssues.some((issue) => issue.path === "whatsapp")).toBe(true); + + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + channels?: unknown; + whatsapp?: unknown; + }; + expect(parsed.channels).toBeUndefined(); + expect(parsed.whatsapp).toBeTruthy(); }); }); - it("auto-migrates routing.allowFrom on load and writes back", async () => { + it("flags routing.allowFrom in snapshot", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -282,26 +258,23 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); - expect(cfg.channels?.whatsapp?.allowFrom).toEqual(["+1666"]); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - channels?: { whatsapp?: { allowFrom?: string[] } }; - routing?: unknown; - }; - expect(parsed.channels?.whatsapp?.allowFrom).toEqual(["+1666"]); - expect(parsed.routing).toBeUndefined(); - } finally { - warnSpy.mockRestore(); - } + expect(snap.valid).toBe(false); + expect(snap.legacyIssues.some((issue) => issue.path === "routing.allowFrom")).toBe(true); + + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + channels?: unknown; + routing?: { allowFrom?: string[] }; + }; + expect(parsed.channels).toBeUndefined(); + 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) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -317,28 +290,21 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - expect(cfg.bindings?.[0]?.match?.channel).toBe("slack"); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - bindings?: Array<{ match?: { channel?: string; provider?: string } }>; - }; - expect(parsed.bindings?.[0]?.match?.channel).toBe("slack"); - expect(parsed.bindings?.[0]?.match?.provider).toBeUndefined(); - expect( - warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), - ).toBe(true); - } finally { - warnSpy.mockRestore(); - } + expect(snap.valid).toBe(false); + expect(snap.issues.length).toBeGreaterThan(0); + + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + bindings?: Array<{ match?: { provider?: string } }>; + }; + expect(parsed.bindings?.[0]?.match?.provider).toBe("slack"); }); }); - it("auto-migrates bindings[].match.accountID on load and writes back", async () => { + it("rejects bindings[].match.accountID on load", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -354,28 +320,21 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - expect(cfg.bindings?.[0]?.match?.accountId).toBe("work"); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - bindings?: Array<{ match?: { accountId?: string; accountID?: string } }>; - }; - expect(parsed.bindings?.[0]?.match?.accountId).toBe("work"); - expect(parsed.bindings?.[0]?.match?.accountID).toBeUndefined(); - expect( - warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), - ).toBe(true); - } finally { - warnSpy.mockRestore(); - } + expect(snap.valid).toBe(false); + expect(snap.issues.length).toBeGreaterThan(0); + + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + bindings?: Array<{ match?: { accountID?: string } }>; + }; + expect(parsed.bindings?.[0]?.match?.accountID).toBe("work"); }); }); - 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) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -395,34 +354,21 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - expect(cfg.session?.sendPolicy?.rules?.[0]?.match?.channel).toBe("telegram"); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - session?: { - sendPolicy?: { - rules?: Array<{ - match?: { channel?: string; provider?: string }; - }>; - }; - }; - }; - 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(); - } + expect(snap.valid).toBe(false); + expect(snap.issues.length).toBeGreaterThan(0); + + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + session?: { sendPolicy?: { rules?: Array<{ match?: { provider?: string } }> } }; + }; + expect(parsed.session?.sendPolicy?.rules?.[0]?.match?.provider).toBe("telegram"); }); }); - it("auto-migrates messages.queue.byProvider on load and writes back", async () => { + it("rejects messages.queue.byProvider on load", async () => { await withTempHome(async (home) => { const configPath = path.join(home, ".clawdbot", "clawdbot.json"); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -432,30 +378,22 @@ describe("legacy config detection", () => { "utf-8", ); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); vi.resetModules(); - try { - const { loadConfig } = await import("./config.js"); - const cfg = loadConfig(); - expect(cfg.messages?.queue?.byChannel?.whatsapp).toBe("queue"); + const { readConfigFileSnapshot } = await import("./config.js"); + const snap = await readConfigFileSnapshot(); - const raw = await fs.readFile(configPath, "utf-8"); - const parsed = JSON.parse(raw) as { - messages?: { - queue?: { - byChannel?: Record; - byProvider?: unknown; - }; + expect(snap.valid).toBe(false); + expect(snap.issues.length).toBeGreaterThan(0); + + const raw = await fs.readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { + messages?: { + queue?: { + byProvider?: Record; }; }; - expect(parsed.messages?.queue?.byChannel?.whatsapp).toBe("queue"); - expect(parsed.messages?.queue?.byProvider).toBeUndefined(); - expect( - warnSpy.mock.calls.some(([msg]) => String(msg).includes("Auto-migrated config")), - ).toBe(true); - } finally { - warnSpy.mockRestore(); - } + }; + expect(parsed.messages?.queue?.byProvider?.whatsapp).toBe("queue"); }); }); }); diff --git a/src/config/config.multi-agent-agentdir-validation.test.ts b/src/config/config.multi-agent-agentdir-validation.test.ts index d2af807d5..378762bf5 100644 --- a/src/config/config.multi-agent-agentdir-validation.test.ts +++ b/src/config/config.multi-agent-agentdir-validation.test.ts @@ -38,7 +38,7 @@ describe("multi-agent agentDir validation", () => { { id: "b", agentDir: "~/.clawdbot/agents/shared/agent" }, ], }, - bindings: [{ agentId: "a", match: { provider: "telegram" } }], + bindings: [{ agentId: "a", match: { channel: "telegram" } }], }, null, 2, diff --git a/src/config/config.preservation-on-validation-failure.test.ts b/src/config/config.preservation-on-validation-failure.test.ts index 14aa742b3..9e0986fd5 100644 --- a/src/config/config.preservation-on-validation-failure.test.ts +++ b/src/config/config.preservation-on-validation-failure.test.ts @@ -3,21 +3,18 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "./test-helpers.js"; -describe("config preservation on validation failure", () => { - it("preserves unknown fields via passthrough", async () => { +describe("config strict validation", () => { + it("rejects unknown fields", async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); const res = validateConfigObject({ agents: { list: [{ id: "pi" }] }, customUnknownField: { nested: "value" }, }); - expect(res.ok).toBe(true); - expect((res as { config: Record }).config.customUnknownField).toEqual({ - nested: "value", - }); + expect(res.ok).toBe(false); }); - it("preserves config data when validation fails", async () => { + it("flags legacy config entries without auto-migrating", async () => { await withTempHome(async (home) => { const configDir = path.join(home, ".clawdbot"); await fs.mkdir(configDir, { recursive: true }); @@ -26,7 +23,6 @@ describe("config preservation on validation failure", () => { JSON.stringify({ agents: { list: [{ id: "pi" }] }, routing: { allowFrom: ["+15555550123"] }, - customData: { preserved: true }, }), "utf-8", ); @@ -35,12 +31,8 @@ describe("config preservation on validation failure", () => { const { readConfigFileSnapshot } = await import("./config.js"); const snap = await readConfigFileSnapshot(); - expect(snap.valid).toBe(true); - expect(snap.legacyIssues).toHaveLength(0); - expect((snap.config as Record).customData).toEqual({ - preserved: true, - }); - expect(snap.config.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); + expect(snap.valid).toBe(false); + expect(snap.legacyIssues).not.toHaveLength(0); }); }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 0d0a3e20f..2d57bf0a7 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -24,10 +24,9 @@ import { import { VERSION } from "../version.js"; import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.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 { resolveConfigPath, resolveStateDir } from "./paths.js"; -import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; import type { ClawdbotConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObject } from "./validation.js"; @@ -87,29 +86,6 @@ function coerceConfig(value: unknown): 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 { if (CONFIG_BACKUP_COUNT <= 1) return; const backupBase = `${configPath}.bak`; @@ -147,10 +123,6 @@ function warnOnConfigMiskeys(raw: unknown, logger: Pick) } } -function formatLegacyMigrationLog(changes: string[]): string { - return `Auto-migrated config:\n${changes.map((entry) => `- ${entry}`).join("\n")}`; -} - function stampConfigVersion(cfg: ClawdbotConfig): ClawdbotConfig { const now = new Date().toISOString(); return { @@ -231,56 +203,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const deps = normalizeDeps(overrides); 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 { try { if (!deps.fs.existsSync(configPath)) { @@ -307,14 +229,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { // Substitute ${VAR} env var references const substituted = resolveConfigEnvVars(resolved, deps.env); - const migrated = applyLegacyMigrations(substituted); - let resolvedConfig = migrated.next ?? substituted; - const autoEnable = applyPluginAutoEnable({ - config: coerceConfig(resolvedConfig), - env: deps.env, - }); - resolvedConfig = autoEnable.config; - const migrationChanges = [...migrated.changes, ...autoEnable.changes]; + const resolvedConfig = substituted; warnOnConfigMiskeys(resolvedConfig, deps.logger); if (typeof resolvedConfig !== "object" || resolvedConfig === null) return {}; const validated = ClawdbotSchema.safeParse(resolvedConfig); @@ -326,14 +241,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { return {}; } 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( applyCompactionDefaults( applyContextPruningDefaults( @@ -467,14 +374,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { }; } - const migrated = applyLegacyMigrations(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 resolvedConfigRaw = substituted; const legacyIssues = findLegacyConfigIssues(resolvedConfigRaw); const validated = validateConfigObject(resolvedConfigRaw); @@ -493,13 +393,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } 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 { path: configPath, exists: true, diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 869f7ee21..b4d89fcc9 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -20,21 +20,25 @@ export const AgentDefaultsSchema = z primary: z.string().optional(), fallbacks: z.array(z.string()).optional(), }) + .strict() .optional(), imageModel: z .object({ primary: z.string().optional(), fallbacks: z.array(z.string()).optional(), }) + .strict() .optional(), models: z .record( z.string(), - z.object({ - alias: z.string().optional(), - /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ - params: z.record(z.string(), z.unknown()).optional(), - }), + z + .object({ + alias: z.string().optional(), + /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ + params: z.record(z.string(), z.unknown()).optional(), + }) + .strict(), ) .optional(), workspace: z.string().optional(), @@ -62,6 +66,7 @@ export const AgentDefaultsSchema = z allow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), }) + .strict() .optional(), softTrim: z .object({ @@ -69,14 +74,17 @@ export const AgentDefaultsSchema = z headChars: z.number().int().nonnegative().optional(), tailChars: z.number().int().nonnegative().optional(), }) + .strict() .optional(), hardClear: z .object({ enabled: z.boolean().optional(), placeholder: z.string().optional(), }) + .strict() .optional(), }) + .strict() .optional(), compaction: z .object({ @@ -89,8 +97,10 @@ export const AgentDefaultsSchema = z prompt: z.string().optional(), systemPrompt: z.string().optional(), }) + .strict() .optional(), }) + .strict() .optional(), thinkingDefault: z .union([ @@ -132,10 +142,11 @@ export const AgentDefaultsSchema = z z.object({ primary: z.string().optional(), fallbacks: z.array(z.string()).optional(), - }), + }).strict(), ]) .optional(), }) + .strict() .optional(), sandbox: z .object({ @@ -149,6 +160,8 @@ export const AgentDefaultsSchema = z browser: SandboxBrowserSchema, prune: SandboxPruneSchema, }) + .strict() .optional(), }) + .strict() .optional(); diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 2175e41fc..fbfc2bf5c 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -30,6 +30,7 @@ export const HeartbeatSchema = z prompt: z.string().optional(), ackMaxChars: z.number().int().nonnegative().optional(), }) + .strict() .superRefine((val, ctx) => { if (!val.every) return; try { @@ -69,7 +70,7 @@ export const SandboxDockerSchema = z z.object({ soft: z.number().int().nonnegative().optional(), hard: z.number().int().nonnegative().optional(), - }), + }).strict(), ]), ) .optional(), @@ -79,6 +80,7 @@ export const SandboxDockerSchema = z extraHosts: z.array(z.string()).optional(), binds: z.array(z.string()).optional(), }) + .strict() .optional(); export const SandboxBrowserSchema = z @@ -98,6 +100,7 @@ export const SandboxBrowserSchema = z autoStart: z.boolean().optional(), autoStartTimeoutMs: z.number().int().positive().optional(), }) + .strict() .optional(); export const SandboxPruneSchema = z @@ -105,6 +108,7 @@ export const SandboxPruneSchema = z idleHours: z.number().int().nonnegative().optional(), maxAgeDays: z.number().int().nonnegative().optional(), }) + .strict() .optional(); export const ToolPolicySchema = z @@ -112,6 +116,7 @@ export const ToolPolicySchema = z allow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), }) + .strict() .optional(); export const ToolsWebSearchSchema = z @@ -123,6 +128,7 @@ export const ToolsWebSearchSchema = z timeoutSeconds: z.number().int().positive().optional(), cacheTtlMinutes: z.number().nonnegative().optional(), }) + .strict() .optional(); export const ToolsWebFetchSchema = z @@ -133,6 +139,7 @@ export const ToolsWebFetchSchema = z cacheTtlMinutes: z.number().nonnegative().optional(), userAgent: z.string().optional(), }) + .strict() .optional(); export const ToolsWebSchema = z @@ -140,17 +147,20 @@ export const ToolsWebSchema = z search: ToolsWebSearchSchema, fetch: ToolsWebFetchSchema, }) + .strict() .optional(); export const ToolProfileSchema = z .union([z.literal("minimal"), z.literal("coding"), z.literal("messaging"), z.literal("full")]) .optional(); -export const ToolPolicyWithProfileSchema = z.object({ - allow: z.array(z.string()).optional(), - deny: z.array(z.string()).optional(), - profile: ToolProfileSchema, -}); +export const ToolPolicyWithProfileSchema = z + .object({ + allow: z.array(z.string()).optional(), + deny: z.array(z.string()).optional(), + profile: ToolProfileSchema, + }) + .strict(); // Provider docking: allowlists keyed by provider id (no schema updates when adding providers). export const ElevatedAllowFromSchema = z @@ -169,6 +179,7 @@ export const AgentSandboxSchema = z browser: SandboxBrowserSchema, prune: SandboxPruneSchema, }) + .strict() .optional(); export const AgentToolsSchema = z @@ -182,6 +193,7 @@ export const AgentToolsSchema = z enabled: z.boolean().optional(), allowFrom: ElevatedAllowFromSchema, }) + .strict() .optional(), exec: z .object({ @@ -199,15 +211,19 @@ export const AgentToolsSchema = z enabled: z.boolean().optional(), allowModels: z.array(z.string()).optional(), }) + .strict() .optional(), }) + .strict() .optional(), sandbox: z .object({ tools: ToolPolicySchema, }) + .strict() .optional(), }) + .strict() .optional(); export const MemorySearchSchema = z @@ -218,6 +234,7 @@ export const MemorySearchSchema = z .object({ sessionMemory: z.boolean().optional(), }) + .strict() .optional(), provider: z.union([z.literal("openai"), z.literal("local"), z.literal("gemini")]).optional(), remote: z @@ -233,8 +250,10 @@ export const MemorySearchSchema = z pollIntervalMs: z.number().int().nonnegative().optional(), timeoutMinutes: z.number().int().positive().optional(), }) + .strict() .optional(), }) + .strict() .optional(), fallback: z .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(), modelCacheDir: z.string().optional(), }) + .strict() .optional(), store: z .object({ @@ -255,14 +275,17 @@ export const MemorySearchSchema = z enabled: z.boolean().optional(), extensionPath: z.string().optional(), }) + .strict() .optional(), }) + .strict() .optional(), chunking: z .object({ tokens: z.number().int().positive().optional(), overlap: z.number().int().nonnegative().optional(), }) + .strict() .optional(), sync: z .object({ @@ -272,6 +295,7 @@ export const MemorySearchSchema = z watchDebounceMs: z.number().int().nonnegative().optional(), intervalMinutes: z.number().int().nonnegative().optional(), }) + .strict() .optional(), query: z .object({ @@ -284,25 +308,32 @@ export const MemorySearchSchema = z textWeight: z.number().min(0).max(1).optional(), candidateMultiplier: z.number().int().positive().optional(), }) + .strict() .optional(), }) + .strict() .optional(), cache: z .object({ enabled: z.boolean().optional(), maxEntries: z.number().int().positive().optional(), }) + .strict() .optional(), }) + .strict() .optional(); export const AgentModelSchema = z.union([ z.string(), - z.object({ - primary: z.string().optional(), - fallbacks: z.array(z.string()).optional(), - }), + z + .object({ + primary: z.string().optional(), + fallbacks: z.array(z.string()).optional(), + }) + .strict(), ]); -export const AgentEntrySchema = z.object({ +export const AgentEntrySchema = z + .object({ id: z.string(), default: z.boolean().optional(), name: z.string().optional(), @@ -323,14 +354,16 @@ export const AgentEntrySchema = z.object({ z.object({ primary: z.string().optional(), fallbacks: z.array(z.string()).optional(), - }), + }).strict(), ]) .optional(), }) + .strict() .optional(), sandbox: AgentSandboxSchema, tools: AgentToolsSchema, -}); +}) + .strict(); export const ToolsSchema = z .object({ @@ -353,27 +386,33 @@ export const ToolsSchema = z prefix: z.string().optional(), suffix: z.string().optional(), }) + .strict() .optional(), }) + .strict() .optional(), broadcast: z .object({ enabled: z.boolean().optional(), }) + .strict() .optional(), }) + .strict() .optional(), agentToAgent: z .object({ enabled: z.boolean().optional(), allow: z.array(z.string()).optional(), }) + .strict() .optional(), elevated: z .object({ enabled: z.boolean().optional(), allowFrom: ElevatedAllowFromSchema, }) + .strict() .optional(), exec: z .object({ @@ -391,18 +430,23 @@ export const ToolsSchema = z enabled: z.boolean().optional(), allowModels: z.array(z.string()).optional(), }) + .strict() .optional(), }) + .strict() .optional(), subagents: z .object({ tools: ToolPolicySchema, }) + .strict() .optional(), sandbox: z .object({ tools: ToolPolicySchema, }) + .strict() .optional(), }) + .strict() .optional(); diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index d5df650b6..0656e23f6 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -8,25 +8,31 @@ export const AgentsSchema = z defaults: z.lazy(() => AgentDefaultsSchema).optional(), list: z.array(AgentEntrySchema).optional(), }) + .strict() .optional(); export const BindingsSchema = z .array( - z.object({ - agentId: z.string(), - match: z.object({ - channel: z.string(), - accountId: z.string().optional(), - peer: z + z + .object({ + agentId: z.string(), + match: z .object({ - kind: z.union([z.literal("dm"), z.literal("group"), z.literal("channel")]), - id: z.string(), + channel: 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(), - guildId: z.string().optional(), - teamId: z.string().optional(), - }), - }), + .strict(), + }) + .strict(), ) .optional(); @@ -43,4 +49,5 @@ export const AudioSchema = z .object({ transcription: TranscribeAudioSchema, }) + .strict() .optional(); diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 0e7bf0cbc..8c71f66d2 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -19,40 +19,48 @@ export const ModelCompatSchema = z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), }) + .strict() .optional(); -export const ModelDefinitionSchema = z.object({ +export const ModelDefinitionSchema = z + .object({ id: z.string().min(1), name: z.string().min(1), api: ModelApiSchema.optional(), reasoning: z.boolean(), input: z.array(z.union([z.literal("text"), z.literal("image")])), - cost: z.object({ - input: z.number(), - output: z.number(), - cacheRead: z.number(), - cacheWrite: z.number(), - }), + cost: z + .object({ + input: z.number(), + output: z.number(), + cacheRead: z.number(), + cacheWrite: z.number(), + }) + .strict(), contextWindow: z.number().positive(), maxTokens: z.number().positive(), headers: z.record(z.string(), z.string()).optional(), compat: ModelCompatSchema, -}); +}) + .strict(); -export const ModelProviderSchema = z.object({ +export const ModelProviderSchema = z + .object({ baseUrl: z.string().min(1), apiKey: z.string().optional(), api: ModelApiSchema.optional(), headers: z.record(z.string(), z.string()).optional(), authHeader: z.boolean().optional(), models: z.array(ModelDefinitionSchema), -}); +}) + .strict(); export const ModelsConfigSchema = z .object({ mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), providers: z.record(z.string(), ModelProviderSchema).optional(), }) + .strict() .optional(); export const GroupChatSchema = z @@ -60,11 +68,14 @@ export const GroupChatSchema = z mentionPatterns: z.array(z.string()).optional(), historyLimit: z.number().int().positive().optional(), }) + .strict() .optional(); -export const DmConfigSchema = z.object({ - historyLimit: z.number().int().min(0).optional(), -}); +export const DmConfigSchema = z + .object({ + historyLimit: z.number().int().min(0).optional(), + }) + .strict(); export const IdentitySchema = z .object({ @@ -72,6 +83,7 @@ export const IdentitySchema = z theme: z.string().optional(), emoji: z.string().optional(), }) + .strict() .optional(); 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 BlockStreamingCoalesceSchema = z.object({ - minChars: z.number().int().positive().optional(), - maxChars: z.number().int().positive().optional(), - idleMs: z.number().int().nonnegative().optional(), -}); +export const BlockStreamingCoalesceSchema = z + .object({ + minChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + idleMs: z.number().int().nonnegative().optional(), + }) + .strict(); -export const BlockStreamingChunkSchema = z.object({ - minChars: z.number().int().positive().optional(), - maxChars: z.number().int().positive().optional(), - breakPreference: z - .union([z.literal("paragraph"), z.literal("newline"), z.literal("sentence")]) - .optional(), -}); +export const BlockStreamingChunkSchema = z + .object({ + minChars: z.number().int().positive().optional(), + maxChars: z.number().int().positive().optional(), + breakPreference: z + .union([z.literal("paragraph"), z.literal("newline"), z.literal("sentence")]) + .optional(), + }) + .strict(); -export const HumanDelaySchema = z.object({ - mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), - minMs: z.number().int().nonnegative().optional(), - maxMs: z.number().int().nonnegative().optional(), -}); +export const HumanDelaySchema = z + .object({ + mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), + minMs: z.number().int().nonnegative().optional(), + maxMs: z.number().int().nonnegative().optional(), + }) + .strict(); -export const CliBackendSchema = z.object({ - command: z.string(), - args: z.array(z.string()).optional(), - output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), - resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), - input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), - maxPromptArgChars: z.number().int().positive().optional(), - env: z.record(z.string(), z.string()).optional(), - clearEnv: z.array(z.string()).optional(), - modelArg: z.string().optional(), - modelAliases: z.record(z.string(), z.string()).optional(), - sessionArg: z.string().optional(), - sessionArgs: z.array(z.string()).optional(), - resumeArgs: z.array(z.string()).optional(), - sessionMode: z.union([z.literal("always"), z.literal("existing"), z.literal("none")]).optional(), - sessionIdFields: z.array(z.string()).optional(), - systemPromptArg: z.string().optional(), - systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(), - systemPromptWhen: z - .union([z.literal("first"), z.literal("always"), z.literal("never")]) - .optional(), - imageArg: z.string().optional(), - imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), - serialize: z.boolean().optional(), -}); +export const CliBackendSchema = z + .object({ + command: z.string(), + args: z.array(z.string()).optional(), + output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), + resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), + input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), + maxPromptArgChars: z.number().int().positive().optional(), + env: z.record(z.string(), z.string()).optional(), + clearEnv: z.array(z.string()).optional(), + modelArg: z.string().optional(), + modelAliases: z.record(z.string(), z.string()).optional(), + sessionArg: z.string().optional(), + sessionArgs: z.array(z.string()).optional(), + resumeArgs: z.array(z.string()).optional(), + sessionMode: z.union([z.literal("always"), z.literal("existing"), z.literal("none")]).optional(), + sessionIdFields: z.array(z.string()).optional(), + systemPromptArg: z.string().optional(), + systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(), + systemPromptWhen: z + .union([z.literal("first"), z.literal("always"), z.literal("never")]) + .optional(), + imageArg: z.string().optional(), + imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), + serialize: z.boolean().optional(), + }) + .strict(); export const normalizeAllowFrom = (values?: Array): string[] => (values ?? []).map((v) => String(v).trim()).filter(Boolean); @@ -173,6 +193,7 @@ export const RetryConfigSchema = z maxDelayMs: z.number().int().min(0).optional(), jitter: z.number().min(0).max(1).optional(), }) + .strict() .optional(); export const QueueModeBySurfaceSchema = z @@ -186,6 +207,7 @@ export const QueueModeBySurfaceSchema = z msteams: QueueModeSchema.optional(), webchat: QueueModeSchema.optional(), }) + .strict() .optional(); export const DebounceMsBySurfaceSchema = z @@ -199,6 +221,7 @@ export const DebounceMsBySurfaceSchema = z msteams: z.number().int().nonnegative().optional(), webchat: z.number().int().nonnegative().optional(), }) + .strict() .optional(); export const QueueSchema = z @@ -209,6 +232,7 @@ export const QueueSchema = z cap: z.number().int().positive().optional(), drop: QueueDropSchema.optional(), }) + .strict() .optional(); export const InboundDebounceSchema = z @@ -216,6 +240,7 @@ export const InboundDebounceSchema = z debounceMs: z.number().int().nonnegative().optional(), byChannel: DebounceMsBySurfaceSchema, }) + .strict() .optional(); export const TranscribeAudioSchema = z @@ -232,6 +257,7 @@ export const TranscribeAudioSchema = z }), timeoutSeconds: z.number().int().positive().optional(), }) + .strict() .optional(); 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(), rules: z .array( - z.object({ - action: z.union([z.literal("allow"), z.literal("deny")]), - match: z - .object({ - channel: z.string().optional(), - chatType: z - .union([z.literal("direct"), z.literal("group"), z.literal("channel")]) - .optional(), - keyPrefix: z.string().optional(), - }) - .optional(), - }), + z + .object({ + action: z.union([z.literal("allow"), z.literal("deny")]), + match: z + .object({ + channel: z.string().optional(), + chatType: z + .union([z.literal("direct"), z.literal("group"), z.literal("channel")]) + .optional(), + keyPrefix: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict(), ) .optional(), }) + .strict() .optional(); 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")]) .optional(), }) + .strict() .optional(); const DeepgramAudioSchema = z @@ -282,6 +313,7 @@ const DeepgramAudioSchema = z punctuate: z.boolean().optional(), smartFormat: z.boolean().optional(), }) + .strict() .optional(); const ProviderOptionValueSchema = z.union([z.string(), z.number(), z.boolean()]); @@ -309,6 +341,7 @@ export const MediaUnderstandingModelSchema = z profile: z.string().optional(), preferredProfile: z.string().optional(), }) + .strict() .optional(); export const ToolsMediaUnderstandingSchema = z @@ -327,6 +360,7 @@ export const ToolsMediaUnderstandingSchema = z attachments: MediaUnderstandingAttachmentsSchema, models: z.array(MediaUnderstandingModelSchema).optional(), }) + .strict() .optional(); export const ToolsMediaSchema = z @@ -337,6 +371,7 @@ export const ToolsMediaSchema = z audio: ToolsMediaUnderstandingSchema.optional(), video: ToolsMediaUnderstandingSchema.optional(), }) + .strict() .optional(); export const NativeCommandsSettingSchema = z.union([z.boolean(), z.literal("auto")]); @@ -346,4 +381,5 @@ export const ProviderCommandsSchema = z native: NativeCommandsSettingSchema.optional(), nativeSkills: NativeCommandsSettingSchema.optional(), }) + .strict() .optional(); diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 413e49f15..140e861dd 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -37,22 +37,26 @@ export const HookMappingSchema = z module: z.string(), export: z.string().optional(), }) + .strict() .optional(), }) + .strict() .optional(); -export const InternalHookHandlerSchema = z.object({ - event: z.string(), - module: z.string(), - export: z.string().optional(), -}); +export const InternalHookHandlerSchema = z + .object({ + event: z.string(), + module: z.string(), + export: z.string().optional(), + }) + .strict(); const HookConfigSchema = z .object({ enabled: z.boolean().optional(), env: z.record(z.string(), z.string()).optional(), }) - .passthrough(); + .strict(); const HookInstallRecordSchema = z .object({ @@ -64,7 +68,7 @@ const HookInstallRecordSchema = z installedAt: z.string().optional(), hooks: z.array(z.string()).optional(), }) - .passthrough(); + .strict(); export const InternalHooksSchema = z .object({ @@ -75,9 +79,11 @@ export const InternalHooksSchema = z .object({ extraDirs: z.array(z.string()).optional(), }) + .strict() .optional(), installs: z.record(z.string(), HookInstallRecordSchema).optional(), }) + .strict() .optional(); export const HooksGmailSchema = z @@ -97,6 +103,7 @@ export const HooksGmailSchema = z port: z.number().int().positive().optional(), path: z.string().optional(), }) + .strict() .optional(), tailscale: z .object({ @@ -104,6 +111,7 @@ export const HooksGmailSchema = z path: z.string().optional(), target: z.string().optional(), }) + .strict() .optional(), model: z.string().optional(), thinking: z @@ -116,4 +124,5 @@ export const HooksGmailSchema = z ]) .optional(), }) + .strict() .optional(); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 17027bec0..5eb06b86a 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -23,32 +23,40 @@ const TelegramInlineButtonsScopeSchema = z.enum(["off", "dm", "group", "all", "a const TelegramCapabilitiesSchema = z.union([ z.array(z.string()), - z.object({ - inlineButtons: TelegramInlineButtonsScopeSchema.optional(), - }), + z + .object({ + inlineButtons: TelegramInlineButtonsScopeSchema.optional(), + }) + .strict(), ]); -export const TelegramTopicSchema = z.object({ - requireMention: z.boolean().optional(), - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - systemPrompt: z.string().optional(), -}); +export const TelegramTopicSchema = z + .object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); -export const TelegramGroupSchema = z.object({ - requireMention: z.boolean().optional(), - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - allowFrom: z.array(z.union([z.string(), z.number()])).optional(), - systemPrompt: z.string().optional(), - topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), -}); +export const TelegramGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(), + }) + .strict(); -const TelegramCustomCommandSchema = z.object({ - command: z.string().transform(normalizeTelegramCommandName), - description: z.string().transform(normalizeTelegramCommandDescription), -}); +const TelegramCustomCommandSchema = z + .object({ + command: z.string().transform(normalizeTelegramCommandName), + description: z.string().transform(normalizeTelegramCommandDescription), + }) + .strict(); const validateTelegramCustomCommands = ( 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(), capabilities: TelegramCapabilitiesSchema.optional(), enabled: z.boolean().optional(), @@ -105,10 +114,12 @@ export const TelegramAccountSchemaBase = z.object({ sendMessage: z.boolean().optional(), deleteMessage: z.boolean().optional(), }) + .strict() .optional(), reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), -}); +}) + .strict(); export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => { requireOpenAllowFrom({ @@ -144,6 +155,7 @@ export const DiscordDmSchema = z groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) + .strict() .superRefine((value, ctx) => { requireOpenAllowFrom({ policy: value.policy, @@ -155,25 +167,30 @@ export const DiscordDmSchema = z }); }); -export const DiscordGuildChannelSchema = z.object({ - allow: z.boolean().optional(), - requireMention: z.boolean().optional(), - skills: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - users: z.array(z.union([z.string(), z.number()])).optional(), - systemPrompt: z.string().optional(), - autoThread: z.boolean().optional(), -}); +export const DiscordGuildChannelSchema = z + .object({ + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + autoThread: z.boolean().optional(), + }) + .strict(); -export const DiscordGuildSchema = z.object({ - slug: z.string().optional(), - requireMention: z.boolean().optional(), - reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), - users: z.array(z.union([z.string(), z.number()])).optional(), - channels: z.record(z.string(), DiscordGuildChannelSchema.optional()).optional(), -}); +export const DiscordGuildSchema = z + .object({ + slug: z.string().optional(), + requireMention: z.boolean().optional(), + reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).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(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), @@ -212,11 +229,13 @@ export const DiscordAccountSchema = z.object({ moderation: z.boolean().optional(), channels: z.boolean().optional(), }) + .strict() .optional(), replyToMode: ReplyToModeSchema.optional(), dm: DiscordDmSchema.optional(), guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), -}); +}) + .strict(); export const DiscordConfigSchema = DiscordAccountSchema.extend({ accounts: z.record(z.string(), DiscordAccountSchema.optional()).optional(), @@ -230,6 +249,7 @@ export const SlackDmSchema = z groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) + .strict() .superRefine((value, ctx) => { requireOpenAllowFrom({ policy: value.policy, @@ -241,22 +261,27 @@ export const SlackDmSchema = z }); }); -export const SlackChannelSchema = z.object({ - enabled: z.boolean().optional(), - allow: z.boolean().optional(), - requireMention: z.boolean().optional(), - allowBots: z.boolean().optional(), - users: z.array(z.union([z.string(), z.number()])).optional(), - skills: z.array(z.string()).optional(), - systemPrompt: z.string().optional(), -}); +export const SlackChannelSchema = z + .object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + allowBots: z.boolean().optional(), + users: z.array(z.union([z.string(), z.number()])).optional(), + skills: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); -export const SlackThreadSchema = z.object({ - historyScope: z.enum(["thread", "channel"]).optional(), - inheritParent: z.boolean().optional(), -}); +export const SlackThreadSchema = z + .object({ + 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(), mode: z.enum(["socket", "http"]).optional(), signingSecret: z.string().optional(), @@ -294,6 +319,7 @@ export const SlackAccountSchema = z.object({ channelInfo: z.boolean().optional(), emojiList: z.boolean().optional(), }) + .strict() .optional(), slashCommand: z .object({ @@ -302,10 +328,12 @@ export const SlackAccountSchema = z.object({ sessionPrefix: z.string().optional(), ephemeral: z.boolean().optional(), }) + .strict() .optional(), dm: SlackDmSchema.optional(), channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), -}); +}) + .strict(); export const SlackConfigSchema = SlackAccountSchema.extend({ 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(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), @@ -367,7 +396,8 @@ export const SignalAccountSchemaBase = z.object({ mediaMaxMb: z.number().int().positive().optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), -}); +}) + .strict(); export const SignalAccountSchema = SignalAccountSchemaBase.superRefine((value, ctx) => { requireOpenAllowFrom({ @@ -391,7 +421,8 @@ export const SignalConfigSchema = SignalAccountSchemaBase.extend({ }); }); -export const IMessageAccountSchemaBase = z.object({ +export const IMessageAccountSchemaBase = z + .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), enabled: z.boolean().optional(), @@ -420,10 +451,12 @@ export const IMessageAccountSchemaBase = z.object({ .object({ requireMention: z.boolean().optional(), }) + .strict() .optional(), ) .optional(), -}); +}) + .strict(); export const IMessageAccountSchema = IMessageAccountSchemaBase.superRefine((value, ctx) => { requireOpenAllowFrom({ @@ -449,16 +482,20 @@ export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ }); }); -export const MSTeamsChannelSchema = z.object({ - requireMention: z.boolean().optional(), - replyStyle: MSTeamsReplyStyleSchema.optional(), -}); +export const MSTeamsChannelSchema = z + .object({ + requireMention: z.boolean().optional(), + replyStyle: MSTeamsReplyStyleSchema.optional(), + }) + .strict(); -export const MSTeamsTeamSchema = z.object({ - requireMention: z.boolean().optional(), - replyStyle: MSTeamsReplyStyleSchema.optional(), - channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(), -}); +export const MSTeamsTeamSchema = z + .object({ + requireMention: z.boolean().optional(), + replyStyle: MSTeamsReplyStyleSchema.optional(), + channels: z.record(z.string(), MSTeamsChannelSchema.optional()).optional(), + }) + .strict(); export const MSTeamsConfigSchema = z .object({ @@ -473,6 +510,7 @@ export const MSTeamsConfigSchema = z port: z.number().int().positive().optional(), path: z.string().optional(), }) + .strict() .optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: z.array(z.string()).optional(), @@ -488,6 +526,7 @@ export const MSTeamsConfigSchema = z replyStyle: MSTeamsReplyStyleSchema.optional(), teams: z.record(z.string(), MSTeamsTeamSchema.optional()).optional(), }) + .strict() .superRefine((value, ctx) => { requireOpenAllowFrom({ policy: value.dmPolicy, diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 171a7c476..6de67790d 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -36,6 +36,7 @@ export const WhatsAppAccountSchema = z .object({ requireMention: z.boolean().optional(), }) + .strict() .optional(), ) .optional(), @@ -45,9 +46,11 @@ export const WhatsAppAccountSchema = z direct: z.boolean().optional().default(true), group: z.enum(["always", "mentions", "never"]).optional().default("mentions"), }) + .strict() .optional(), debounceMs: z.number().int().nonnegative().optional().default(0), }) + .strict() .superRefine((value, ctx) => { if (value.dmPolicy !== "open") return; const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); @@ -84,6 +87,7 @@ export const WhatsAppConfigSchema = z sendMessage: z.boolean().optional(), polls: z.boolean().optional(), }) + .strict() .optional(), groups: z .record( @@ -92,6 +96,7 @@ export const WhatsAppConfigSchema = z .object({ requireMention: z.boolean().optional(), }) + .strict() .optional(), ) .optional(), @@ -101,9 +106,11 @@ export const WhatsAppConfigSchema = z direct: z.boolean().optional().default(true), group: z.enum(["always", "mentions", "never"]).optional().default("mentions"), }) + .strict() .optional(), debounceMs: z.number().int().nonnegative().optional().default(0), }) + .strict() .superRefine((value, ctx) => { if (value.dmPolicy !== "open") return; const allow = (value.allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 878cc8787..171f2ae27 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -20,6 +20,7 @@ export const ChannelsSchema = z .object({ groupPolicy: GroupPolicySchema.optional(), }) + .strict() .optional(), whatsapp: WhatsAppConfigSchema.optional(), telegram: TelegramConfigSchema.optional(), @@ -29,5 +30,5 @@ export const ChannelsSchema = z imessage: IMessageConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(), }) - .catchall(z.unknown()) + .strict() .optional(); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index db4badd05..6a8ad114a 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -7,11 +7,13 @@ import { QueueSchema, } from "./zod-schema.core.js"; -const SessionResetConfigSchema = z.object({ - mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), - atHour: z.number().int().min(0).max(23).optional(), - idleMinutes: z.number().int().positive().optional(), -}); +const SessionResetConfigSchema = z + .object({ + mode: z.union([z.literal("daily"), z.literal("idle")]).optional(), + atHour: z.number().int().min(0).max(23).optional(), + idleMinutes: z.number().int().positive().optional(), + }) + .strict(); export const SessionSchema = z .object({ @@ -30,6 +32,7 @@ export const SessionSchema = z group: SessionResetConfigSchema.optional(), thread: SessionResetConfigSchema.optional(), }) + .strict() .optional(), store: z.string().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(), rules: z .array( - z.object({ - action: z.union([z.literal("allow"), z.literal("deny")]), - match: z - .object({ - channel: z.string().optional(), - chatType: z - .union([z.literal("direct"), z.literal("group"), z.literal("channel")]) - .optional(), - keyPrefix: z.string().optional(), - }) - .optional(), - }), + z + .object({ + action: z.union([z.literal("allow"), z.literal("deny")]), + match: z + .object({ + channel: z.string().optional(), + chatType: z + .union([z.literal("direct"), z.literal("group"), z.literal("channel")]) + .optional(), + keyPrefix: z.string().optional(), + }) + .strict() + .optional(), + }) + .strict(), ) .optional(), }) + .strict() .optional(), agentToAgent: z .object({ maxPingPongTurns: z.number().int().min(0).max(5).optional(), }) + .strict() .optional(), }) + .strict() .optional(); export const MessagesSchema = z @@ -82,6 +91,7 @@ export const MessagesSchema = z ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(), removeAckAfterReply: z.boolean().optional(), }) + .strict() .optional(); export const CommandsSchema = z @@ -96,5 +106,6 @@ export const CommandsSchema = z restart: z.boolean().optional(), useAccessGroups: z.boolean().optional(), }) + .strict() .optional() .default({ native: "auto", nativeSkills: "auto" }); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d870a4d74..77eda3c4b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -13,6 +13,7 @@ export const ClawdbotSchema = z lastTouchedVersion: z.string().optional(), lastTouchedAt: z.string().optional(), }) + .strict() .optional(), env: z .object({ @@ -21,6 +22,7 @@ export const ClawdbotSchema = z enabled: z.boolean().optional(), timeoutMs: z.number().int().nonnegative().optional(), }) + .strict() .optional(), vars: z.record(z.string(), z.string()).optional(), }) @@ -34,6 +36,7 @@ export const ClawdbotSchema = z lastRunCommand: z.string().optional(), lastRunMode: z.union([z.literal("local"), z.literal("remote")]).optional(), }) + .strict() .optional(), logging: z .object({ @@ -66,12 +69,14 @@ export const ClawdbotSchema = z redactSensitive: z.union([z.literal("off"), z.literal("tools")]).optional(), redactPatterns: z.array(z.string()).optional(), }) + .strict() .optional(), update: z .object({ channel: z.union([z.literal("stable"), z.literal("beta")]).optional(), checkOnStart: z.boolean().optional(), }) + .strict() .optional(), browser: z .object({ @@ -99,28 +104,33 @@ export const ClawdbotSchema = z driver: z.union([z.literal("clawd"), z.literal("extension")]).optional(), color: HexColorSchema, }) + .strict() .refine((value) => value.cdpPort || value.cdpUrl, { message: "Profile must set cdpPort or cdpUrl", }), ) .optional(), }) + .strict() .optional(), ui: z .object({ seamColor: HexColorSchema.optional(), }) + .strict() .optional(), auth: z .object({ profiles: z .record( z.string(), - z.object({ - provider: z.string(), - mode: z.union([z.literal("api_key"), z.literal("oauth"), z.literal("token")]), - email: z.string().optional(), - }), + z + .object({ + provider: z.string(), + mode: z.union([z.literal("api_key"), z.literal("oauth"), z.literal("token")]), + email: z.string().optional(), + }) + .strict(), ) .optional(), order: z.record(z.string(), z.array(z.string())).optional(), @@ -131,8 +141,10 @@ export const ClawdbotSchema = z billingMaxHours: z.number().positive().optional(), failureWindowHours: z.number().positive().optional(), }) + .strict() .optional(), }) + .strict() .optional(), models: ModelsConfigSchema, agents: AgentsSchema, @@ -149,6 +161,7 @@ export const ClawdbotSchema = z store: z.string().optional(), maxConcurrentRuns: z.number().int().positive().optional(), }) + .strict() .optional(), hooks: z .object({ @@ -162,6 +175,7 @@ export const ClawdbotSchema = z gmail: HooksGmailSchema, internal: InternalHooksSchema, }) + .strict() .optional(), web: z .object({ @@ -175,8 +189,10 @@ export const ClawdbotSchema = z jitter: z.number().min(0).max(1).optional(), maxAttempts: z.number().int().min(0).optional(), }) + .strict() .optional(), }) + .strict() .optional(), channels: ChannelsSchema, bridge: z @@ -194,8 +210,10 @@ export const ClawdbotSchema = z keyPath: z.string().optional(), caPath: z.string().optional(), }) + .strict() .optional(), }) + .strict() .optional(), discovery: z .object({ @@ -203,8 +221,10 @@ export const ClawdbotSchema = z .object({ enabled: z.boolean().optional(), }) + .strict() .optional(), }) + .strict() .optional(), canvasHost: z .object({ @@ -213,6 +233,7 @@ export const ClawdbotSchema = z port: z.number().int().positive().optional(), liveReload: z.boolean().optional(), }) + .strict() .optional(), talk: z .object({ @@ -223,6 +244,7 @@ export const ClawdbotSchema = z apiKey: z.string().optional(), interruptOnSpeech: z.boolean().optional(), }) + .strict() .optional(), gateway: z .object({ @@ -236,6 +258,7 @@ export const ClawdbotSchema = z enabled: z.boolean().optional(), basePath: z.string().optional(), }) + .strict() .optional(), auth: z .object({ @@ -244,12 +267,14 @@ export const ClawdbotSchema = z password: z.string().optional(), allowTailscale: z.boolean().optional(), }) + .strict() .optional(), tailscale: z .object({ mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(), resetOnExit: z.boolean().optional(), }) + .strict() .optional(), remote: z .object({ @@ -259,6 +284,7 @@ export const ClawdbotSchema = z sshTarget: z.string().optional(), sshIdentity: z.string().optional(), }) + .strict() .optional(), reload: z .object({ @@ -272,6 +298,7 @@ export const ClawdbotSchema = z .optional(), debounceMs: z.number().int().min(0).optional(), }) + .strict() .optional(), http: z .object({ @@ -281,12 +308,16 @@ export const ClawdbotSchema = z .object({ enabled: z.boolean().optional(), }) + .strict() .optional(), }) + .strict() .optional(), }) + .strict() .optional(), }) + .strict() .optional(), skills: z .object({ @@ -297,6 +328,7 @@ export const ClawdbotSchema = z watch: z.boolean().optional(), watchDebounceMs: z.number().int().min(0).optional(), }) + .strict() .optional(), install: z .object({ @@ -305,6 +337,7 @@ export const ClawdbotSchema = z .union([z.literal("npm"), z.literal("pnpm"), z.literal("yarn"), z.literal("bun")]) .optional(), }) + .strict() .optional(), entries: z .record( @@ -315,10 +348,11 @@ export const ClawdbotSchema = z apiKey: z.string().optional(), env: z.record(z.string(), z.string()).optional(), }) - .passthrough(), + .strict(), ) .optional(), }) + .strict() .optional(), plugins: z .object({ @@ -329,11 +363,13 @@ export const ClawdbotSchema = z .object({ paths: z.array(z.string()).optional(), }) + .strict() .optional(), slots: z .object({ memory: z.string().optional(), }) + .strict() .optional(), entries: z .record( @@ -343,7 +379,7 @@ export const ClawdbotSchema = z enabled: z.boolean().optional(), config: z.record(z.string(), z.unknown()).optional(), }) - .passthrough(), + .strict(), ) .optional(), installs: z @@ -358,13 +394,14 @@ export const ClawdbotSchema = z version: z.string().optional(), installedAt: z.string().optional(), }) - .passthrough(), + .strict(), ) .optional(), }) + .strict() .optional(), }) - .passthrough() + .strict() .superRefine((cfg, ctx) => { const agents = cfg.agents?.list ?? []; if (agents.length === 0) return; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6154966d1..0197fbedf 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -24,7 +24,6 @@ import { } from "../infra/skills-remote.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js"; -import { autoMigrateLegacyState } from "../infra/state-migrations.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import type { PluginServicesHandle } from "../plugins/services.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -175,7 +174,6 @@ export async function startGatewayServer( const cfgAtStart = loadConfig(); setGatewaySigusr1RestartPolicy({ allowExternal: cfgAtStart.commands?.restart === true }); initSubagentRegistry(); - await autoMigrateLegacyState({ cfg: cfgAtStart, log }); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); const baseMethods = listGatewayMethods(); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index ef9cd8e07..edc9ea061 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -55,6 +55,7 @@ export type { export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { ClawdbotPluginApi } from "../plugins/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 { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 36119fc0c..5d549b042 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -10,6 +10,7 @@ type TempPlugin = { dir: string; file: string; id: string }; const tempDirs: string[] = []; const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR; +const EMPTY_CONFIG_SCHEMA = `configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },`; function makeTempDir() { const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`); @@ -44,7 +45,11 @@ describe("loadClawdbotPlugins", () => { it("disables bundled plugins by default", () => { const bundledDir = makeTempDir(); 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; const registry = loadClawdbotPlugins({ @@ -100,7 +105,7 @@ describe("loadClawdbotPlugins", () => { const bundledPath = path.join(bundledDir, "memory-core.ts"); fs.writeFileSync( bundledPath, - 'export default { id: "memory-core", kind: "memory", register() {} };', + `export default { id: "memory-core", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, "utf-8", ); process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; @@ -137,7 +142,7 @@ describe("loadClawdbotPlugins", () => { ); fs.writeFileSync( 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", ); @@ -164,7 +169,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ 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({ @@ -187,7 +192,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "blocked", - body: `export default function () {}`, + body: `export default { id: "blocked", ${EMPTY_CONFIG_SCHEMA} register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -237,7 +242,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "channel-demo", - body: `export default function (api) { + body: `export default { id: "channel-demo", ${EMPTY_CONFIG_SCHEMA} register(api) { api.registerChannel({ plugin: { id: "demo", @@ -256,7 +261,7 @@ describe("loadClawdbotPlugins", () => { outbound: { deliveryMode: "direct" } } }); -};`, +} };`, }); const registry = loadClawdbotPlugins({ @@ -278,9 +283,9 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "http-demo", - body: `export default function (api) { + body: `export default { id: "http-demo", ${EMPTY_CONFIG_SCHEMA} register(api) { api.registerHttpHandler(async () => false); -};`, +} };`, }); const registry = loadClawdbotPlugins({ @@ -304,7 +309,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const plugin = writePlugin({ id: "config-disable", - body: `export default function () {}`, + body: `export default { id: "config-disable", ${EMPTY_CONFIG_SCHEMA} register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -327,11 +332,11 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memoryA = writePlugin({ id: "memory-a", - body: `export default { id: "memory-a", kind: "memory", register() {} };`, + body: `export default { id: "memory-a", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, }); const memoryB = writePlugin({ 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({ @@ -354,7 +359,7 @@ describe("loadClawdbotPlugins", () => { process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; const memory = writePlugin({ id: "memory-off", - body: `export default { id: "memory-off", kind: "memory", register() {} };`, + body: `export default { id: "memory-off", kind: "memory", ${EMPTY_CONFIG_SCHEMA} register() {} };`, }); const registry = loadClawdbotPlugins({ @@ -373,12 +378,16 @@ describe("loadClawdbotPlugins", () => { it("prefers higher-precedence plugins with the same id", () => { 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; const override = writePlugin({ id: "shadow", - body: `export default function () {}`, + body: `export default { id: "shadow", ${EMPTY_CONFIG_SCHEMA} register() {} };`, }); const registry = loadClawdbotPlugins({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index d76b39275..c7b60c0a0 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -30,6 +30,7 @@ export type PluginLoadOptions = { logger?: PluginLogger; coreGatewayHandlers?: Record; cache?: boolean; + mode?: "full" | "validate"; }; type NormalizedPluginsConfig = { @@ -297,6 +298,7 @@ function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnost export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegistry { const cfg = options.config ?? {}; const logger = options.logger ?? defaultLogger(); + const validateOnly = options.mode === "validate"; const normalized = normalizePluginsConfig(cfg.plugins); const cacheKey = buildCacheKey({ workspaceDir: options.workspaceDir, @@ -437,6 +439,21 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi >) : 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) { memorySlotMatched = true; } @@ -481,6 +498,12 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } + if (validateOnly) { + registry.plugins.push(record); + seenIds.set(candidate.idHint, candidate.origin); + continue; + } + if (typeof register !== "function") { logger.error(`[plugins] ${record.id} missing register/activate export`); record.status = "error"; diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 31e5d1509..1705367e0 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -36,8 +36,10 @@ afterEach(() => { }); describe("resolvePluginTools optional tools", () => { + const emptyConfigSchema = + 'configSchema: { safeParse() { return { success: true, data: {} }; }, jsonSchema: { type: "object", additionalProperties: false, properties: {} } },'; const pluginBody = ` -export default function (api) { +export default { ${emptyConfigSchema} register(api) { api.registerTool( { name: "optional_tool", @@ -49,7 +51,7 @@ export default function (api) { }, { optional: true }, ); -} +} } `; it("skips optional tools without explicit allowlist", () => { @@ -138,7 +140,7 @@ export default function (api) { const plugin = writePlugin({ id: "multi", body: ` -export default function (api) { +export default { ${emptyConfigSchema} register(api) { api.registerTool({ name: "message", description: "conflict", @@ -155,7 +157,7 @@ export default function (api) { return { content: [{ type: "text", text: "ok" }] }; }, }); -} +} } `, });