import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; import { z } from "zod"; import { parseDurationMs } from "../cli/parse-duration.js"; /** * Nix mode detection: When CLAWDIS_NIX_MODE=1, the gateway is running under Nix. * In this mode: * - No auto-install flows should be attempted * - Missing dependencies should produce actionable Nix-specific error messages * - Config is managed externally (read-only from Nix perspective) */ export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1"; let warnedMainKeyOverride = false; export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type ReplyToMode = "off" | "first" | "all"; export type SessionSendPolicyAction = "allow" | "deny"; export type SessionSendPolicyMatch = { surface?: string; chatType?: "direct" | "group" | "room"; keyPrefix?: string; }; export type SessionSendPolicyRule = { action: SessionSendPolicyAction; match?: SessionSendPolicyMatch; }; export type SessionSendPolicyConfig = { default?: SessionSendPolicyAction; rules?: SessionSendPolicyRule[]; }; export type SessionConfig = { scope?: SessionScope; resetTriggers?: string[]; idleMinutes?: number; heartbeatIdleMinutes?: number; store?: string; typingIntervalSeconds?: number; mainKey?: string; sendPolicy?: SessionSendPolicyConfig; }; export type LoggingConfig = { level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; file?: string; consoleLevel?: | "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; consoleStyle?: "pretty" | "compact" | "json"; }; export type WebReconnectConfig = { initialMs?: number; maxMs?: number; factor?: number; jitter?: number; maxAttempts?: number; // 0 = unlimited }; export type WebConfig = { /** If false, do not start the WhatsApp web provider. Default: true. */ enabled?: boolean; heartbeatSeconds?: number; reconnect?: WebReconnectConfig; }; export type WhatsAppConfig = { /** Optional allowlist for WhatsApp direct chats (E.164). */ allowFrom?: string[]; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; groups?: Record< string, { requireMention?: boolean; } >; }; export type BrowserConfig = { enabled?: boolean; /** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */ controlUrl?: string; /** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */ cdpUrl?: string; /** Accent color for the clawd browser profile (hex). Default: #FF4500 */ color?: string; /** Override the browser executable path (macOS/Linux). */ executablePath?: string; /** Start Chrome headless (best-effort). Default: false */ headless?: boolean; /** Pass --no-sandbox to Chrome (Linux containers). Default: false */ noSandbox?: boolean; /** If true: never launch; only attach to an existing browser. Default: false */ attachOnly?: boolean; }; export type CronConfig = { enabled?: boolean; store?: string; maxConcurrentRuns?: number; }; export type HookMappingMatch = { path?: string; source?: string; }; export type HookMappingTransform = { module: string; export?: string; }; export type HookMappingConfig = { id?: string; match?: HookMappingMatch; action?: "wake" | "agent"; wakeMode?: "now" | "next-heartbeat"; name?: string; sessionKey?: string; messageTemplate?: string; textTemplate?: string; deliver?: boolean; channel?: | "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; to?: string; thinking?: string; timeoutSeconds?: number; transform?: HookMappingTransform; }; export type HooksGmailTailscaleMode = "off" | "serve" | "funnel"; export type HooksGmailConfig = { account?: string; label?: string; topic?: string; subscription?: string; pushToken?: string; hookUrl?: string; includeBody?: boolean; maxBytes?: number; renewEveryMinutes?: number; serve?: { bind?: string; port?: number; path?: string; }; tailscale?: { mode?: HooksGmailTailscaleMode; path?: string; }; }; export type HooksConfig = { enabled?: boolean; path?: string; token?: string; maxBodyBytes?: number; presets?: string[]; transformsDir?: string; mappings?: HookMappingConfig[]; gmail?: HooksGmailConfig; }; export type TelegramConfig = { /** If false, do not start the Telegram provider. Default: true. */ enabled?: boolean; botToken?: string; /** Path to file containing bot token (for secret managers like agenix) */ tokenFile?: string; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; groups?: Record< string, { requireMention?: boolean; } >; allowFrom?: Array; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; proxy?: string; webhookUrl?: string; webhookSecret?: string; webhookPath?: string; }; export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ enabled?: boolean; /** Allowlist for DM senders (ids or names). */ allowFrom?: Array; /** If true, allow group DMs (default: false). */ groupEnabled?: boolean; /** Optional allowlist for group DM channels (ids or slugs). */ groupChannels?: Array; }; export type DiscordGuildChannelConfig = { allow?: boolean; requireMention?: boolean; }; export type DiscordReactionNotificationMode = | "off" | "own" | "all" | "allowlist"; export type DiscordGuildEntry = { slug?: string; requireMention?: boolean; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ reactionNotifications?: DiscordReactionNotificationMode; users?: Array; channels?: Record; }; export type DiscordSlashCommandConfig = { /** Enable handling for the configured slash command (default: false). */ enabled?: boolean; /** Slash command name (default: "clawd"). */ name?: string; /** Session key prefix for slash commands (default: "discord:slash"). */ sessionPrefix?: string; /** Reply ephemerally (default: true). */ ephemeral?: boolean; }; export type DiscordActionConfig = { reactions?: boolean; stickers?: boolean; polls?: boolean; permissions?: boolean; messages?: boolean; threads?: boolean; pins?: boolean; search?: boolean; memberInfo?: boolean; roleInfo?: boolean; roles?: boolean; channelInfo?: boolean; voiceStatus?: boolean; events?: boolean; moderation?: boolean; }; export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; token?: string; /** Outbound text chunk size (chars). Default: 2000. */ textChunkLimit?: number; mediaMaxMb?: number; historyLimit?: number; /** Per-action tool gating (default: true for all). */ actions?: DiscordActionConfig; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; slashCommand?: DiscordSlashCommandConfig; dm?: DiscordDmConfig; /** New per-guild config keyed by guild id or slug. */ guilds?: Record; }; export type SignalConfig = { /** If false, do not start the Signal provider. Default: true. */ enabled?: boolean; /** Optional explicit E.164 account for signal-cli. */ account?: string; /** Optional full base URL for signal-cli HTTP daemon. */ httpUrl?: string; /** HTTP host for signal-cli daemon (default 127.0.0.1). */ httpHost?: string; /** HTTP port for signal-cli daemon (default 8080). */ httpPort?: number; /** signal-cli binary path (default: signal-cli). */ cliPath?: string; /** Auto-start signal-cli daemon (default: true if httpUrl not set). */ autoStart?: boolean; receiveMode?: "on-start" | "manual"; ignoreAttachments?: boolean; ignoreStories?: boolean; sendReadReceipts?: boolean; allowFrom?: Array; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; mediaMaxMb?: number; }; export type IMessageConfig = { /** If false, do not start the iMessage provider. Default: true. */ enabled?: boolean; /** imsg CLI binary path (default: imsg). */ cliPath?: string; /** Optional Messages db path override. */ dbPath?: string; /** Optional default send service (imessage|sms|auto). */ service?: "imessage" | "sms" | "auto"; /** Optional default region (used when sending SMS). */ region?: string; /** Optional allowlist for inbound handles or chat_id targets. */ allowFrom?: Array; /** Include attachments + reactions in watch payloads. */ includeAttachments?: boolean; /** Max outbound media size in MB. */ mediaMaxMb?: number; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; groups?: Record< string, { requireMention?: boolean; } >; }; export type QueueMode = | "steer" | "followup" | "collect" | "steer-backlog" | "steer+backlog" | "queue" | "interrupt"; export type QueueDropPolicy = "old" | "new" | "summarize"; export type QueueModeBySurface = { whatsapp?: QueueMode; telegram?: QueueMode; discord?: QueueMode; signal?: QueueMode; imessage?: QueueMode; webchat?: QueueMode; }; export type GroupChatConfig = { mentionPatterns?: string[]; historyLimit?: number; }; export type RoutingConfig = { transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; timeoutSeconds?: number; }; groupChat?: GroupChatConfig; queue?: { mode?: QueueMode; bySurface?: QueueModeBySurface; debounceMs?: number; cap?: number; drop?: QueueDropPolicy; }; }; export type MessagesConfig = { messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) }; export type BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback"; export type BridgeConfig = { enabled?: boolean; port?: number; /** * Bind address policy for the node bridge server. * - auto: prefer tailnet IP when present, else LAN (0.0.0.0) * - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces) * - tailnet: bind to the Tailscale interface IP (100.64.0.0/10) plus loopback * - loopback: 127.0.0.1 */ bind?: BridgeBindMode; }; export type WideAreaDiscoveryConfig = { enabled?: boolean; }; export type DiscoveryConfig = { wideArea?: WideAreaDiscoveryConfig; }; export type CanvasHostConfig = { enabled?: boolean; /** Directory to serve (default: ~/clawd/canvas). */ root?: string; /** HTTP port to listen on (default: 18793). */ port?: number; }; export type TalkConfig = { /** Default ElevenLabs voice ID for Talk mode. */ voiceId?: string; /** Optional voice name -> ElevenLabs voice ID map. */ voiceAliases?: Record; /** Default ElevenLabs model ID for Talk mode. */ modelId?: string; /** Default ElevenLabs output format (e.g. mp3_44100_128). */ outputFormat?: string; /** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */ apiKey?: string; /** Stop speaking when user starts talking (default: true). */ interruptOnSpeech?: boolean; }; export type GatewayControlUiConfig = { /** If false, the Gateway will not serve the Control UI (default /). */ enabled?: boolean; /** Optional base path prefix for the Control UI (e.g. "/clawdis"). */ basePath?: string; }; export type GatewayAuthMode = "token" | "password"; export type GatewayAuthConfig = { /** Authentication mode for Gateway connections. Defaults to token when set. */ mode?: GatewayAuthMode; /** Shared token for token mode (stored locally for CLI auth). */ token?: string; /** Shared password for password mode (consider env instead). */ password?: string; /** Allow Tailscale identity headers when serve mode is enabled. */ allowTailscale?: boolean; }; export type GatewayTailscaleMode = "off" | "serve" | "funnel"; export type GatewayTailscaleConfig = { /** Tailscale exposure mode for the Gateway control UI. */ mode?: GatewayTailscaleMode; /** Reset serve/funnel configuration on shutdown. */ resetOnExit?: boolean; }; export type GatewayRemoteConfig = { /** Remote Gateway WebSocket URL (ws:// or wss://). */ url?: string; /** Token for remote auth (when the gateway requires token auth). */ token?: string; /** Password for remote auth (when the gateway requires password auth). */ password?: string; }; export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid"; export type GatewayReloadConfig = { /** Reload strategy for config changes (default: hybrid). */ mode?: GatewayReloadMode; /** Debounce window for config reloads (ms). Default: 300. */ debounceMs?: number; }; export type GatewayConfig = { /** Single multiplexed port for Gateway WS + HTTP (default: 18789). */ port?: number; /** * Explicit gateway mode. When set to "remote", local gateway start is disabled. * When set to "local", the CLI may start the gateway locally. */ mode?: "local" | "remote"; /** * Bind address policy for the Gateway WebSocket + Control UI HTTP server. * Default: loopback (127.0.0.1). */ bind?: BridgeBindMode; controlUi?: GatewayControlUiConfig; auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig; remote?: GatewayRemoteConfig; reload?: GatewayReloadConfig; }; export type SkillConfig = { enabled?: boolean; apiKey?: string; env?: Record; [key: string]: unknown; }; export type SkillsLoadConfig = { /** * Additional skill folders to scan (lowest precedence). * Each directory should contain skill subfolders with `SKILL.md`. */ extraDirs?: string[]; }; export type SkillsInstallConfig = { preferBrew?: boolean; nodeManager?: "npm" | "pnpm" | "yarn" | "bun"; }; export type SkillsConfig = { /** Optional bundled-skill allowlist (only affects bundled skills). */ allowBundled?: string[]; load?: SkillsLoadConfig; install?: SkillsInstallConfig; entries?: Record; }; export type ModelApi = | "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai"; export type ModelCompatConfig = { supportsStore?: boolean; supportsDeveloperRole?: boolean; supportsReasoningEffort?: boolean; maxTokensField?: "max_completion_tokens" | "max_tokens"; }; export type ModelDefinitionConfig = { id: string; name: string; api?: ModelApi; reasoning: boolean; input: Array<"text" | "image">; cost: { input: number; output: number; cacheRead: number; cacheWrite: number; }; contextWindow: number; maxTokens: number; headers?: Record; compat?: ModelCompatConfig; }; export type ModelProviderConfig = { baseUrl: string; apiKey: string; api?: ModelApi; headers?: Record; authHeader?: boolean; models: ModelDefinitionConfig[]; }; export type ModelsConfig = { mode?: "merge" | "replace"; providers?: Record; }; export type ClawdisConfig = { identity?: { name?: string; theme?: string; emoji?: string; }; wizard?: { lastRunAt?: string; lastRunVersion?: string; lastRunCommit?: string; lastRunCommand?: string; lastRunMode?: "local" | "remote"; }; logging?: LoggingConfig; browser?: BrowserConfig; ui?: { /** Accent color for Clawdis UI chrome (hex). */ seamColor?: string; }; skills?: SkillsConfig; models?: ModelsConfig; agent?: { /** Model id (provider/model), e.g. "anthropic/claude-opus-4-5". */ model?: string; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ workspace?: string; /** Optional allowlist for /model (provider/model or model-only). */ allowedModels?: string[]; /** Optional model aliases for /model (alias -> provider/model). */ modelAliases?: Record; /** Optional display-only context window override (used for % in status UIs). */ contextTokens?: number; /** Default thinking level when no /think directive is present. */ thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; /** Default verbose level when no /verbose directive is present. */ verboseDefault?: "off" | "on"; /** Default block streaming level when no override is present. */ blockStreamingDefault?: "off" | "on"; /** * Block streaming boundary: * - "text_end": end of each assistant text content block (before tool calls) * - "message_end": end of the whole assistant message (may include tool blocks) */ blockStreamingBreak?: "text_end" | "message_end"; /** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */ blockStreamingChunk?: { minChars?: number; maxChars?: number; breakPreference?: "paragraph" | "newline" | "sentence"; }; timeoutSeconds?: number; /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ mediaMaxMb?: number; typingIntervalSeconds?: number; /** Periodic background heartbeat runs. */ heartbeat?: { /** Heartbeat interval (duration string, default unit: minutes). */ every?: string; /** Heartbeat model override (provider/model). */ model?: string; /** Delivery target (last|whatsapp|telegram|discord|signal|imessage|none). */ target?: | "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage" | "none"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; /** Override the heartbeat prompt body (default: "HEARTBEAT"). */ prompt?: string; }; /** Max concurrent agent runs across all conversations. Default: 1 (sequential). */ maxConcurrent?: number; /** Bash tool defaults. */ bash?: { /** Default time (ms) before a bash command auto-backgrounds. */ backgroundMs?: number; /** Default timeout (seconds) before auto-killing bash commands. */ timeoutSec?: number; /** How long to keep finished sessions in memory (ms). */ cleanupMs?: number; }; /** Optional sandbox settings for non-main sessions. */ sandbox?: { /** Enable sandboxing for sessions. */ mode?: "off" | "non-main" | "all"; /** Use one container per session (recommended for hard isolation). */ perSession?: boolean; /** Root directory for sandbox workspaces. */ workspaceRoot?: string; /** Docker-specific sandbox settings. */ docker?: { /** Docker image to use for sandbox containers. */ image?: string; /** Prefix for sandbox container names. */ containerPrefix?: string; /** Container workdir mount path (default: /workspace). */ workdir?: string; /** Run container rootfs read-only. */ readOnlyRoot?: boolean; /** Extra tmpfs mounts for read-only containers. */ tmpfs?: string[]; /** Container network mode (bridge|none|custom). */ network?: string; /** Container user (uid:gid). */ user?: string; /** Drop Linux capabilities. */ capDrop?: string[]; /** Extra environment variables for sandbox exec. */ env?: Record; /** Optional setup command run once after container creation. */ setupCommand?: string; }; /** Optional sandboxed browser settings. */ browser?: { enabled?: boolean; image?: string; containerPrefix?: string; cdpPort?: number; vncPort?: number; noVncPort?: number; headless?: boolean; enableNoVnc?: boolean; }; /** Tool allow/deny policy (deny wins). */ tools?: { allow?: string[]; deny?: string[]; }; /** Auto-prune sandbox containers. */ prune?: { /** Prune if idle for more than N hours (0 disables). */ idleHours?: number; /** Prune if older than N days (0 disables). */ maxAgeDays?: number; }; }; }; routing?: RoutingConfig; messages?: MessagesConfig; session?: SessionConfig; web?: WebConfig; whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; signal?: SignalConfig; imessage?: IMessageConfig; cron?: CronConfig; hooks?: HooksConfig; bridge?: BridgeConfig; discovery?: DiscoveryConfig; canvasHost?: CanvasHostConfig; talk?: TalkConfig; gateway?: GatewayConfig; }; /** * State directory for mutable data (sessions, logs, caches). * Can be overridden via CLAWDIS_STATE_DIR environment variable. * Default: ~/.clawdis */ export const STATE_DIR_CLAWDIS = process.env.CLAWDIS_STATE_DIR ?? path.join(os.homedir(), ".clawdis"); /** * Config file path (JSON5). * Can be overridden via CLAWDIS_CONFIG_PATH environment variable. * Default: ~/.clawdis/clawdis.json (or $CLAWDIS_STATE_DIR/clawdis.json) */ export const CONFIG_PATH_CLAWDIS = process.env.CLAWDIS_CONFIG_PATH ?? path.join(STATE_DIR_CLAWDIS, "clawdis.json"); export const DEFAULT_GATEWAY_PORT = 18789; export function resolveGatewayPort( cfg?: ClawdisConfig, env: NodeJS.ProcessEnv = process.env, ): number { const envRaw = env.CLAWDIS_GATEWAY_PORT?.trim(); if (envRaw) { const parsed = Number.parseInt(envRaw, 10); if (Number.isFinite(parsed) && parsed > 0) return parsed; } const configPort = cfg?.gateway?.port; if (typeof configPort === "number" && Number.isFinite(configPort)) { if (configPort > 0) return configPort; } return DEFAULT_GATEWAY_PORT; } const ModelApiSchema = z.union([ z.literal("openai-completions"), z.literal("openai-responses"), z.literal("anthropic-messages"), z.literal("google-generative-ai"), ]); const ModelCompatSchema = z .object({ supportsStore: z.boolean().optional(), supportsDeveloperRole: z.boolean().optional(), supportsReasoningEffort: z.boolean().optional(), maxTokensField: z .union([z.literal("max_completion_tokens"), z.literal("max_tokens")]) .optional(), }) .optional(); 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(), }), contextWindow: z.number().positive(), maxTokens: z.number().positive(), headers: z.record(z.string(), z.string()).optional(), compat: ModelCompatSchema, }); const ModelProviderSchema = z.object({ baseUrl: z.string().min(1), apiKey: z.string().min(1), api: ModelApiSchema.optional(), headers: z.record(z.string(), z.string()).optional(), authHeader: z.boolean().optional(), models: z.array(ModelDefinitionSchema), }); const ModelsConfigSchema = z .object({ mode: z.union([z.literal("merge"), z.literal("replace")]).optional(), providers: z.record(z.string(), ModelProviderSchema).optional(), }) .optional(); const GroupChatSchema = z .object({ mentionPatterns: z.array(z.string()).optional(), historyLimit: z.number().int().positive().optional(), }) .optional(); const QueueModeSchema = z.union([ z.literal("steer"), z.literal("followup"), z.literal("collect"), z.literal("steer-backlog"), z.literal("steer+backlog"), z.literal("queue"), z.literal("interrupt"), ]); const QueueDropSchema = z.union([ z.literal("old"), z.literal("new"), z.literal("summarize"), ]); const ReplyToModeSchema = z.union([ z.literal("off"), z.literal("first"), z.literal("all"), ]); const QueueModeBySurfaceSchema = z .object({ whatsapp: QueueModeSchema.optional(), telegram: QueueModeSchema.optional(), discord: QueueModeSchema.optional(), signal: QueueModeSchema.optional(), imessage: QueueModeSchema.optional(), webchat: QueueModeSchema.optional(), }) .optional(); const TranscribeAudioSchema = z .object({ command: z.array(z.string()), timeoutSeconds: z.number().int().positive().optional(), }) .optional(); const HexColorSchema = z .string() .regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)"); const SessionSchema = z .object({ scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), heartbeatIdleMinutes: z.number().int().positive().optional(), store: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), mainKey: z.string().optional(), sendPolicy: z .object({ 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({ surface: z.string().optional(), chatType: z .union([ z.literal("direct"), z.literal("group"), z.literal("room"), ]) .optional(), keyPrefix: z.string().optional(), }) .optional(), }), ) .optional(), }) .optional(), }) .optional(); const MessagesSchema = z .object({ messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), timestampPrefix: z.union([z.boolean(), z.string()]).optional(), }) .optional(); const HeartbeatSchema = z .object({ every: z.string().optional(), model: z.string().optional(), target: z .union([ z.literal("last"), z.literal("whatsapp"), z.literal("telegram"), z.literal("discord"), z.literal("signal"), z.literal("imessage"), z.literal("none"), ]) .optional(), to: z.string().optional(), prompt: z.string().optional(), }) .superRefine((val, ctx) => { if (!val.every) return; try { parseDurationMs(val.every, { defaultUnit: "m" }); } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["every"], message: "invalid duration (use ms, s, m, h)", }); } }) .optional(); const RoutingSchema = z .object({ groupChat: GroupChatSchema, transcribeAudio: TranscribeAudioSchema, queue: z .object({ mode: QueueModeSchema.optional(), bySurface: QueueModeBySurfaceSchema, debounceMs: z.number().int().nonnegative().optional(), cap: z.number().int().positive().optional(), drop: QueueDropSchema.optional(), }) .optional(), }) .optional(); const HookMappingSchema = z .object({ id: z.string().optional(), match: z .object({ path: z.string().optional(), source: z.string().optional(), }) .optional(), action: z.union([z.literal("wake"), z.literal("agent")]).optional(), wakeMode: z .union([z.literal("now"), z.literal("next-heartbeat")]) .optional(), name: z.string().optional(), sessionKey: z.string().optional(), messageTemplate: z.string().optional(), textTemplate: z.string().optional(), deliver: z.boolean().optional(), channel: z .union([ z.literal("last"), z.literal("whatsapp"), z.literal("telegram"), z.literal("discord"), z.literal("signal"), z.literal("imessage"), ]) .optional(), to: z.string().optional(), thinking: z.string().optional(), timeoutSeconds: z.number().int().positive().optional(), transform: z .object({ module: z.string(), export: z.string().optional(), }) .optional(), }) .optional(); const HooksGmailSchema = z .object({ account: z.string().optional(), label: z.string().optional(), topic: z.string().optional(), subscription: z.string().optional(), pushToken: z.string().optional(), hookUrl: z.string().optional(), includeBody: z.boolean().optional(), maxBytes: z.number().int().positive().optional(), renewEveryMinutes: z.number().int().positive().optional(), serve: z .object({ bind: z.string().optional(), port: z.number().int().positive().optional(), path: z.string().optional(), }) .optional(), tailscale: z .object({ mode: z .union([z.literal("off"), z.literal("serve"), z.literal("funnel")]) .optional(), path: z.string().optional(), }) .optional(), }) .optional(); export const ClawdisSchema = z.object({ identity: z .object({ name: z.string().optional(), theme: z.string().optional(), emoji: z.string().optional(), }) .optional(), wizard: z .object({ lastRunAt: z.string().optional(), lastRunVersion: z.string().optional(), lastRunCommit: z.string().optional(), lastRunCommand: z.string().optional(), lastRunMode: z .union([z.literal("local"), z.literal("remote")]) .optional(), }) .optional(), logging: z .object({ level: z .union([ z.literal("silent"), z.literal("fatal"), z.literal("error"), z.literal("warn"), z.literal("info"), z.literal("debug"), z.literal("trace"), ]) .optional(), file: z.string().optional(), consoleLevel: z .union([ z.literal("silent"), z.literal("fatal"), z.literal("error"), z.literal("warn"), z.literal("info"), z.literal("debug"), z.literal("trace"), ]) .optional(), consoleStyle: z .union([z.literal("pretty"), z.literal("compact"), z.literal("json")]) .optional(), }) .optional(), browser: z .object({ enabled: z.boolean().optional(), controlUrl: z.string().optional(), cdpUrl: z.string().optional(), color: z.string().optional(), executablePath: z.string().optional(), headless: z.boolean().optional(), noSandbox: z.boolean().optional(), attachOnly: z.boolean().optional(), }) .optional(), ui: z .object({ seamColor: HexColorSchema.optional(), }) .optional(), models: ModelsConfigSchema, agent: z .object({ model: z.string().optional(), workspace: z.string().optional(), allowedModels: z.array(z.string()).optional(), modelAliases: z.record(z.string(), z.string()).optional(), contextTokens: z.number().int().positive().optional(), thinkingDefault: z .union([ z.literal("off"), z.literal("minimal"), z.literal("low"), z.literal("medium"), z.literal("high"), ]) .optional(), verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), blockStreamingDefault: z .union([z.literal("off"), z.literal("on")]) .optional(), blockStreamingBreak: z .union([z.literal("text_end"), z.literal("message_end")]) .optional(), blockStreamingChunk: 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(), }) .optional(), timeoutSeconds: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(), heartbeat: HeartbeatSchema, maxConcurrent: z.number().int().positive().optional(), bash: z .object({ backgroundMs: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(), }) .optional(), sandbox: z .object({ mode: z .union([z.literal("off"), z.literal("non-main"), z.literal("all")]) .optional(), perSession: z.boolean().optional(), workspaceRoot: z.string().optional(), docker: z .object({ image: z.string().optional(), containerPrefix: z.string().optional(), workdir: z.string().optional(), readOnlyRoot: z.boolean().optional(), tmpfs: z.array(z.string()).optional(), network: z.string().optional(), user: z.string().optional(), capDrop: z.array(z.string()).optional(), env: z.record(z.string(), z.string()).optional(), setupCommand: z.string().optional(), }) .optional(), browser: z .object({ enabled: z.boolean().optional(), image: z.string().optional(), containerPrefix: z.string().optional(), cdpPort: z.number().int().positive().optional(), vncPort: z.number().int().positive().optional(), noVncPort: z.number().int().positive().optional(), headless: z.boolean().optional(), enableNoVnc: z.boolean().optional(), }) .optional(), tools: z .object({ allow: z.array(z.string()).optional(), deny: z.array(z.string()).optional(), }) .optional(), prune: z .object({ idleHours: z.number().int().nonnegative().optional(), maxAgeDays: z.number().int().nonnegative().optional(), }) .optional(), }) .optional(), }) .optional(), routing: RoutingSchema, messages: MessagesSchema, session: SessionSchema, cron: z .object({ enabled: z.boolean().optional(), store: z.string().optional(), maxConcurrentRuns: z.number().int().positive().optional(), }) .optional(), hooks: z .object({ enabled: z.boolean().optional(), path: z.string().optional(), token: z.string().optional(), maxBodyBytes: z.number().int().positive().optional(), presets: z.array(z.string()).optional(), transformsDir: z.string().optional(), mappings: z.array(HookMappingSchema).optional(), gmail: HooksGmailSchema, }) .optional(), web: z .object({ enabled: z.boolean().optional(), heartbeatSeconds: z.number().int().positive().optional(), reconnect: z .object({ initialMs: z.number().positive().optional(), maxMs: z.number().positive().optional(), factor: z.number().positive().optional(), jitter: z.number().min(0).max(1).optional(), maxAttempts: z.number().int().min(0).optional(), }) .optional(), }) .optional(), whatsapp: z .object({ allowFrom: z.array(z.string()).optional(), textChunkLimit: z.number().int().positive().optional(), groups: z .record( z.string(), z .object({ requireMention: z.boolean().optional(), }) .optional(), ) .optional(), }) .optional(), telegram: z .object({ enabled: z.boolean().optional(), botToken: z.string().optional(), tokenFile: z.string().optional(), replyToMode: ReplyToModeSchema.optional(), groups: z .record( z.string(), z .object({ requireMention: z.boolean().optional(), }) .optional(), ) .optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), webhookUrl: z.string().optional(), webhookSecret: z.string().optional(), webhookPath: z.string().optional(), }) .optional(), discord: z .object({ enabled: z.boolean().optional(), token: z.string().optional(), textChunkLimit: z.number().int().positive().optional(), slashCommand: z .object({ enabled: z.boolean().optional(), name: z.string().optional(), sessionPrefix: z.string().optional(), ephemeral: z.boolean().optional(), }) .optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), actions: z .object({ reactions: z.boolean().optional(), stickers: z.boolean().optional(), polls: z.boolean().optional(), permissions: z.boolean().optional(), messages: z.boolean().optional(), threads: z.boolean().optional(), pins: z.boolean().optional(), search: z.boolean().optional(), memberInfo: z.boolean().optional(), roleInfo: z.boolean().optional(), roles: z.boolean().optional(), channelInfo: z.boolean().optional(), voiceStatus: z.boolean().optional(), events: z.boolean().optional(), moderation: z.boolean().optional(), }) .optional(), replyToMode: ReplyToModeSchema.optional(), dm: z .object({ enabled: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), }) .optional(), guilds: z .record( z.string(), 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(), z .object({ allow: z.boolean().optional(), requireMention: z.boolean().optional(), }) .optional(), ) .optional(), }) .optional(), ) .optional(), }) .optional(), signal: z .object({ enabled: z.boolean().optional(), account: z.string().optional(), httpUrl: z.string().optional(), httpHost: z.string().optional(), httpPort: z.number().int().positive().optional(), cliPath: z.string().optional(), autoStart: z.boolean().optional(), receiveMode: z .union([z.literal("on-start"), z.literal("manual")]) .optional(), ignoreAttachments: z.boolean().optional(), ignoreStories: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), }) .optional(), imessage: z .object({ enabled: z.boolean().optional(), cliPath: z.string().optional(), dbPath: z.string().optional(), service: z .union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]) .optional(), region: z.string().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), includeAttachments: z.boolean().optional(), mediaMaxMb: z.number().positive().optional(), textChunkLimit: z.number().int().positive().optional(), groups: z .record( z.string(), z .object({ requireMention: z.boolean().optional(), }) .optional(), ) .optional(), }) .optional(), bridge: z .object({ enabled: z.boolean().optional(), port: z.number().int().positive().optional(), bind: z .union([ z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback"), ]) .optional(), }) .optional(), discovery: z .object({ wideArea: z .object({ enabled: z.boolean().optional(), }) .optional(), }) .optional(), canvasHost: z .object({ enabled: z.boolean().optional(), root: z.string().optional(), port: z.number().int().positive().optional(), }) .optional(), talk: z .object({ voiceId: z.string().optional(), voiceAliases: z.record(z.string(), z.string()).optional(), modelId: z.string().optional(), outputFormat: z.string().optional(), apiKey: z.string().optional(), interruptOnSpeech: z.boolean().optional(), }) .optional(), gateway: z .object({ port: z.number().int().positive().optional(), mode: z.union([z.literal("local"), z.literal("remote")]).optional(), bind: z .union([ z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback"), ]) .optional(), controlUi: z .object({ enabled: z.boolean().optional(), basePath: z.string().optional(), }) .optional(), auth: z .object({ mode: z.union([z.literal("token"), z.literal("password")]).optional(), token: z.string().optional(), password: z.string().optional(), allowTailscale: z.boolean().optional(), }) .optional(), tailscale: z .object({ mode: z .union([z.literal("off"), z.literal("serve"), z.literal("funnel")]) .optional(), resetOnExit: z.boolean().optional(), }) .optional(), remote: z .object({ url: z.string().optional(), token: z.string().optional(), password: z.string().optional(), }) .optional(), reload: z .object({ mode: z .union([ z.literal("off"), z.literal("restart"), z.literal("hot"), z.literal("hybrid"), ]) .optional(), debounceMs: z.number().int().min(0).optional(), }) .optional(), }) .optional(), skills: z .object({ allowBundled: z.array(z.string()).optional(), load: z .object({ extraDirs: z.array(z.string()).optional(), }) .optional(), install: z .object({ preferBrew: z.boolean().optional(), nodeManager: z .union([ z.literal("npm"), z.literal("pnpm"), z.literal("yarn"), z.literal("bun"), ]) .optional(), }) .optional(), entries: z .record( z.string(), z .object({ enabled: z.boolean().optional(), apiKey: z.string().optional(), env: z.record(z.string(), z.string()).optional(), }) .passthrough(), ) .optional(), }) .optional(), }); export type ConfigValidationIssue = { path: string; message: string; }; export type LegacyConfigIssue = { path: string; message: string; }; export type ConfigFileSnapshot = { path: string; exists: boolean; raw: string | null; parsed: unknown; valid: boolean; config: ClawdisConfig; issues: ConfigValidationIssue[]; legacyIssues: LegacyConfigIssue[]; }; type LegacyConfigRule = { path: string[]; message: string; }; type LegacyConfigMigration = { id: string; describe: string; apply: (raw: Record, changes: string[]) => void; }; const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ { path: ["routing", "allowFrom"], message: "routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).", }, { path: ["routing", "groupChat", "requireMention"], message: 'routing.groupChat.requireMention was removed; use whatsapp/telegram/imessage groups defaults (e.g. whatsapp.groups."*".requireMention) instead (run `clawdis doctor` to migrate).', }, { path: ["telegram", "requireMention"], message: 'telegram.requireMention was removed; use telegram.groups."*".requireMention instead (run `clawdis doctor` to migrate).', }, ]; const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ { id: "routing.allowFrom->whatsapp.allowFrom", describe: "Move routing.allowFrom to whatsapp.allowFrom", apply: (raw, changes) => { const routing = raw.routing; if (!routing || typeof routing !== "object") return; const allowFrom = (routing as Record).allowFrom; if (allowFrom === undefined) return; const whatsapp = raw.whatsapp && typeof raw.whatsapp === "object" ? (raw.whatsapp as Record) : {}; if (whatsapp.allowFrom === undefined) { whatsapp.allowFrom = allowFrom; changes.push("Moved routing.allowFrom → whatsapp.allowFrom."); } else { changes.push( "Removed routing.allowFrom (whatsapp.allowFrom already set).", ); } delete (routing as Record).allowFrom; if (Object.keys(routing as Record).length === 0) { delete raw.routing; } raw.whatsapp = whatsapp; }, }, { id: "routing.groupChat.requireMention->groups.*.requireMention", describe: "Move routing.groupChat.requireMention to whatsapp/telegram/imessage groups", apply: (raw, changes) => { const routing = raw.routing; if (!routing || typeof routing !== "object") return; const groupChat = (routing as Record).groupChat && typeof (routing as Record).groupChat === "object" ? ((routing as Record).groupChat as Record< string, unknown >) : null; if (!groupChat) return; const requireMention = groupChat.requireMention; if (requireMention === undefined) return; const applyTo = (key: "whatsapp" | "telegram" | "imessage") => { const section = raw[key] && typeof raw[key] === "object" ? (raw[key] as Record) : {}; const groups = section.groups && typeof section.groups === "object" ? (section.groups as Record) : {}; const defaultKey = "*"; const entry = groups[defaultKey] && typeof groups[defaultKey] === "object" ? (groups[defaultKey] as Record) : {}; if (entry.requireMention === undefined) { entry.requireMention = requireMention; groups[defaultKey] = entry; section.groups = groups; raw[key] = section; changes.push( `Moved routing.groupChat.requireMention → ${key}.groups."*".requireMention.`, ); } else { changes.push( `Removed routing.groupChat.requireMention (${key}.groups."*" already set).`, ); } }; applyTo("whatsapp"); applyTo("telegram"); applyTo("imessage"); delete groupChat.requireMention; if (Object.keys(groupChat).length === 0) { delete (routing as Record).groupChat; } if (Object.keys(routing as Record).length === 0) { delete raw.routing; } }, }, { id: "telegram.requireMention->telegram.groups.*.requireMention", describe: "Move telegram.requireMention to telegram.groups.*.requireMention", apply: (raw, changes) => { const telegram = raw.telegram; if (!telegram || typeof telegram !== "object") return; const requireMention = (telegram as Record) .requireMention; if (requireMention === undefined) return; const groups = (telegram as Record).groups && typeof (telegram as Record).groups === "object" ? ((telegram as Record).groups as Record< string, unknown >) : {}; const defaultKey = "*"; const entry = groups[defaultKey] && typeof groups[defaultKey] === "object" ? (groups[defaultKey] as Record) : {}; if (entry.requireMention === undefined) { entry.requireMention = requireMention; groups[defaultKey] = entry; (telegram as Record).groups = groups; changes.push( 'Moved telegram.requireMention → telegram.groups."*".requireMention.', ); } else { changes.push( 'Removed telegram.requireMention (telegram.groups."*" already set).', ); } delete (telegram as Record).requireMention; if (Object.keys(telegram as Record).length === 0) { delete raw.telegram; } }, }, ]; function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { if (!raw || typeof raw !== "object") return []; const root = raw as Record; const issues: LegacyConfigIssue[] = []; for (const rule of LEGACY_CONFIG_RULES) { let cursor: unknown = root; for (const key of rule.path) { if (!cursor || typeof cursor !== "object") { cursor = undefined; break; } cursor = (cursor as Record)[key]; } if (cursor !== undefined) { issues.push({ path: rule.path.join("."), message: rule.message }); } } return issues; } export function migrateLegacyConfig(raw: unknown): { config: ClawdisConfig | null; changes: string[]; } { if (!raw || typeof raw !== "object") return { config: null, changes: [] }; const next = structuredClone(raw) as Record; const changes: string[] = []; for (const migration of LEGACY_CONFIG_MIGRATIONS) { migration.apply(next, changes); } if (changes.length === 0) return { config: null, changes: [] }; const validated = validateConfigObject(next); if (!validated.ok) { changes.push( "Migration applied, but config still invalid; fix remaining issues manually.", ); return { config: null, changes }; } return { config: validated.config, changes }; } function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig { const identity = cfg.identity; if (!identity) return cfg; const name = identity.name?.trim(); const routing = cfg.routing ?? {}; const groupChat = routing.groupChat ?? {}; let mutated = false; const next: ClawdisConfig = { ...cfg }; if (name && !groupChat.mentionPatterns) { const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp); const re = parts.length ? parts.join("\\s+") : escapeRegExp(name); const pattern = `\\b@?${re}\\b`; next.routing = { ...(next.routing ?? routing), groupChat: { ...groupChat, mentionPatterns: [pattern] }, }; mutated = true; } return mutated ? next : cfg; } function applySessionDefaults(cfg: ClawdisConfig): ClawdisConfig { const session = cfg.session; if (!session || session.mainKey === undefined) return cfg; const trimmed = session.mainKey.trim(); const next: ClawdisConfig = { ...cfg, session: { ...session } }; next.session.mainKey = "main"; if (trimmed && trimmed !== "main" && !warnedMainKeyOverride) { warnedMainKeyOverride = true; console.warn('session.mainKey is ignored; main session is always "main".'); } return next; } export function loadConfig(): ClawdisConfig { // Read config file (JSON5) if present. const configPath = CONFIG_PATH_CLAWDIS; try { if (!fs.existsSync(configPath)) return {}; const raw = fs.readFileSync(configPath, "utf-8"); const parsed = JSON5.parse(raw); if (typeof parsed !== "object" || parsed === null) return {}; const validated = ClawdisSchema.safeParse(parsed); if (!validated.success) { console.error("Invalid config:"); for (const iss of validated.error.issues) { console.error(`- ${iss.path.join(".")}: ${iss.message}`); } return {}; } return applySessionDefaults( applyIdentityDefaults(validated.data as ClawdisConfig), ); } catch (err) { console.error(`Failed to read config at ${configPath}`, err); return {}; } } export function validateConfigObject( raw: unknown, ): | { ok: true; config: ClawdisConfig } | { ok: false; issues: ConfigValidationIssue[] } { const legacyIssues = findLegacyConfigIssues(raw); if (legacyIssues.length > 0) { return { ok: false, issues: legacyIssues.map((iss) => ({ path: iss.path, message: iss.message, })), }; } const validated = ClawdisSchema.safeParse(raw); if (!validated.success) { return { ok: false, issues: validated.error.issues.map((iss) => ({ path: iss.path.join("."), message: iss.message, })), }; } return { ok: true, config: applySessionDefaults( applyIdentityDefaults(validated.data as ClawdisConfig), ), }; } export function parseConfigJson5( raw: string, ): { ok: true; parsed: unknown } | { ok: false; error: string } { try { return { ok: true, parsed: JSON5.parse(raw) as unknown }; } catch (err) { return { ok: false, error: String(err) }; } } function readTalkApiKeyFromProfile(): string | null { const home = os.homedir(); const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map( (name) => path.join(home, name), ); for (const candidate of candidates) { if (!fs.existsSync(candidate)) continue; try { const text = fs.readFileSync(candidate, "utf-8"); const match = text.match( /(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/, ); const value = match?.[1]?.trim(); if (value) return value; } catch { // Ignore profile read errors. } } return null; } function resolveTalkApiKey(): string | null { const envValue = (process.env.ELEVENLABS_API_KEY ?? "").trim(); if (envValue) return envValue; return readTalkApiKeyFromProfile(); } function applyTalkApiKey(config: ClawdisConfig): ClawdisConfig { const resolved = resolveTalkApiKey(); if (!resolved) return config; const existing = config.talk?.apiKey?.trim(); if (existing) return config; return { ...config, talk: { ...config.talk, apiKey: resolved, }, }; } export async function readConfigFileSnapshot(): Promise { const configPath = CONFIG_PATH_CLAWDIS; const exists = fs.existsSync(configPath); if (!exists) { const config = applyTalkApiKey(applySessionDefaults({})); const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, exists: false, raw: null, parsed: {}, valid: true, config, issues: [], legacyIssues, }; } try { const raw = fs.readFileSync(configPath, "utf-8"); const parsedRes = parseConfigJson5(raw); if (!parsedRes.ok) { return { path: configPath, exists: true, raw, parsed: {}, valid: false, config: {}, issues: [ { path: "", message: `JSON5 parse failed: ${parsedRes.error}` }, ], legacyIssues: [], }; } const legacyIssues = findLegacyConfigIssues(parsedRes.parsed); const validated = validateConfigObject(parsedRes.parsed); if (!validated.ok) { return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: false, config: {}, issues: validated.issues, legacyIssues, }; } return { path: configPath, exists: true, raw, parsed: parsedRes.parsed, valid: true, config: applyTalkApiKey(applySessionDefaults(validated.config)), issues: [], legacyIssues, }; } catch (err) { return { path: configPath, exists: true, raw: null, parsed: {}, valid: false, config: {}, issues: [{ path: "", message: `read failed: ${String(err)}` }], legacyIssues: [], }; } } export async function writeConfigFile(cfg: ClawdisConfig) { await fs.promises.mkdir(path.dirname(CONFIG_PATH_CLAWDIS), { recursive: true, }); const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); await fs.promises.writeFile(CONFIG_PATH_CLAWDIS, json, "utf-8"); }