diff --git a/docs/config-reference.md b/docs/config-reference.md index 97de6f8..b69cd45 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -61,11 +61,13 @@ and `project` are required. Everything else is optional and has sensible default "toolsProfile": "full", "sandbox": false }, - "telegram": { - "botToken": "123456:ABC-...", - "allowFrom": ["123456789"], - "groups": { - "-100123456789": { "requireMention": true } + "channels": { + "telegram": { + "botToken": "123456:ABC-...", + "allowFrom": ["123456789"], + "groups": { + "-100123456789": { "requireMention": true } + } } } } @@ -190,17 +192,76 @@ Agent behavior configuration. Applied during bootstrap (when `provider` is prese | `toolsProfile` | string | `"full"` | Agent tools profile (`"full"`, `"coding"`, `"messaging"`, etc.). | | `sandbox` | boolean | — | Set to `false` to disable sandbox mode (`agents.defaults.sandbox.mode off`). | -## `telegram` +## `channels` -Telegram channel configuration (optional). Applied during bootstrap after onboarding. +Communication channels. Each key is a channel name; the value is +channel-specific config. Applied during bootstrap via `openclaw config set`. + +Known channels with wizard support: **telegram**, **discord**, **slack**, **whatsapp**. +Unknown channel names are accepted — they pass through to OpenClaw directly. + +### `enabled` field + +Every channel supports an `enabled` boolean: + +| `enabled` value | Behavior | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `true` | Channel is configured during bootstrap. | +| omitted | Treated as `true` — presence of the channel key means "enable it." | +| `false` | Channel is skipped during bootstrap. Config is preserved (useful for temporarily disabling a channel without deleting its credentials). | + +### `channels.telegram` | Field | Type | Description | | ---------------------------- | -------- | ------------------------------------------------ | +| `enabled` | boolean | Enable this channel (default: `true`). | | `botToken` | string | Bot token from BotFather (required). | | `allowFrom` | string[] | Telegram user IDs allowed to DM the bot. | | `groups` | object | Group IDs and their settings. | | `groups..requireMention` | boolean | Whether the bot requires @mention in this group. | +### `channels.discord` + +| Field | Type | Description | +| --------- | ------- | -------------------------------------- | +| `enabled` | boolean | Enable this channel (default: `true`). | +| `token` | string | Discord bot token (required). | + +### `channels.slack` + +| Field | Type | Description | +| ---------- | ------- | ------------------------------------------------------ | +| `enabled` | boolean | Enable this channel (default: `true`). | +| `botToken` | string | Slack Bot Token `xoxb-...` (required). | +| `appToken` | string | Slack App Token `xapp-...` for Socket Mode (required). | + +### `channels.whatsapp` + +| Field | Type | Description | +| --------- | ------- | -------------------------------------- | +| `enabled` | boolean | Enable this channel (default: `true`). | + +No credentials needed — uses QR code pairing after provisioning: +`clawctl oc -i channels login --channel whatsapp` + +Additional channel-specific fields beyond the essentials listed above are +accepted and passed through to OpenClaw. See +[OpenClaw channel docs](https://docs.openclaw.ai/channels) for all options. + +## `openclaw` + +Arbitrary OpenClaw config passthrough. Each key is a dotpath, applied via +`openclaw config set` during bootstrap. No host-side validation — OpenClaw +validates at daemon restart. + +```json +"openclaw": { + "channels.discord.streaming": "partial", + "channels.discord.voice.enabled": true, + "session.dmScope": "per-channel-peer" +} +``` + ## Secret references String values in the config can use URI references instead of plaintext secrets: @@ -216,5 +277,5 @@ secrets and can be safely committed to git. See - [`config.json`](../examples/config.json) — minimal (name + project only) - [`config.bootstrap.json`](../examples/config.bootstrap.json) — minimal working gateway (name + project + API key) -- [`config.full.json`](../examples/config.full.json) — all options including provider + telegram +- [`config.full.json`](../examples/config.full.json) — all options including provider + channels - [`config.op.json`](../examples/config.op.json) — zero-plaintext secrets using `op://` and `env://` references diff --git a/examples/config.full.json b/examples/config.full.json index d24811e..d3eb683 100644 --- a/examples/config.full.json +++ b/examples/config.full.json @@ -46,11 +46,14 @@ } }, - "telegram": { - "botToken": "123456:ABC-...", - "allowFrom": ["123456789"], - "groups": { - "-100123456789": { "requireMention": true } + "channels": { + "telegram": { + "enabled": true, + "botToken": "123456:ABC-...", + "allowFrom": ["123456789"], + "groups": { + "-100123456789": { "requireMention": true } + } } } } diff --git a/examples/config.op.json b/examples/config.op.json index 88a1516..3e2f1f2 100644 --- a/examples/config.op.json +++ b/examples/config.op.json @@ -10,7 +10,10 @@ "type": "zai", "apiKey": "op://Sam/z.AI API Key/credential" }, - "telegram": { - "botToken": "op://Sam/Telegram Token/credential" + "channels": { + "telegram": { + "enabled": true, + "botToken": "op://Sam/Telegram Token/credential" + } } } diff --git a/packages/cli/src/components/capability-section.tsx b/packages/cli/src/components/capability-section.tsx index 30ba75b..00b24d3 100644 --- a/packages/cli/src/components/capability-section.tsx +++ b/packages/cli/src/components/capability-section.tsx @@ -86,6 +86,18 @@ export function CapabilitySection({ ); } + if (field.type === "toggle") { + const checked = values[path] === "true"; + return ( + + + {isFocused ? "\u25b8 " : " "} + {checked ? "[x]" : "[ ]"} {field.label} + + + ); + } + if ((field.type === "text" || field.type === "password") && isEditing) { return ( diff --git a/packages/cli/src/components/config-review.tsx b/packages/cli/src/components/config-review.tsx index f89f14c..e3c8826 100644 --- a/packages/cli/src/components/config-review.tsx +++ b/packages/cli/src/components/config-review.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Text, Box } from "ink"; import type { InstanceConfig } from "@clawctl/types"; +import { CHANNEL_REGISTRY, CHANNEL_ORDER } from "@clawctl/types"; import { ALL_CAPABILITIES } from "@clawctl/capabilities"; interface ConfigReviewProps { @@ -123,15 +124,32 @@ export function ConfigReview({ config, validationErrors, validationWarnings }: C )} - - {config.telegram ? ( - {"\u2713"} configured - ) : ( - - {"\u2500\u2500"} not configured {"\u2500\u2500"} - - )} - + {/* Channel rows */} + {CHANNEL_ORDER.map((name) => { + const def = CHANNEL_REGISTRY[name]; + if (!def) return null; + const chConfig = config.channels?.[name]; + const isConfigured = chConfig !== undefined; + const summary = + isConfigured && def.configDef.summary + ? def.configDef.summary( + typeof chConfig === "object" ? (chConfig as Record) : {}, + ) + : null; + return ( + + {isConfigured ? ( + + {"\u2713"} {summary || "configured"} + + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + ); + })} = { "personalize interactions.", ], }, - telegram: { - title: "Telegram", - lines: [ - "Connect a Telegram bot for", - "chat-based agent control.", - "", - "Requires a bot token from", - "@BotFather on Telegram.", - "", - "allowFrom: Telegram user IDs", - "groups: Group IDs + settings", - ], - }, review: { title: "Review", lines: [ diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index bde70bb..27a930c 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -13,27 +13,84 @@ import { providerSchema, ALL_PROVIDER_TYPES, DEFAULT_PROJECT_BASE, + CHANNEL_REGISTRY, + CHANNEL_ORDER, } from "@clawctl/types"; -import type { InstanceConfig } from "@clawctl/types"; +import type { InstanceConfig, CapabilityConfigDef } from "@clawctl/types"; import { ALL_CAPABILITIES } from "@clawctl/capabilities"; type Phase = "form" | "review"; // --------------------------------------------------------------------------- -// Focus list: hardcoded core sections + dynamic capability sections +// Dynamic sections: unified abstraction for capabilities and channels // --------------------------------------------------------------------------- -/** Core sections that are hardcoded in the wizard. */ -type CoreSectionId = "resources" | "provider" | "network" | "bootstrap" | "telegram"; +/** + * A wizard section driven by a configDef — shared by capabilities and channels. + * + * All focus-list, sidebar, select-field, and rendering logic operates on + * DynamicSection uniformly. No string prefix slicing needed in consumer code. + */ +interface DynamicSection { + kind: "capability" | "channel"; + /** Unique key used in focus IDs, e.g. "cap:tailscale" or "ch:telegram". */ + key: string; + /** Name used in the config object, e.g. "tailscale" or "telegram". */ + name: string; + configDef: CapabilityConfigDef; +} + +const CONFIGURABLE_CAPABILITIES = ALL_CAPABILITIES.filter((c) => !c.core && c.configDef); -const CORE_SECTIONS: CoreSectionId[] = [ - "resources", - "provider", - "network", - "bootstrap", - "telegram", +const DYNAMIC_SECTIONS: DynamicSection[] = [ + ...CONFIGURABLE_CAPABILITIES.map( + (c): DynamicSection => ({ + kind: "capability", + key: `cap:${c.name}`, + name: c.name, + configDef: c.configDef!, + }), + ), + ...CHANNEL_ORDER.map((name) => CHANNEL_REGISTRY[name]) + .filter(Boolean) + .map( + (ch): DynamicSection => ({ + kind: "channel", + key: `ch:${ch.name}`, + name: ch.name, + configDef: ch.configDef, + }), + ), ]; +/** Look up the DynamicSection that owns a focus ID (header or field). */ +function findSection(focusId: string): DynamicSection | undefined { + // Exact match → section header + const exact = DYNAMIC_SECTIONS.find((s) => s.key === focusId); + if (exact) return exact; + // Prefix match → field within section + return DYNAMIC_SECTIONS.find((s) => focusId.startsWith(s.key + ":")); +} + +/** Extract the field path from a section field focus ID, or null for headers. */ +function fieldPathOf(section: DynamicSection, focusId: string): string | null { + const prefix = section.key + ":"; + return focusId.startsWith(prefix) ? focusId.slice(prefix.length) : null; +} + +/** Child focus IDs for a dynamic section. */ +function dynamicChildren(section: DynamicSection): string[] { + return section.configDef.fields.map((f) => `${section.key}:${f.path as string}`); +} + +// --------------------------------------------------------------------------- +// Core sections (hardcoded layout, not driven by configDef) +// --------------------------------------------------------------------------- + +type CoreSectionId = "resources" | "provider" | "network" | "bootstrap"; + +const CORE_SECTIONS: CoreSectionId[] = ["resources", "provider", "network", "bootstrap"]; + const CORE_SECTION_CHILDREN: Record = { resources: ["resources.cpus", "resources.memory", "resources.disk"], provider: [ @@ -50,51 +107,51 @@ const CORE_SECTION_CHILDREN: Record = { "bootstrap.userName", "bootstrap.userContext", ], - telegram: ["telegram.botToken", "telegram.allowFrom"], }; -/** Non-core capabilities that have a configDef (rendered dynamically). */ -const CONFIGURABLE_CAPABILITIES = ALL_CAPABILITIES.filter((c) => !c.core && c.configDef); +// --------------------------------------------------------------------------- +// Focus list +// --------------------------------------------------------------------------- -/** - * All section IDs in visual render order. - * Capability sections appear after network, before bootstrap/telegram. - */ +/** All section IDs in visual render order. */ function allSectionIds(): string[] { - return [ - "resources", - "provider", - "network", - ...CONFIGURABLE_CAPABILITIES.map((c) => `cap:${c.name}`), - "bootstrap", - "telegram", - ]; + return ["resources", "provider", "network", ...DYNAMIC_SECTIONS.map((s) => s.key), "bootstrap"]; } -/** Children focus IDs for a section (core or capability). */ function sectionChildren(sectionId: string): string[] { if (sectionId in CORE_SECTION_CHILDREN) { return CORE_SECTION_CHILDREN[sectionId as CoreSectionId]; } - // Dynamic capability section: cap: → cap:: - const capName = sectionId.replace("cap:", ""); - const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === capName); - if (!cap?.configDef) return []; - return cap.configDef.fields.map((f) => `cap:${capName}:${f.path as string}`); + const section = DYNAMIC_SECTIONS.find((s) => s.key === sectionId); + return section ? dynamicChildren(section) : []; } function buildFocusList(expanded: Set): string[] { const list: string[] = ["name", "project"]; - for (const section of allSectionIds()) { - list.push(section); - if (expanded.has(section)) { - list.push(...sectionChildren(section)); + for (const sectionId of allSectionIds()) { + list.push(sectionId); + if (expanded.has(sectionId)) { + list.push(...sectionChildren(sectionId)); } } list.push("action"); return list; } +function isSection(id: string): boolean { + return (CORE_SECTIONS as string[]).includes(id) || DYNAMIC_SECTIONS.some((s) => s.key === id); +} + +/** Look up the field type for a dynamic section field focus ID. */ +function dynamicFieldType(focusId: string): string | undefined { + const section = findSection(focusId); + if (!section) return undefined; + const path = fieldPathOf(section, focusId); + if (!path) return undefined; + const field = section.configDef.fields.find((f) => (f.path as string) === path); + return field?.type; +} + const MEMORY_OPTIONS = ["4GiB", "8GiB", "16GiB", "32GiB"]; const DISK_OPTIONS = ["30GiB", "50GiB", "100GiB", "200GiB"]; @@ -120,7 +177,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const [providerBaseUrl, setProviderBaseUrl] = useState(""); const [providerModelId, setProviderModelId] = useState(""); - // Network (gateway port only — tailscale moved to capability) + // Network const [gatewayPort, setGatewayPort] = useState("18789"); // Bootstrap @@ -129,12 +186,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const [userName, setUserName] = useState(""); const [userContext, setUserContext] = useState(""); - // Telegram - const [botToken, setBotToken] = useState(""); - const [allowFrom, setAllowFrom] = useState(""); - - // Capability config values: { "tailscale": { "authKey": "...", "mode": "serve" }, ... } - const [capValues, setCapValues] = useState>>({}); + // Dynamic section values: keyed by section.key → { fieldPath: value } + const [dynamicValues, setDynamicValues] = useState>>({}); // Navigation const [expanded, setExpanded] = useState>(new Set()); @@ -143,17 +196,15 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const [selectingProviderType, setSelectingProviderType] = useState(false); const [selectingMemory, setSelectingMemory] = useState(false); const [selectingDisk, setSelectingDisk] = useState(false); - // For capability select fields: "cap:tailscale:mode" or null - const [selectingCapField, setSelectingCapField] = useState(null); + const [selectingDynamicField, setSelectingDynamicField] = useState(null); const focusList = useMemo(() => buildFocusList(expanded), [expanded]); const currentFocus = focusList[focusIdx] ?? "name"; - // Helper to update a single capability field value - const setCapValue = (capName: string, fieldPath: string, value: string) => { - setCapValues((prev) => ({ + const setDynamicValue = (sectionKey: string, fieldPath: string, value: string) => { + setDynamicValues((prev) => ({ ...prev, - [capName]: { ...(prev[capName] ?? {}), [fieldPath]: value }, + [sectionKey]: { ...(prev[sectionKey] ?? {}), [fieldPath]: value }, })); }; @@ -194,33 +245,36 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { }; } - if (botToken) { - config.telegram = { - botToken, - ...(allowFrom - ? { - allowFrom: allowFrom - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - } - : {}), - }; - } + // Dynamic section values → capabilities or channels + for (const section of DYNAMIC_SECTIONS) { + const vals = dynamicValues[section.key]; + if (!vals || !Object.values(vals).some((v) => v)) continue; + // Channels require enabled — skip if toggled off or never toggled + if (section.kind === "channel" && vals.enabled !== "true") continue; - // Capability config from dynamic sections - for (const cap of CONFIGURABLE_CAPABILITIES) { - const vals = capValues[cap.name]; - if (!vals) continue; - const hasAnyValue = Object.values(vals).some((v) => v); - if (!hasAnyValue) continue; - if (!config.capabilities) config.capabilities = {}; - // Build config object from field values, filtering empty strings - const capConfig: Record = {}; + const sectionConfig: Record = {}; for (const [k, v] of Object.entries(vals)) { - if (v) capConfig[k] = v; + if (!v) continue; + const field = section.configDef.fields.find((f) => (f.path as string) === k); + if (field?.type === "toggle") { + sectionConfig[k] = v === "true"; + } else if (field?.type === "text" && v.includes(",")) { + sectionConfig[k] = v + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } else { + sectionConfig[k] = v; + } + } + + if (section.kind === "capability") { + config.capabilities ??= {}; + config.capabilities[section.name] = sectionConfig; + } else { + config.channels ??= {}; + config.channels[section.name] = sectionConfig; } - config.capabilities[cap.name] = capConfig; } return config; @@ -235,7 +289,6 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { if (!config.name) errors.push("Instance name is required"); if (!config.project) errors.push("Project directory is required"); - // Validate provider section if partially filled if (config.provider) { const provResult = providerSchema.safeParse(config.provider); if (!provResult.success) { @@ -245,7 +298,6 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } } - // Full schema validation if (config.name && config.project) { const result = instanceConfigSchema.safeParse(config); if (!result.success) { @@ -266,41 +318,33 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { return { errors, warnings }; }; - // Sidebar content: check hardcoded help first, then capability configDef help + // Sidebar: hardcoded help, then dynamic section/field help const getSidebarContent = (): SidebarContent => { if (phase === "review") return SIDEBAR_HELP["review"]; - - // Direct match in hardcoded help if (SIDEBAR_HELP[currentFocus]) return SIDEBAR_HELP[currentFocus]; - // Capability field help: cap:: - if (currentFocus.startsWith("cap:")) { - const parts = currentFocus.split(":"); - if (parts.length === 3) { - const [, capName, fieldPath] = parts; - const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === capName); - const field = cap?.configDef?.fields.find((f) => (f.path as string) === fieldPath); + const section = findSection(currentFocus); + if (section) { + const path = fieldPathOf(section, currentFocus); + if (path) { + const field = section.configDef.fields.find((f) => (f.path as string) === path); if (field?.help) return field.help; - } - // Capability section help: cap: - if (parts.length === 2) { - const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === parts[1]); - if (cap?.configDef?.sectionHelp) return cap.configDef.sectionHelp; + } else if (section.configDef.sectionHelp) { + return section.configDef.sectionHelp; } } - // Fall back to section-level help const sectionKey = currentFocus.split(".")[0]; return SIDEBAR_HELP[sectionKey] ?? SIDEBAR_HELP["name"]; }; const sidebarContent = getSidebarContent(); - // Section status helpers (core sections only) + // Core section status/summary const coreSectionStatus = (id: CoreSectionId): "unconfigured" | "configured" | "error" => { switch (id) { case "resources": - return "configured"; // always has defaults + return "configured"; case "provider": return providerType ? "configured" : "unconfigured"; case "network": { @@ -309,8 +353,6 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } case "bootstrap": return agentName ? "configured" : "unconfigured"; - case "telegram": - return botToken ? "configured" : "unconfigured"; } }; @@ -324,59 +366,32 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { return gatewayPort !== "18789" ? `port ${gatewayPort}` : "defaults"; case "bootstrap": return agentName || ""; - case "telegram": - return botToken ? "configured" : ""; } }; const toggleSection = (id: string) => { setExpanded((prev) => { const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); }; const isSelectMode = - selectingProviderType || selectingMemory || selectingDisk || selectingCapField !== null; + selectingProviderType || selectingMemory || selectingDisk || selectingDynamicField !== null; - /** Check if a focus ID is a section header (core or capability). */ - const isSection = (id: string): boolean => { - return ( - (CORE_SECTIONS as string[]).includes(id) || - (id.startsWith("cap:") && id.split(":").length === 2) - ); - }; - - /** Check if a capability select field is active for a given focus ID. */ - const isCapSelectField = (focusId: string): boolean => { - if (!focusId.startsWith("cap:")) return false; - const parts = focusId.split(":"); - if (parts.length !== 3) return false; - const [, capName, fieldPath] = parts; - const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === capName); - const field = cap?.configDef?.fields.find((f) => (f.path as string) === fieldPath); - return field?.type === "select"; - }; - - /** Find the parent section for a focus ID. */ const findParentSection = (focusId: string): string | null => { for (const sectionId of allSectionIds()) { - if (sectionChildren(sectionId).includes(focusId)) { - return sectionId; - } + if (sectionChildren(sectionId).includes(focusId)) return sectionId; } return null; }; - // Handle input + // Input handling useInput( (input, key) => { - if (isSelectMode) return; // SelectInput handles its own input + if (isSelectMode) return; if (phase === "review") { if (key.escape) { @@ -385,9 +400,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } if (key.return) { const { errors } = validate(); - if (errors.length === 0) { - onComplete(buildConfig()); - } + if (errors.length === 0) onComplete(buildConfig()); return; } if (input.toLowerCase() === "s" && onSaveOnly) { @@ -397,11 +410,9 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { return; } - // Form mode if (editing) { if (key.return || key.escape) { setEditing(false); - // Auto-fill project if empty if (currentFocus === "name" && !project && name) { setProject(`${DEFAULT_PROJECT_BASE}/${name.trim()}`); } @@ -424,13 +435,18 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { setSelectingMemory(true); } else if (currentFocus === "resources.disk") { setSelectingDisk(true); - } else if (isCapSelectField(currentFocus)) { - setSelectingCapField(currentFocus); + } else if (dynamicFieldType(currentFocus) === "select") { + setSelectingDynamicField(currentFocus); + } else if (dynamicFieldType(currentFocus) === "toggle") { + // Toggle boolean value + const section = findSection(currentFocus)!; + const path = fieldPathOf(section, currentFocus)!; + const current = dynamicValues[section.key]?.[path]; + setDynamicValue(section.key, path, current === "true" ? "false" : "true"); } else { setEditing(true); } } else if (key.escape) { - // Collapse parent section const parent = findParentSection(currentFocus); if (parent) { toggleSection(parent); @@ -445,7 +461,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { { isActive: !isSelectMode }, ); - // Render the review screen + // Review screen if (phase === "review") { const { errors, warnings } = validate(); return ( @@ -464,7 +480,6 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { ); } - // Determine field status const fieldStatus = (id: string) => { if (currentFocus === id && editing) return "editing" as const; if (currentFocus === id) return "focused" as const; @@ -473,6 +488,25 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const providerTypeItems = ALL_PROVIDER_TYPES.map((t) => ({ label: t, value: t })); + // Resolve focused field within a dynamic section for CapabilitySection props + const dynamicSectionProps = (section: DynamicSection) => { + const vals = dynamicValues[section.key] ?? {}; + const focusedField = fieldPathOf(section, currentFocus); + const selectingField = + selectingDynamicField != null ? fieldPathOf(section, selectingDynamicField) : null; + return { + configDef: section.configDef, + values: vals, + onChange: (path: string, value: string) => setDynamicValue(section.key, path, value), + focused: currentFocus === section.key, + expanded: expanded.has(section.key), + focusedField, + editing, + selectingField, + onSelectDone: () => setSelectingDynamicField(null), + }; + }; + return ( @@ -535,6 +569,13 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Sections */} + {/* ── Infrastructure ── */} + + + {"\u2500\u2500"} Infrastructure {"\u2500\u2500"} + + + {/* Resources */} - {/* Network (gateway port only) */} + {/* Network */} - {/* Dynamic capability sections */} - {CONFIGURABLE_CAPABILITIES.map((cap) => { - const sectionId = `cap:${cap.name}`; - const capVals = capValues[cap.name] ?? {}; - // Determine which field within this capability is focused - let focusedField: string | null = null; - if (currentFocus.startsWith(`cap:${cap.name}:`)) { - focusedField = currentFocus.split(":").slice(2).join(":"); - } - return ( - setCapValue(cap.name, path, value)} - focused={currentFocus === sectionId} - expanded={expanded.has(sectionId)} - focusedField={focusedField} - editing={editing} - selectingField={ - selectingCapField?.startsWith(`cap:${cap.name}:`) - ? selectingCapField.split(":").slice(2).join(":") - : null - } - onSelectDone={() => setSelectingCapField(null)} - /> - ); - })} + {/* Dynamic capability sections (under Infrastructure) */} + {DYNAMIC_SECTIONS.filter((s) => s.kind === "capability").map((section) => ( + + ))} + + {/* ── Channels ── */} + + + {"\u2500\u2500"} Channels {"\u2500\u2500"} + + + + {DYNAMIC_SECTIONS.filter((s) => s.kind === "channel").map((section) => ( + + ))} + + {/* ── Identity ── */} + + + {"\u2500\u2500"} Identity {"\u2500\u2500"} + + {/* Bootstrap / Agent Identity */} )} - - {/* Telegram */} - - {currentFocus === "telegram.botToken" && editing ? ( - - - Bot Token - - - - ) : ( - - )} - {currentFocus === "telegram.allowFrom" && editing ? ( - - - Allow From - - - - ) : ( - - )} - @@ -879,7 +869,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { - {/* Keybinding hints (pinned bottom) */} + {/* Keybinding hints */} [{"\u2191\u2193"}] navigate {"\u00b7"} [Enter] {editing ? "confirm" : "edit/expand"}{" "} @@ -888,7 +878,6 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { - {/* Sidebar */} diff --git a/packages/host-core/src/bootstrap.ts b/packages/host-core/src/bootstrap.ts index 25dbd21..3e012a9 100644 --- a/packages/host-core/src/bootstrap.ts +++ b/packages/host-core/src/bootstrap.ts @@ -2,13 +2,13 @@ import { randomBytes } from "crypto"; import { mkdir, rm } from "fs/promises"; import { join } from "path"; import type { VMDriver, OnLine } from "./drivers/types.js"; -import { GATEWAY_PORT, CLAW_BIN_PATH } from "@clawctl/types"; +import { GATEWAY_PORT, CLAW_BIN_PATH, CHANNEL_REGISTRY } from "@clawctl/types"; +import type { InstanceConfig, ChannelDef } from "@clawctl/types"; import { buildOnboardCommand } from "./providers.js"; import { patchMainConfig, patchAuthProfiles } from "./infra-secrets.js"; import { generateBootstrapPrompt } from "@clawctl/templates"; import { redactSecrets } from "./redact.js"; import { getTailscaleHostname } from "./tailscale.js"; -import type { InstanceConfig } from "@clawctl/types"; import type { ResolvedSecretRef } from "./secrets.js"; export interface BootstrapResult { @@ -68,7 +68,7 @@ export async function bootstrapOpenclaw( // c) Post-onboard config (including gateway token — must be before daemon // restart so the daemon picks it up) const gatewayToken = config.network?.gatewayToken ?? randomBytes(24).toString("hex"); - const secrets = [gatewayToken, config.telegram?.botToken].filter(Boolean) as string[]; + const secrets = [gatewayToken, ...collectChannelSecrets(config)].filter(Boolean) as string[]; const safeLog = (msg: string) => onLine?.(redactSecrets(msg, secrets)); const configCmds: string[] = []; @@ -108,41 +108,28 @@ export async function bootstrapOpenclaw( } } - // d) Telegram setup (plaintext — must run before secret migration) - if (config.telegram) { - onLine?.("Configuring Telegram..."); - const tg = config.telegram; - const tgCmds: string[] = [ - "openclaw config set channels.telegram.enabled true", - `openclaw config set channels.telegram.botToken "${tg.botToken}"`, - ]; - - // Set allowFrom before dmPolicy — openclaw validates that allowlist - // mode has at least one sender ID - if (tg.allowFrom?.length) { - const allowJson = JSON.stringify(tg.allowFrom); - tgCmds.push(`openclaw config set channels.telegram.allowFrom '${allowJson}'`); - } - - tgCmds.push("openclaw config set channels.telegram.dmPolicy allowlist"); - tgCmds.push("openclaw config set plugins.entries.telegram.enabled true"); - - if (tg.groups) { - const groupIds = Object.keys(tg.groups); - if (groupIds.length > 0) { - const groupAllowJson = JSON.stringify(groupIds); - tgCmds.push(`openclaw config set channels.telegram.groupAllowFrom '${groupAllowJson}'`); - } - for (const [id, settings] of Object.entries(tg.groups)) { - if (settings.requireMention !== undefined) { - tgCmds.push( - `openclaw config set channels.telegram.groups.${id}.requireMention ${settings.requireMention}`, - ); + // d) Channel setup (plaintext — must run before secret migration) + if (config.channels) { + for (const [channelName, channelConfig] of Object.entries(config.channels)) { + if (channelConfig.enabled === false) continue; + onLine?.(`Configuring ${channelName}...`); + const cmds = buildChannelCommands(channelName, channelConfig); + for (const cmd of cmds) { + safeLog(` ${cmd}`); + const r = await driver.exec(vmName, cmd); + if (r.exitCode !== 0) { + safeLog(` Warning: ${cmd} failed: ${r.stderr}`); } } } + } - for (const cmd of tgCmds) { + // d2) OpenClaw passthrough config + if (config.openclaw && Object.keys(config.openclaw).length > 0) { + onLine?.("Applying openclaw config overrides..."); + for (const [path, value] of Object.entries(config.openclaw)) { + const serialized = serializeConfigValue(value); + const cmd = `openclaw config set ${path} ${serialized}`; safeLog(` ${cmd}`); const r = await driver.exec(vmName, cmd); if (r.exitCode !== 0) { @@ -243,3 +230,95 @@ export async function bootstrapOpenclaw( doctorPassed, }; } + +// --------------------------------------------------------------------------- +// Channel command generation +// --------------------------------------------------------------------------- + +/** Serialize a config value for `openclaw config set`. */ +function serializeConfigValue(value: unknown): string { + if (typeof value === "string") return `"${value}"`; + if (typeof value === "boolean" || typeof value === "number") return String(value); + return `'${JSON.stringify(value)}'`; +} + +/** + * Walk a config object and generate `openclaw config set` commands for each leaf. + * Arrays and plain objects are JSON-encoded as single values. + */ +function configToCommands(prefix: string, obj: Record): string[] { + const cmds: string[] = []; + for (const [key, value] of Object.entries(obj)) { + const path = `${prefix}.${key}`; + if (value === null || value === undefined) continue; + if (Array.isArray(value) || typeof value !== "object") { + cmds.push(`openclaw config set ${path} ${serializeConfigValue(value)}`); + } else { + // Nested object — recurse + cmds.push(...configToCommands(path, value as Record)); + } + } + return cmds; +} + +/** + * Build the full list of `openclaw config set` commands for a channel. + * + * 1. Enable the channel and its plugin + * 2. Set each config field from the channel config object + * 3. Run channel-specific postCommands (e.g., Telegram's dmPolicy after allowFrom) + */ +function buildChannelCommands( + channelName: string, + channelConfig: Record, +): string[] { + const def: ChannelDef | undefined = CHANNEL_REGISTRY[channelName]; + const pluginName = def?.pluginName ?? channelName; + const enabled = channelConfig.enabled !== false; + const cmds: string[] = [ + `openclaw config set channels.${channelName}.enabled ${enabled}`, + `openclaw config set plugins.entries.${pluginName}.enabled ${enabled}`, + ]; + + // Keys handled by postCommands — skipped by the generic loop + const postHandledKeys = new Set(def?.postCommands?.handledKeys ?? []); + + // Apply config fields (skip enabled + postCommands-handled keys) + for (const [key, value] of Object.entries(channelConfig)) { + if (key === "enabled") continue; + if (postHandledKeys.has(key)) continue; + if (value === null || value === undefined) continue; + const path = `channels.${channelName}.${key}`; + if (Array.isArray(value) || typeof value !== "object") { + cmds.push(`openclaw config set ${path} ${serializeConfigValue(value)}`); + } else { + cmds.push(...configToCommands(path, value as Record)); + } + } + + // Channel-specific post commands (e.g., Telegram dmPolicy after allowFrom) + if (def?.postCommands) { + cmds.push(...def.postCommands.run(channelConfig)); + } + + return cmds; +} + +/** + * Collect all secret values from channel configs for log redaction. + */ +function collectChannelSecrets(config: InstanceConfig): string[] { + const secrets: string[] = []; + if (config.channels) { + for (const [channelName, channelConfig] of Object.entries(config.channels)) { + const def = CHANNEL_REGISTRY[channelName]; + if (!def) continue; + for (const field of def.configDef.fields) { + if (!field.secret) continue; + const value = channelConfig[field.path as string]; + if (typeof value === "string" && value) secrets.push(value); + } + } + } + return secrets; +} diff --git a/packages/host-core/src/config.test.ts b/packages/host-core/src/config.test.ts index da0412a..024fb19 100644 --- a/packages/host-core/src/config.test.ts +++ b/packages/host-core/src/config.test.ts @@ -38,10 +38,12 @@ describe("validateConfig", () => { ], agent: { skipOnboarding: true, toolsProfile: "full", sandbox: false }, provider: { type: "anthropic", apiKey: "sk-ant-xyz", model: "anthropic/claude-opus-4-6" }, - telegram: { - botToken: "123:ABC", - allowFrom: ["111"], - groups: { "-100": { requireMention: true } }, + channels: { + telegram: { + botToken: "123:ABC", + allowFrom: ["111"], + groups: { "-100": { requireMention: true } }, + }, }, }); @@ -61,8 +63,11 @@ describe("validateConfig", () => { expect(config.provider?.type).toBe("anthropic"); expect(config.provider?.apiKey).toBe("sk-ant-xyz"); expect(config.provider?.model).toBe("anthropic/claude-opus-4-6"); - expect(config.telegram?.botToken).toBe("123:ABC"); - expect(config.telegram?.groups?.["-100"]?.requireMention).toBe(true); + expect(config.channels?.telegram?.botToken).toBe("123:ABC"); + expect( + (config.channels?.telegram?.groups as Record>)?.["-100"] + ?.requireMention, + ).toBe(true); }); test("throws on missing name", () => { @@ -352,92 +357,64 @@ describe("validateConfig", () => { ); }); - // -- telegram --------------------------------------------------------------- + // -- channels ---------------------------------------------------------------- - test("accepts telegram with botToken only", () => { + test("accepts channels with telegram config", () => { const config = validateConfig({ name: "t", project: "/tmp", - telegram: { botToken: "123:ABC" }, + channels: { telegram: { botToken: "123:ABC" } }, }); - expect(config.telegram?.botToken).toBe("123:ABC"); - expect(config.telegram?.allowFrom).toBeUndefined(); - expect(config.telegram?.groups).toBeUndefined(); + expect(config.channels?.telegram?.botToken).toBe("123:ABC"); }); - test("accepts telegram with all fields", () => { + test("accepts channels with multiple channels", () => { const config = validateConfig({ name: "t", project: "/tmp", - telegram: { - botToken: "123:ABC", - allowFrom: ["111", "222"], - groups: { - "-100123": { requireMention: true }, - "-100456": {}, - }, + channels: { + telegram: { botToken: "123:ABC" }, + discord: { token: "MTIz..." }, + whatsapp: {}, }, }); - expect(config.telegram?.botToken).toBe("123:ABC"); - expect(config.telegram?.allowFrom).toEqual(["111", "222"]); - expect(config.telegram?.groups?.["-100123"]?.requireMention).toBe(true); - expect(config.telegram?.groups?.["-100456"]?.requireMention).toBeUndefined(); - }); - - test("throws on missing telegram.botToken", () => { - expect(() => validateConfig({ name: "t", project: "/tmp", telegram: {} })).toThrow("botToken"); - }); - - test("throws on empty telegram.botToken", () => { - expect(() => - validateConfig({ name: "t", project: "/tmp", telegram: { botToken: "" } }), - ).toThrow("botToken"); + expect(config.channels?.telegram?.botToken).toBe("123:ABC"); + expect(config.channels?.discord?.token).toBe("MTIz..."); + expect(config.channels?.whatsapp).toEqual({}); }); - test("throws on non-array telegram.allowFrom", () => { - expect(() => - validateConfig({ - name: "t", - project: "/tmp", - telegram: { botToken: "123:ABC", allowFrom: "111" }, - }), - ).toThrow("allowFrom"); - }); - - test("throws on non-string entry in telegram.allowFrom", () => { - expect(() => - validateConfig({ - name: "t", - project: "/tmp", - telegram: { botToken: "123:ABC", allowFrom: [111] }, - }), - ).toThrow("allowFrom"); + test("accepts channels with extra fields (passthrough)", () => { + const config = validateConfig({ + name: "t", + project: "/tmp", + channels: { + telegram: { botToken: "123:ABC", streaming: "partial", allowFrom: ["111"] }, + }, + }); + expect(config.channels?.telegram?.streaming).toBe("partial"); }); - test("throws on non-object telegram.groups", () => { - expect(() => - validateConfig({ - name: "t", - project: "/tmp", - telegram: { botToken: "123:ABC", groups: "bad" }, - }), - ).toThrow("groups"); + test("accepts unknown channel names permissively", () => { + const config = validateConfig({ + name: "t", + project: "/tmp", + channels: { matrix: { homeserver: "https://matrix.org", accessToken: "tok" } }, + }); + expect(config.channels?.matrix?.homeserver).toBe("https://matrix.org"); }); - test("throws on non-boolean telegram.groups.*.requireMention", () => { - expect(() => - validateConfig({ - name: "t", - project: "/tmp", - telegram: { botToken: "123:ABC", groups: { "-100": { requireMention: "yes" } } }, - }), - ).toThrow("requireMention"); - }); + // -- openclaw passthrough --------------------------------------------------- - test("throws on non-object telegram", () => { - expect(() => validateConfig({ name: "t", project: "/tmp", telegram: "bad" })).toThrow( - "telegram", - ); + test("accepts openclaw passthrough config", () => { + const config = validateConfig({ + name: "t", + project: "/tmp", + openclaw: { + "session.dmScope": "per-channel-peer", + "channels.discord.voice.enabled": true, + }, + }); + expect(config.openclaw?.["session.dmScope"]).toBe("per-channel-peer"); }); // -- op:// cross-validation ------------------------------------------------- @@ -554,15 +531,20 @@ describe("sanitizeConfig", () => { expect(net.gatewayPort).toBe(9000); }); - test("strips telegram.botToken", () => { + test("strips channel secrets (channels.telegram.botToken)", () => { const result = sanitizeConfig({ name: "t", project: "/tmp", - telegram: { botToken: "123:SECRET", allowFrom: ["111"] }, + channels: { + telegram: { botToken: "123:SECRET", allowFrom: ["111"] }, + discord: { token: "MTIz-SECRET" }, + }, }); - const tg = result.telegram as Record; + const tg = (result.channels as Record>).telegram; expect(tg.botToken).toBeUndefined(); expect(tg.allowFrom).toEqual(["111"]); + const dc = (result.channels as Record>).discord; + expect(dc.token).toBeUndefined(); }); test("strips bootstrap field", () => { diff --git a/packages/host-core/src/config.ts b/packages/host-core/src/config.ts index c429392..3586857 100644 --- a/packages/host-core/src/config.ts +++ b/packages/host-core/src/config.ts @@ -1,7 +1,7 @@ import { readFile } from "fs/promises"; -import { resolveEnvRefs, validateConfig } from "@clawctl/types"; +import { resolveEnvRefs, validateConfig, CHANNEL_REGISTRY } from "@clawctl/types"; import type { InstanceConfig, VMConfig, CapabilityDef } from "@clawctl/types"; -import { buildCapabilitiesSchema, getSecretPaths } from "./schema-derive.js"; +import { buildCapabilitiesSchema, buildChannelsSchema, getSecretPaths } from "./schema-derive.js"; // Re-export validateConfig from types for convenience export { validateConfig } from "@clawctl/types"; @@ -43,9 +43,18 @@ export function sanitizeConfig( delete (clone.network as Record).gatewayToken; } - // telegram.botToken - if (clone.telegram && typeof clone.telegram === "object") { - delete (clone.telegram as Record).botToken; + // Channel secrets (from ChannelDef fields marked secret: true) + if (clone.channels && typeof clone.channels === "object") { + const channels = clone.channels as Record; + for (const [channelName, channelConfig] of Object.entries(channels)) { + if (!channelConfig || typeof channelConfig !== "object") continue; + const def = CHANNEL_REGISTRY[channelName]; + if (!def) continue; + const secretPaths = def.configDef.fields.filter((f) => f.secret).map((f) => f.path as string); + for (const path of secretPaths) { + delete (channelConfig as Record)[path]; + } + } } // Capability secrets (from configDef fields marked secret: true) @@ -111,5 +120,7 @@ export async function loadConfig( } const capabilitySchema = capabilities ? buildCapabilitiesSchema(capabilities) : undefined; - return validateConfig(parsed, { capabilitySchema }); + const channelDefs = Object.values(CHANNEL_REGISTRY); + const channelSchema = channelDefs.length > 0 ? buildChannelsSchema(channelDefs) : undefined; + return validateConfig(parsed, { capabilitySchema, channelSchema }); } diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index e37d0f8..638bc87 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -15,6 +15,7 @@ export { loadConfig, validateConfig, configToVMConfig, sanitizeConfig } from "./ export { deriveConfigSchema, buildCapabilitiesSchema, + buildChannelsSchema, getSecretPaths, getByPath, setByPath, diff --git a/packages/host-core/src/infra-secrets.test.ts b/packages/host-core/src/infra-secrets.test.ts index 12dbd70..5cb32e3 100644 --- a/packages/host-core/src/infra-secrets.test.ts +++ b/packages/host-core/src/infra-secrets.test.ts @@ -15,9 +15,9 @@ describe("SecretRef construction", () => { expect(id).toBe("/provider_apikey"); }); - test("produces correct file provider ref for telegram botToken", () => { - const id = `/${sanitizeKey(["telegram", "botToken"])}`; - expect(id).toBe("/telegram_bottoken"); + test("produces correct file provider ref for channel botToken", () => { + const id = `/${sanitizeKey(["channels", "telegram", "botToken"])}`; + expect(id).toBe("/channels_telegram_bottoken"); }); test("produces correct ref shape", () => { diff --git a/packages/host-core/src/infra-secrets.ts b/packages/host-core/src/infra-secrets.ts index 6afa1d8..79ef257 100644 --- a/packages/host-core/src/infra-secrets.ts +++ b/packages/host-core/src/infra-secrets.ts @@ -2,7 +2,7 @@ * Post-onboard config patching: replace plaintext secrets with file provider SecretRefs. * * Two operations on known JSON paths: - * 1. Patch main config — add file provider, replace telegram botToken with SecretRef + * 1. Patch main config — add file provider, replace channel secrets with SecretRefs * 2. Patch auth-profiles.json — replace token with tokenRef */ import type { VMDriver, OnLine } from "./drivers/types.js"; @@ -65,7 +65,9 @@ export async function patchMainConfig( driver: VMDriver, vmName: string, resolvedMap: ResolvedSecretRef[], - config: { telegram?: { botToken?: string } }, + config: { + channels?: Record>; + }, onLine?: OnLine, ): Promise { onLine?.("Patching main config with file provider..."); @@ -82,14 +84,17 @@ export async function patchMainConfig( mode: "json", }; - // Replace telegram botToken with SecretRef if it was an op:// ref - const telegramRef = resolvedMap.find((r) => r.path[0] === "telegram" && r.path[1] === "botToken"); - if (telegramRef && mainConfig.channels) { + // Replace channel secrets with SecretRefs if they were op:// refs. + for (const ref of resolvedMap) { + if (ref.path[0] !== "channels" || ref.path.length < 3) continue; + const channelName = ref.path[1]; + const fieldName = ref.path[2]; + + if (!mainConfig.channels) continue; const channels = mainConfig.channels as Record; - if (channels.telegram) { - const telegram = channels.telegram as Record; - telegram.botToken = makeSecretRef(telegramRef.path); - } + const channel = channels[channelName]; + if (!channel || typeof channel !== "object") continue; + (channel as Record)[fieldName] = makeSecretRef(ref.path); } await writeVMJson(driver, vmName, CONFIG_PATH, mainConfig); diff --git a/packages/host-core/src/schema-derive.ts b/packages/host-core/src/schema-derive.ts index 3069323..827020e 100644 --- a/packages/host-core/src/schema-derive.ts +++ b/packages/host-core/src/schema-derive.ts @@ -8,7 +8,12 @@ */ import { z } from "zod"; -import type { CapabilityConfigDef, CapabilityConfigField, CapabilityDef } from "@clawctl/types"; +import type { + CapabilityConfigDef, + CapabilityConfigField, + CapabilityDef, + ChannelDef, +} from "@clawctl/types"; // --------------------------------------------------------------------------- // Path resolution utilities @@ -71,6 +76,10 @@ function deriveFieldSchema(field: CapabilityConfigField): z.ZodTypeAny { schema = field.required ? z.string().min(1) : z.string(); break; } + case "toggle": { + schema = z.boolean(); + break; + } case "select": { const values = (field.options ?? []).map((o: { label: string; value: string }) => o.value); if (values.length >= 2) { @@ -194,6 +203,35 @@ export function buildCapabilitiesSchema(capabilities: CapabilityDef[]): z.ZodTyp .optional(); } +/** + * Build a composed Zod schema for the `channels` config section. + * + * Known channels get strict validation (derived from ChannelDef fields) + * with .passthrough() to allow extra fields we don't model. Unknown + * channel keys are allowed permissively. + */ +export function buildChannelsSchema(channels: ChannelDef[]): z.ZodTypeAny { + const knownShapes: Record = {}; + + for (const ch of channels) { + if (ch.configDef.fields.length > 0) { + const objSchema = deriveConfigSchema(ch.configDef); + // .passthrough() allows extra fields beyond the essential ones we model + const passthrough = objSchema instanceof z.ZodObject ? objSchema.passthrough() : objSchema; + knownShapes[ch.name] = passthrough.optional(); + } else { + // Channels with no required fields (e.g., WhatsApp with QR pairing) + knownShapes[ch.name] = z.record(z.string(), z.unknown()).optional(); + } + } + + if (Object.keys(knownShapes).length === 0) { + return z.record(z.string(), z.record(z.string(), z.unknown())).optional(); + } + + return z.object(knownShapes).catchall(z.record(z.string(), z.unknown())).optional(); +} + /** * Extract secret field paths from a capability's configDef. * Returns an array of paths (plain keys or JSON Pointers) marked as secret. diff --git a/packages/host-core/src/secrets-sync.test.ts b/packages/host-core/src/secrets-sync.test.ts index 72b0d73..a86bb82 100644 --- a/packages/host-core/src/secrets-sync.test.ts +++ b/packages/host-core/src/secrets-sync.test.ts @@ -12,7 +12,7 @@ describe("sanitizeKey", () => { }); test("handles deep paths", () => { - expect(sanitizeKey(["telegram", "botToken"])).toBe("telegram_bottoken"); + expect(sanitizeKey(["channels", "telegram", "botToken"])).toBe("channels_telegram_bottoken"); }); test("lowercases mixed case", () => { @@ -32,7 +32,7 @@ describe("buildInfraSecrets", () => { resolvedValue: "sk-abc123", }, { - path: ["telegram", "botToken"], + path: ["channels", "telegram", "botToken"], reference: "op://V/Bot/token", scheme: "op", resolvedValue: "123:ABC", @@ -42,7 +42,7 @@ describe("buildInfraSecrets", () => { const result = buildInfraSecrets(resolvedMap); expect(result).toEqual({ provider_apikey: "sk-abc123", - telegram_bottoken: "123:ABC", + channels_telegram_bottoken: "123:ABC", }); }); diff --git a/packages/host-core/src/secrets.test.ts b/packages/host-core/src/secrets.test.ts index bac271a..c353ac7 100644 --- a/packages/host-core/src/secrets.test.ts +++ b/packages/host-core/src/secrets.test.ts @@ -31,12 +31,12 @@ describe("findSecretRefs", () => { test("finds multiple references at different depths", () => { const refs = findSecretRefs({ provider: { apiKey: "op://V/I/f" }, - telegram: { botToken: "op://V/Bot/token" }, + channels: { telegram: { botToken: "op://V/Bot/token" } }, capabilities: { "one-password": { serviceAccountToken: "env://OP_TOKEN" } }, }); expect(refs).toHaveLength(3); expect(refs.map((r) => r.scheme)).toEqual( - ["provider", "telegram", "services"].length ? ["op", "op", "env"] : [], + ["provider", "channels", "services"].length ? ["op", "op", "env"] : [], ); }); @@ -128,10 +128,11 @@ describe("resolveEnvRefs", () => { test("resolves multiple env:// references", () => { const result = resolveEnvRefs({ provider: { apiKey: "env://TEST_API_KEY" }, - telegram: { botToken: "env://TEST_BOT_TOKEN" }, + channels: { telegram: { botToken: "env://TEST_BOT_TOKEN" } }, }); expect((result.provider as Record).apiKey).toBe("resolved-api-key"); - expect((result.telegram as Record).botToken).toBe("resolved-bot-token"); + const channels = result.channels as Record>; + expect(channels.telegram.botToken).toBe("resolved-bot-token"); }); test("leaves non-ref values unchanged", () => { diff --git a/packages/types/src/capability.ts b/packages/types/src/capability.ts index 2300cb6..d4d57d5 100644 --- a/packages/types/src/capability.ts +++ b/packages/types/src/capability.ts @@ -154,7 +154,7 @@ export interface CapabilityMigration { // --------------------------------------------------------------------------- /** Field types supported by the config definition / TUI form. */ -export type ConfigFieldType = "text" | "password" | "select"; +export type ConfigFieldType = "text" | "password" | "select" | "toggle"; /** * Recursive JSON Pointer paths for nested config objects. diff --git a/packages/types/src/channels.ts b/packages/types/src/channels.ts new file mode 100644 index 0000000..75ce2df --- /dev/null +++ b/packages/types/src/channels.ts @@ -0,0 +1,273 @@ +/** + * Channel definition system — data-driven channel configuration. + * + * Each ChannelDef declares the essential fields for a communication channel + * (credential + key settings). These drive validation, wizard rendering, + * bootstrap command generation, and secret sanitization. + * + * Only essential fields are modeled (~1-3 per channel). Optional channel + * fields flow through the `openclaw` passthrough or `openclaw config set`. + */ + +import type { CapabilityConfigField, CapabilityConfigDef } from "./capability.js"; + +/** + * A communication channel that OpenClaw can connect to. + * + * ChannelDefs are lighter than CapabilityDefs — they don't install software + * or hook into lifecycle phases. They just declare config fields that get + * applied via `openclaw config set` during bootstrap. + */ +export interface ChannelDef { + /** Channel identifier in OpenClaw config (e.g., "telegram", "discord"). */ + name: string; + /** Human-readable label for wizard/logs. */ + label: string; + /** Plugin name to enable (e.g., "telegram", "discord"). */ + pluginName: string; + /** + * Config definition — same shape as capability configDef. + * Declares essential fields, drives Zod derivation and wizard rendering. + */ + configDef: CapabilityConfigDef; + /** + * Extra commands to run after the field-derived config commands. + * Receives the channel config object and returns additional + * `openclaw config set` commands. + * + * Keys listed in `handledKeys` are skipped by the generic config loop + * so postCommands has full control over their ordering and format. + */ + postCommands?: { + /** Config keys that postCommands handles — skipped by the generic loop. */ + handledKeys: string[]; + /** Generate the commands for the handled keys. */ + run: (config: Record) => string[]; + }; +} + +// --------------------------------------------------------------------------- +// Shared enabled toggle — first field in every channel +// --------------------------------------------------------------------------- + +const enabledField: CapabilityConfigField = { + path: "enabled", + label: "Enabled", + type: "toggle", +}; + +// --------------------------------------------------------------------------- +// Channel definitions +// --------------------------------------------------------------------------- + +const telegramChannel: ChannelDef = { + name: "telegram", + label: "Telegram", + pluginName: "telegram", + configDef: { + sectionLabel: "Telegram", + sectionHelp: { + title: "Telegram Bot", + lines: [ + "Connect your agent to Telegram.", + "Create a bot via @BotFather and paste the token here.", + "allowFrom restricts DMs to specific Telegram user IDs.", + ], + }, + fields: [ + enabledField, + { + path: "botToken", + label: "Bot Token", + type: "password", + required: true, + secret: true, + placeholder: "123456:ABC-DEF1234...", + help: { + title: "Telegram Bot Token", + lines: ["Get this from @BotFather on Telegram.", "Supports op:// and env:// references."], + }, + }, + { + path: "allowFrom", + label: "Allow From", + type: "text", + placeholder: "user_id_1, user_id_2", + help: { + title: "Allowed Users", + lines: [ + "Comma-separated Telegram user IDs allowed to DM the bot.", + "Leave empty to use pairing mode (approve via CLI).", + ], + }, + }, + ], + summary: (values) => + values.enabled === "true" ? (values.botToken ? "configured" : "enabled") : "", + }, + postCommands: { + handledKeys: ["allowFrom", "groups"], + run: (config) => { + const cmds: string[] = []; + // allowFrom → set allowlist, then dmPolicy + const allowFrom = config.allowFrom; + if (Array.isArray(allowFrom) && allowFrom.length > 0) { + cmds.push(`openclaw config set channels.telegram.allowFrom '${JSON.stringify(allowFrom)}'`); + cmds.push("openclaw config set channels.telegram.dmPolicy allowlist"); + } + // groups + const groups = config.groups; + if (groups && typeof groups === "object") { + const groupIds = Object.keys(groups); + if (groupIds.length > 0) { + cmds.push( + `openclaw config set channels.telegram.groupAllowFrom '${JSON.stringify(groupIds)}'`, + ); + } + for (const [id, settings] of Object.entries( + groups as Record>, + )) { + if (settings.requireMention !== undefined) { + cmds.push( + `openclaw config set channels.telegram.groups.${id}.requireMention ${settings.requireMention}`, + ); + } + } + } + return cmds; + }, + }, +}; + +const discordChannel: ChannelDef = { + name: "discord", + label: "Discord", + pluginName: "discord", + configDef: { + sectionLabel: "Discord", + sectionHelp: { + title: "Discord Bot", + lines: [ + "Connect your agent to Discord.", + "Create a bot in the Discord Developer Portal.", + "Enable Message Content intent and add to your server.", + ], + }, + fields: [ + enabledField, + { + path: "token", + label: "Bot Token", + type: "password", + required: true, + secret: true, + placeholder: "MTIzNDU2Nzg5...", + help: { + title: "Discord Bot Token", + lines: [ + "From Discord Developer Portal → Bot → Token.", + "Supports op:// and env:// references.", + ], + }, + }, + ], + summary: (values) => + values.enabled === "true" ? (values.token ? "configured" : "enabled") : "", + }, +}; + +const slackChannel: ChannelDef = { + name: "slack", + label: "Slack", + pluginName: "slack", + configDef: { + sectionLabel: "Slack", + sectionHelp: { + title: "Slack App", + lines: [ + "Connect your agent to Slack via Socket Mode.", + "Create a Slack App, enable Socket Mode,", + "and generate both a Bot Token and App Token.", + ], + }, + fields: [ + enabledField, + { + path: "botToken", + label: "Bot Token", + type: "password", + required: true, + secret: true, + placeholder: "xoxb-...", + help: { + title: "Slack Bot Token", + lines: [ + "OAuth Bot Token (xoxb-...) from your Slack App.", + "Supports op:// and env:// references.", + ], + }, + }, + { + path: "appToken", + label: "App Token", + type: "password", + required: true, + secret: true, + placeholder: "xapp-...", + help: { + title: "Slack App Token", + lines: [ + "App-Level Token (xapp-...) with connections:write scope.", + "Required for Socket Mode. Supports op:// and env:// references.", + ], + }, + }, + ], + summary: (values) => + values.enabled === "true" ? (values.botToken ? "configured" : "enabled") : "", + }, +}; + +const whatsappChannel: ChannelDef = { + name: "whatsapp", + label: "WhatsApp", + pluginName: "whatsapp", + configDef: { + sectionLabel: "WhatsApp", + sectionHelp: { + title: "WhatsApp", + lines: [ + "Connect your agent to WhatsApp.", + "No token needed — uses QR code pairing.", + "After provisioning, pair via: clawctl oc -i channels login --channel whatsapp", + ], + }, + fields: [enabledField], + summary: (values) => (values.enabled === "true" ? "QR pairing" : ""), + }, +}; + +// --------------------------------------------------------------------------- +// Channel registry +// --------------------------------------------------------------------------- + +/** All known channel definitions, keyed by channel name. */ +export const CHANNEL_REGISTRY: Record = { + telegram: telegramChannel, + discord: discordChannel, + slack: slackChannel, + whatsapp: whatsappChannel, +}; + +/** Ordered list of channels for wizard display. */ +export const CHANNEL_ORDER: string[] = ["telegram", "discord", "slack", "whatsapp"]; + +/** + * Get secret field paths for a channel. + * Returns paths of fields marked `secret: true` in the channel's configDef. + */ +export function getChannelSecretPaths(channelName: string): string[] { + const def = CHANNEL_REGISTRY[channelName]; + if (!def) return []; + return def.configDef.fields.filter((f) => f.secret).map((f) => f.path as string); +} diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 54b147e..14cc4f0 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -25,6 +25,8 @@ function formatZodError(error: z.ZodError): string { export interface ValidateConfigOptions { /** Strict Zod schema for the capabilities section (built from capability configDefs). */ capabilitySchema?: z.ZodTypeAny; + /** Strict Zod schema for the channels section (built from ChannelDef configDefs). */ + channelSchema?: z.ZodTypeAny; } /** Validate raw JSON and return a typed InstanceConfig. */ @@ -52,6 +54,18 @@ export function validateConfig(raw: unknown, opts?: ValidateConfigOptions): Inst } } + // Validate channel-specific config against strict schemas + if (opts?.channelSchema && config.channels) { + const chResult = opts.channelSchema.safeParse(config.channels); + if (!chResult.success) { + const issue = (chResult as { error: z.ZodError }).error.issues[0]; + const path = ["channels", ...issue.path].join("."); + throw new Error( + issue.message.startsWith("'") ? issue.message : `'${path}': ${issue.message}`, + ); + } + } + // Cross-validate: op:// references require capabilities["one-password"] const opRefs = findSecretRefs(raw as Record).filter((r) => r.scheme === "op"); if (opRefs.length > 0) { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 167d983..ac8d49f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -36,9 +36,14 @@ export { mountsSchema, providerSchema, bootstrapSchema, - telegramSchema, + channelsSchema, + openclawSchema, } from "./schemas/index.js"; +// Channels +export type { ChannelDef } from "./channels.js"; +export { CHANNEL_REGISTRY, CHANNEL_ORDER, getChannelSecretPaths } from "./channels.js"; + // Constants export { PROJECT_MOUNT_POINT, diff --git a/packages/types/src/schemas/channels.ts b/packages/types/src/schemas/channels.ts new file mode 100644 index 0000000..bfe1123 --- /dev/null +++ b/packages/types/src/schemas/channels.ts @@ -0,0 +1,22 @@ +/** + * Zod schemas for the channels and openclaw config sections. + */ +import { z } from "zod"; + +/** + * Base schema for the channels config section. + * + * Each channel is a record of string keys to unknown values. + * Strict per-channel validation is applied separately via + * buildChannelsSchema() in host-core/schema-derive.ts. + */ +export const channelsSchema = z.record(z.string(), z.record(z.string(), z.unknown())).optional(); + +/** + * Schema for the openclaw passthrough config section. + * + * Accepts arbitrary dotpath-to-value mappings that are applied via + * `openclaw config set` during bootstrap. No host-side validation — + * OpenClaw validates at daemon restart. + */ +export const openclawSchema = z.record(z.string(), z.unknown()).optional(); diff --git a/packages/types/src/schemas/index.ts b/packages/types/src/schemas/index.ts index 690f203..75bff55 100644 --- a/packages/types/src/schemas/index.ts +++ b/packages/types/src/schemas/index.ts @@ -6,8 +6,8 @@ import { z } from "zod"; import { resourcesSchema, networkSchema, agentSchema, toolsSchema, mountsSchema } from "./base.js"; import { providerSchema } from "./provider.js"; -import { telegramSchema } from "./telegram.js"; import { bootstrapSchema } from "./bootstrap.js"; +import { channelsSchema, openclawSchema } from "./channels.js"; /** Schema for capability configs: true (enabled with defaults) or config object. */ export const capabilitiesSchema = z @@ -25,10 +25,11 @@ export const instanceConfigSchema = z.object({ agent: agentSchema.optional(), provider: providerSchema.optional(), bootstrap: bootstrapSchema.optional(), - telegram: telegramSchema.optional(), + channels: channelsSchema, + openclaw: openclawSchema, }); export { resourcesSchema, networkSchema, agentSchema, toolsSchema, mountsSchema }; export { providerSchema }; export { bootstrapSchema }; -export { telegramSchema }; +export { channelsSchema, openclawSchema }; diff --git a/packages/types/src/schemas/telegram.ts b/packages/types/src/schemas/telegram.ts deleted file mode 100644 index e209381..0000000 --- a/packages/types/src/schemas/telegram.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Zod schema for the telegram config section. - */ -import { z } from "zod"; - -export const telegramSchema = z.object({ - botToken: z.string().min(1, "'telegram.botToken' must be a non-empty string"), - allowFrom: z.array(z.string()).optional(), - groups: z.record(z.string(), z.object({ requireMention: z.boolean().optional() })).optional(), -}); diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index a1cc5d0..b347009 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -96,13 +96,16 @@ export interface InstanceConfig { }; }; - /** Telegram channel (optional). */ - telegram?: { - /** Bot token from BotFather. */ - botToken: string; - /** Telegram user IDs allowed to DM the bot. */ - allowFrom?: string[]; - /** Group IDs and their settings. */ - groups?: Record; - }; + /** + * Communication channels (Telegram, Discord, Slack, WhatsApp, etc.). + * Each key is a channel name; the value is channel-specific config. + */ + channels?: Record>; + + /** + * Arbitrary OpenClaw config passthrough. + * Each key is a dotpath (e.g., "session.dmScope"), applied via + * `openclaw config set` during bootstrap. No host-side validation. + */ + openclaw?: Record; } diff --git a/tasks/2026-04-01_1212_generalized-channel-config/TASK.md b/tasks/2026-04-01_1212_generalized-channel-config/TASK.md new file mode 100644 index 0000000..b08af72 --- /dev/null +++ b/tasks/2026-04-01_1212_generalized-channel-config/TASK.md @@ -0,0 +1,90 @@ +# Generalized Channel Configuration + OpenClaw Passthrough + +## Status: Resolved + +## Scope + +Introduce a data-driven `ChannelDef` system and an `openclaw` passthrough config key so users can configure any OpenClaw channel (and arbitrary OpenClaw settings) from their clawctl JSON config file. + +**Covers:** + +- `ChannelDef` type and registry (Telegram, Discord, Slack, WhatsApp) +- `channels` config key replacing top-level `telegram` +- `openclaw` passthrough config key for arbitrary OpenClaw settings +- Generic bootstrap loop for channel config application +- Wizard integration (dynamic channel sections) +- Secret handling generalization +- Backward compatibility for existing `telegram` configs + +**Does not cover:** + +- ChannelDefs for all 26+ OpenClaw channels (only the four most popular) +- Schema scraping dev script for auto-generating ChannelDefs +- Host-side validation of optional channel fields or passthrough values + +## Context + +clawctl hardcodes Telegram as the only communication channel. OpenClaw supports 26+ channels, each with distinct config. Users wanting non-Telegram channels must SSH into the VM manually — defeating clawctl's purpose. + +The approach uses three tiers: + +1. **Curated sections** (existing): provider, resources, network, etc. +2. **ChannelDef system** (new): data-driven definitions declaring essential fields per channel (~15 lines each), driving validation, wizard, bootstrap, and sanitization +3. **`openclaw` passthrough** (new): arbitrary dotpath-to-value mappings applied via `openclaw config set` + +This hybrid avoids maintaining 500+ field definitions while still providing proper secret handling and wizard UX for common channels. Users are never blocked — passthrough covers anything without a ChannelDef. + +## Plan + +The ChannelDef approach was chosen over: + +- **Full typed schemas per channel**: 10-40+ fields each across 26 channels = unmaintainable +- **Pure passthrough**: Can't handle secrets (sanitization, redaction, op:// refs) +- **Runtime schema introspection**: Requires a running VM; can't validate before provisioning + +ChannelDefs reuse existing infrastructure: `CapabilityConfigField` type, `CapabilitySection` component, `deriveConfigSchema()`, `getSecretPaths()`. + +Implementation phases: + +1. Types & ChannelDef infrastructure (new files in types/) +2. Config loading & validation (schema-derive, config.ts) +3. Bootstrap generalization (bootstrap.ts, infra-secrets.ts) +4. Wizard integration (config-builder.tsx, config-review.tsx) +5. Examples, docs, tests + +## Steps + +- [x] Create `packages/types/src/channels.ts` — ChannelDef type + registry +- [x] Create `packages/types/src/schemas/channels.ts` — Zod schemas +- [x] Update `packages/types/src/types.ts` — add channels/openclaw to InstanceConfig +- [x] Update `packages/types/src/schemas/index.ts` — wire into master schema +- [x] Update `packages/types/src/index.ts` — exports +- [x] Update `packages/host-core/src/schema-derive.ts` — buildChannelsSchema() +- [x] Update `packages/host-core/src/config.ts` — generalized sanitization +- [x] Update `packages/host-core/src/bootstrap.ts` — generic channel loop + passthrough +- [x] Update `packages/host-core/src/infra-secrets.ts` — generalize for channels +- [x] Update `packages/cli/src/steps/config-builder.tsx` — DynamicSection abstraction + channel sections +- [x] Update `packages/cli/src/components/config-review.tsx` — dynamic channel review +- [x] Remove backward compat (top-level telegram, telegramSchema) +- [x] Update examples and docs +- [x] Update tests + +## Notes + +- Dropped all backward compatibility for top-level `telegram` key per user feedback — not a widely used library yet, clean code preferred. +- Introduced `DynamicSection` abstraction in the wizard that unifies capabilities and channels. This eliminated the scattered `startsWith("cap:")` / `split(":")` string parsing in favor of typed lookups via `findSection()` and `fieldPathOf()`. +- Net code reduction: -112 lines despite adding 4 new channels. The `CapabilitySection` component needed zero changes — it already rendered any `configDef`. + +## Outcome + +Delivered: + +- `ChannelDef` system with Telegram, Discord, Slack, WhatsApp +- `channels` config key replacing top-level `telegram` +- `openclaw` passthrough for arbitrary OpenClaw settings +- Generic bootstrap loop applying any channel's config +- Unified wizard DynamicSection for capabilities + channels +- Secret sanitization generalized for all channels +- Updated tests, examples, and docs + +Adding new channels requires only a ~15-line ChannelDef entry in `packages/types/src/channels.ts`. The `openclaw` passthrough lets users configure anything OpenClaw supports immediately, even without a ChannelDef.