From c9504a6f203e19987d0ca2880d5d3c7b7a80abc3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Jan 2026 07:05:04 +0100 Subject: [PATCH] refactor: split config module --- src/auto-reply/reply.ts | 11 +- src/auto-reply/reply/commands.ts | 8 +- src/auto-reply/reply/session.ts | 5 +- src/auto-reply/status.ts | 2 +- src/config/config.ts | 2073 +----------------------------- src/config/defaults.ts | 83 ++ src/config/io.ts | 188 +++ src/config/legacy-migrate.ts | 19 + src/config/legacy.ts | 202 +++ src/config/paths.ts | 69 + src/config/schema.ts | 2 +- src/config/talk.ts | 45 + src/config/types.ts | 764 +++++++++++ src/config/validation.ts | 37 + src/config/zod-schema.ts | 798 ++++++++++++ src/web/auto-reply.test.ts | 12 +- 16 files changed, 2236 insertions(+), 2082 deletions(-) create mode 100644 src/config/defaults.ts create mode 100644 src/config/io.ts create mode 100644 src/config/legacy-migrate.ts create mode 100644 src/config/legacy.ts create mode 100644 src/config/paths.ts create mode 100644 src/config/talk.ts create mode 100644 src/config/types.ts create mode 100644 src/config/validation.ts create mode 100644 src/config/zod-schema.ts diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 6f3935b90..d24e48b20 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -107,7 +107,7 @@ function resolveElevatedAllowList( return allowFrom?.telegram; case "discord": { const hasExplicit = Boolean( - allowFrom && Object.prototype.hasOwnProperty.call(allowFrom, "discord"), + allowFrom && Object.hasOwn(allowFrom, "discord"), ); if (hasExplicit) return allowFrom?.discord; return discordFallback; @@ -308,7 +308,7 @@ export async function getReplyFromConfig( : "on"; const resolvedBlockStreaming = agentCfg?.blockStreamingDefault === "off" ? "off" : "on"; - const resolvedBlockStreamingBreak = + const resolvedBlockStreamingBreak: "text_end" | "message_end" = agentCfg?.blockStreamingBreak === "message_end" ? "message_end" : "text_end"; @@ -468,9 +468,10 @@ export async function getReplyFromConfig( const isGroupChat = sessionCtx.ChatType === "group"; const wasMentioned = ctx.WasMentioned === true; const shouldEagerType = !isGroupChat || wasMentioned; - const shouldInjectGroupIntro = + const shouldInjectGroupIntro = Boolean( isGroupChat && - (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro); + (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), + ); const groupIntro = shouldInjectGroupIntro ? buildGroupIntro({ sessionCtx, @@ -626,7 +627,7 @@ export async function getReplyFromConfig( ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined, extraSystemPrompt: groupIntro || undefined, - enforceFinalTag: provider === "ollama" ? true : undefined, + ...(provider === "ollama" ? { enforceFinalTag: true } : {}), }, }; diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index c36d71628..2e9bc2151 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -1,5 +1,9 @@ import type { ClawdisConfig } from "../../config/config.js"; -import { type SessionEntry, saveSessionStore } from "../../config/sessions.js"; +import { + type SessionEntry, + type SessionScope, + saveSessionStore, +} from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { triggerClawdisRestart } from "../../infra/restart.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; @@ -101,7 +105,7 @@ export async function handleCommands(params: { sessionStore?: Record; sessionKey?: string; storePath?: string; - sessionScope: string; + sessionScope?: SessionScope; workspaceDir: string; defaultGroupActivation: () => "always" | "mention"; resolvedThinkLevel?: ThinkLevel; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 131972c3a..d22a7ad8e 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -11,6 +11,7 @@ import { resolveSessionKey, resolveStorePath, type SessionEntry, + type SessionScope, saveSessionStore, } from "../../config/sessions.js"; import type { MsgContext, TemplateContext } from "../templating.js"; @@ -26,7 +27,7 @@ export type SessionInitResult = { systemSent: boolean; abortedLastRun: boolean; storePath: string; - sessionScope: string; + sessionScope: SessionScope; groupResolution?: GroupKeyResolution; isGroup: boolean; bodyStripped?: string; @@ -66,7 +67,7 @@ export async function initSessionState(params: { let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; - const groupResolution = resolveGroupSessionKey(ctx); + const groupResolution = resolveGroupSessionKey(ctx) ?? undefined; const isGroup = ctx.ChatType?.trim().toLowerCase() === "group" || Boolean(groupResolution); const triggerBodyNormalized = stripStructuralPrefixes(ctx.Body ?? "") diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index ba1a2ed73..7f73db652 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -162,7 +162,7 @@ export function buildStatusMessage(args: StatusArgs): string { const verboseLevel = args.resolvedVerbose ?? args.agent?.verboseDefault ?? "off"; const elevatedLevel = - args.entry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "on"; + args.sessionEntry?.elevatedLevel ?? args.agent?.elevatedDefault ?? "on"; const webLine = (() => { if (args.webLinked === false) { diff --git a/src/config/config.ts b/src/config/config.ts index 2a6bec89f..1c927efcf 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,2061 +1,12 @@ -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; - agentToAgent?: { - /** Max ping-pong turns between requester/target (0–5). Default: 5. */ - maxPingPongTurns?: number; - }; -}; - -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 AgentElevatedAllowFromConfig = { - whatsapp?: string[]; - telegram?: Array; - discord?: Array; - signal?: Array; - imessage?: Array; - webchat?: Array; -}; - -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 BrowserProfileConfig = { - /** CDP port for this profile. Allocated once at creation, persisted permanently. */ - cdpPort?: number; - /** CDP URL for this profile (use for remote Chrome). */ - cdpUrl?: string; - /** Profile color (hex). Auto-assigned at creation. */ - color: string; -}; -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; - /** Default profile to use when profile param is omitted. Default: "clawd" */ - defaultProfile?: string; - /** Named browser profiles with explicit CDP ports or URLs. */ - profiles?: Record; -}; - -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; - emojiUploads?: boolean; - stickerUploads?: 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 elevated level when no /elevated directive is present. */ - elevatedDefault?: "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; - }; - /** Elevated bash permissions for the host machine. */ - elevated?: { - /** Enable or disable elevated mode (default: true). */ - enabled?: boolean; - /** Approved senders for /elevated (per-surface allowlists). */ - allowFrom?: AgentElevatedAllowFromConfig; - }; - /** 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(), - agentToAgent: z - .object({ - maxPingPongTurns: z.number().int().min(0).max(5).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(), - defaultProfile: z.string().optional(), - profiles: z - .record( - z - .string() - .regex( - /^[a-z0-9-]+$/, - "Profile names must be alphanumeric with hyphens only", - ), - z - .object({ - cdpPort: z.number().int().min(1).max(65535).optional(), - cdpUrl: z.string().optional(), - color: HexColorSchema, - }) - .refine((value) => value.cdpPort || value.cdpUrl, { - message: "Profile must set cdpPort or cdpUrl", - }), - ) - .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(), - elevatedDefault: 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(), - elevated: z - .object({ - enabled: z.boolean().optional(), - allowFrom: z - .object({ - whatsapp: z.array(z.string()).optional(), - telegram: z.array(z.union([z.string(), z.number()])).optional(), - discord: z.array(z.union([z.string(), z.number()])).optional(), - signal: z.array(z.union([z.string(), z.number()])).optional(), - imessage: z.array(z.union([z.string(), z.number()])).optional(), - webchat: z.array(z.union([z.string(), z.number()])).optional(), - }) - .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, 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"); -} +export { + createConfigIO, + loadConfig, + parseConfigJson5, + readConfigFileSnapshot, + writeConfigFile, +} from "./io.js"; +export { migrateLegacyConfig } from "./legacy-migrate.js"; +export * from "./paths.js"; +export * from "./types.js"; +export { validateConfigObject } from "./validation.js"; +export { ClawdisSchema } from "./zod-schema.js"; diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 000000000..0195e588d --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,83 @@ +import { resolveTalkApiKey } from "./talk.js"; +import type { ClawdisConfig } from "./types.js"; + +type WarnState = { warned: boolean }; + +let defaultWarnState: WarnState = { warned: false }; + +export type SessionDefaultsOptions = { + warn?: (message: string) => void; + warnState?: WarnState; +}; + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export 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; +} + +export function applySessionDefaults( + cfg: ClawdisConfig, + options: SessionDefaultsOptions = {}, +): ClawdisConfig { + const session = cfg.session; + if (!session || session.mainKey === undefined) return cfg; + + const trimmed = session.mainKey.trim(); + const warn = options.warn ?? console.warn; + const warnState = options.warnState ?? defaultWarnState; + + const next: ClawdisConfig = { + ...cfg, + session: { ...session, mainKey: "main" }, + }; + + if (trimmed && trimmed !== "main" && !warnState.warned) { + warnState.warned = true; + warn('session.mainKey is ignored; main session is always "main".'); + } + + return next; +} + +export 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 function resetSessionDefaultsWarningForTests() { + defaultWarnState = { warned: false }; +} diff --git a/src/config/io.ts b/src/config/io.ts new file mode 100644 index 000000000..3c4e0cbc7 --- /dev/null +++ b/src/config/io.ts @@ -0,0 +1,188 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import JSON5 from "json5"; + +import { + applyIdentityDefaults, + applySessionDefaults, + applyTalkApiKey, +} from "./defaults.js"; +import { findLegacyConfigIssues } from "./legacy.js"; +import { + CONFIG_PATH_CLAWDIS, + resolveConfigPath, + resolveStateDir, +} from "./paths.js"; +import type { + ClawdisConfig, + ConfigFileSnapshot, + LegacyConfigIssue, +} from "./types.js"; +import { validateConfigObject } from "./validation.js"; +import { ClawdisSchema } from "./zod-schema.js"; + +export type ParseConfigJson5Result = + | { ok: true; parsed: unknown } + | { ok: false; error: string }; + +export type ConfigIoDeps = { + fs?: typeof fs; + json5?: typeof JSON5; + env?: NodeJS.ProcessEnv; + homedir?: () => string; + configPath?: string; + logger?: Pick; +}; + +function resolveConfigPathForDeps(deps: Required): string { + if (deps.configPath) return deps.configPath; + return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir)); +} + +function normalizeDeps(overrides: ConfigIoDeps = {}): Required { + return { + fs: overrides.fs ?? fs, + json5: overrides.json5 ?? JSON5, + env: overrides.env ?? process.env, + homedir: overrides.homedir ?? os.homedir, + configPath: overrides.configPath ?? "", + logger: overrides.logger ?? console, + }; +} + +export function parseConfigJson5( + raw: string, + json5: { parse: (value: string) => unknown } = JSON5, +): ParseConfigJson5Result { + try { + return { ok: true, parsed: json5.parse(raw) as unknown }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + +export function createConfigIO(overrides: ConfigIoDeps = {}) { + const deps = normalizeDeps(overrides); + const configPath = resolveConfigPathForDeps(deps); + + function loadConfig(): ClawdisConfig { + try { + if (!deps.fs.existsSync(configPath)) return {}; + const raw = deps.fs.readFileSync(configPath, "utf-8"); + const parsed = deps.json5.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + const validated = ClawdisSchema.safeParse(parsed); + if (!validated.success) { + deps.logger.error("Invalid config:"); + for (const iss of validated.error.issues) { + deps.logger.error(`- ${iss.path.join(".")}: ${iss.message}`); + } + return {}; + } + return applySessionDefaults( + applyIdentityDefaults(validated.data as ClawdisConfig), + ); + } catch (err) { + deps.logger.error(`Failed to read config at ${configPath}`, err); + return {}; + } + } + + async function readConfigFileSnapshot(): Promise { + const exists = deps.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 = deps.fs.readFileSync(configPath, "utf-8"); + const parsedRes = parseConfigJson5(raw, deps.json5); + 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: [], + }; + } + } + + async function writeConfigFile(cfg: ClawdisConfig) { + await deps.fs.promises.mkdir(path.dirname(configPath), { + recursive: true, + }); + const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n"); + await deps.fs.promises.writeFile(configPath, json, "utf-8"); + } + + return { + configPath, + loadConfig, + readConfigFileSnapshot, + writeConfigFile, + }; +} + +const defaultIO = createConfigIO({ configPath: CONFIG_PATH_CLAWDIS }); + +export const loadConfig = defaultIO.loadConfig; +export const readConfigFileSnapshot = defaultIO.readConfigFileSnapshot; +export const writeConfigFile = defaultIO.writeConfigFile; diff --git a/src/config/legacy-migrate.ts b/src/config/legacy-migrate.ts new file mode 100644 index 000000000..c06011d3e --- /dev/null +++ b/src/config/legacy-migrate.ts @@ -0,0 +1,19 @@ +import { applyLegacyMigrations } from "./legacy.js"; +import type { ClawdisConfig } from "./types.js"; +import { validateConfigObject } from "./validation.js"; + +export function migrateLegacyConfig(raw: unknown): { + config: ClawdisConfig | null; + changes: string[]; +} { + const { next, changes } = applyLegacyMigrations(raw); + if (!next) 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 }; +} diff --git a/src/config/legacy.ts b/src/config/legacy.ts new file mode 100644 index 000000000..c8d6a2fc9 --- /dev/null +++ b/src/config/legacy.ts @@ -0,0 +1,202 @@ +import type { LegacyConfigIssue } from "./types.js"; + +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; + } + }, + }, +]; + +export 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 applyLegacyMigrations(raw: unknown): { + next: Record | null; + changes: string[]; +} { + if (!raw || typeof raw !== "object") return { next: 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 { next: null, changes: [] }; + return { next, changes }; +} diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 000000000..cd4494da9 --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,69 @@ +import os from "node:os"; +import path from "node:path"; + +import type { ClawdisConfig } from "./types.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 function resolveIsNixMode( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env.CLAWDIS_NIX_MODE === "1"; +} + +export const isNixMode = resolveIsNixMode(); + +/** + * State directory for mutable data (sessions, logs, caches). + * Can be overridden via CLAWDIS_STATE_DIR environment variable. + * Default: ~/.clawdis + */ +export function resolveStateDir( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + const override = env.CLAWDIS_STATE_DIR?.trim(); + if (override) return override; + return path.join(homedir(), ".clawdis"); +} + +export const STATE_DIR_CLAWDIS = resolveStateDir(); + +/** + * Config file path (JSON5). + * Can be overridden via CLAWDIS_CONFIG_PATH environment variable. + * Default: ~/.clawdis/clawdis.json (or $CLAWDIS_STATE_DIR/clawdis.json) + */ +export function resolveConfigPath( + env: NodeJS.ProcessEnv = process.env, + stateDir: string = resolveStateDir(env, os.homedir), +): string { + const override = env.CLAWDIS_CONFIG_PATH?.trim(); + if (override) return override; + return path.join(stateDir, "clawdis.json"); +} + +export const CONFIG_PATH_CLAWDIS = resolveConfigPath(); + +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; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index f076c9ba6..e89803841 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,5 +1,5 @@ import { VERSION } from "../version.js"; -import { ClawdisSchema } from "./config.js"; +import { ClawdisSchema } from "./zod-schema.js"; export type ConfigUiHint = { label?: string; diff --git a/src/config/talk.ts b/src/config/talk.ts new file mode 100644 index 000000000..84ec01867 --- /dev/null +++ b/src/config/talk.ts @@ -0,0 +1,45 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +type TalkApiKeyDeps = { + fs?: typeof fs; + os?: typeof os; + path?: typeof path; +}; + +export function readTalkApiKeyFromProfile( + deps: TalkApiKeyDeps = {}, +): string | null { + const fsImpl = deps.fs ?? fs; + const osImpl = deps.os ?? os; + const pathImpl = deps.path ?? path; + + const home = osImpl.homedir(); + const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map( + (name) => pathImpl.join(home, name), + ); + for (const candidate of candidates) { + if (!fsImpl.existsSync(candidate)) continue; + try { + const text = fsImpl.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; +} + +export function resolveTalkApiKey( + env: NodeJS.ProcessEnv = process.env, + deps: TalkApiKeyDeps = {}, +): string | null { + const envValue = (env.ELEVENLABS_API_KEY ?? "").trim(); + if (envValue) return envValue; + return readTalkApiKeyFromProfile(deps); +} diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 000000000..37dfc9853 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,764 @@ +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; + agentToAgent?: { + /** Max ping-pong turns between requester/target (0–5). Default: 5. */ + maxPingPongTurns?: number; + }; +}; + +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 AgentElevatedAllowFromConfig = { + whatsapp?: string[]; + telegram?: Array; + discord?: Array; + signal?: Array; + imessage?: Array; + webchat?: Array; +}; + +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 BrowserProfileConfig = { + /** CDP port for this profile. Allocated once at creation, persisted permanently. */ + cdpPort?: number; + /** CDP URL for this profile (use for remote Chrome). */ + cdpUrl?: string; + /** Profile color (hex). Auto-assigned at creation. */ + color: string; +}; +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; + /** Default profile to use when profile param is omitted. Default: "clawd" */ + defaultProfile?: string; + /** Named browser profiles with explicit CDP ports or URLs. */ + profiles?: Record; +}; + +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; + emojiUploads?: boolean; + stickerUploads?: 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 elevated level when no /elevated directive is present. */ + elevatedDefault?: "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; + }; + /** Elevated bash permissions for the host machine. */ + elevated?: { + /** Enable or disable elevated mode (default: true). */ + enabled?: boolean; + /** Approved senders for /elevated (per-surface allowlists). */ + allowFrom?: AgentElevatedAllowFromConfig; + }; + /** 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; +}; + +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[]; +}; diff --git a/src/config/validation.ts b/src/config/validation.ts new file mode 100644 index 000000000..e205452f1 --- /dev/null +++ b/src/config/validation.ts @@ -0,0 +1,37 @@ +import { applyIdentityDefaults, applySessionDefaults } from "./defaults.js"; +import { findLegacyConfigIssues } from "./legacy.js"; +import type { ClawdisConfig, ConfigValidationIssue } from "./types.js"; +import { ClawdisSchema } from "./zod-schema.js"; + +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), + ), + }; +} diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts new file mode 100644 index 000000000..f0f7e6c90 --- /dev/null +++ b/src/config/zod-schema.ts @@ -0,0 +1,798 @@ +import { z } from "zod"; + +import { parseDurationMs } from "../cli/parse-duration.js"; + +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(), + agentToAgent: z + .object({ + maxPingPongTurns: z.number().int().min(0).max(5).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(), + defaultProfile: z.string().optional(), + profiles: z + .record( + z + .string() + .regex( + /^[a-z0-9-]+$/, + "Profile names must be alphanumeric with hyphens only", + ), + z + .object({ + cdpPort: z.number().int().min(1).max(65535).optional(), + cdpUrl: z.string().optional(), + color: HexColorSchema, + }) + .refine((value) => value.cdpPort || value.cdpUrl, { + message: "Profile must set cdpPort or cdpUrl", + }), + ) + .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(), + elevatedDefault: 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(), + elevated: z + .object({ + enabled: z.boolean().optional(), + allowFrom: z + .object({ + whatsapp: z.array(z.string()).optional(), + telegram: z.array(z.union([z.string(), z.number()])).optional(), + discord: z.array(z.union([z.string(), z.number()])).optional(), + signal: z.array(z.union([z.string(), z.number()])).optional(), + imessage: z.array(z.union([z.string(), z.number()])).optional(), + webchat: z.array(z.union([z.string(), z.number()])).optional(), + }) + .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(), +}); diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index e6668ac87..61964f736 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -378,16 +378,8 @@ describe("web auto-reply", () => { await vi.advanceTimersByTimeAsync(31 * 60 * 1000); await Promise.resolve(); - const waitForSecondCall = async () => { - const started = Date.now(); - while ( - listenerFactory.mock.calls.length < 2 && - Date.now() - started < 200 - ) { - await Promise.resolve(); - } - }; - await waitForSecondCall(); + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); expect(listenerFactory).toHaveBeenCalledTimes(2); controller.abort();