From 273a85b64bb1aef67e85ec69c31f8b1bf72c44e2 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 12:13:05 +0200 Subject: [PATCH 01/12] chore: add task for generalized channel configuration Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TASK.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tasks/2026-04-01_1212_generalized-channel-config/TASK.md 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..619d528 --- /dev/null +++ b/tasks/2026-04-01_1212_generalized-channel-config/TASK.md @@ -0,0 +1,68 @@ +# Generalized Channel Configuration + OpenClaw Passthrough + +## Status: In Progress + +## 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 + +- [ ] Create `packages/types/src/channels.ts` — ChannelDef type + registry +- [ ] Create `packages/types/src/schemas/channels.ts` — Zod schemas +- [ ] Update `packages/types/src/types.ts` — add channels/openclaw to InstanceConfig +- [ ] Update `packages/types/src/schemas/index.ts` — wire into master schema +- [ ] Update `packages/types/src/index.ts` — exports +- [ ] Update `packages/host-core/src/schema-derive.ts` — buildChannelsSchema() +- [ ] Update `packages/host-core/src/config.ts` — telegram migration + sanitization +- [ ] Update `packages/host-core/src/bootstrap.ts` — generic channel loop + passthrough +- [ ] Update `packages/host-core/src/infra-secrets.ts` — generalize for channels +- [ ] Update `packages/cli/src/steps/config-builder.tsx` — dynamic channel sections +- [ ] Update `packages/cli/src/components/config-review.tsx` — dynamic channel review +- [ ] Update examples and docs +- [ ] Add tests + +## Notes + +## Outcome From fe7374fbc21c08029cbce40ca3e3e840682b5127 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 12:14:40 +0200 Subject: [PATCH 02/12] feat: add ChannelDef system and channels/openclaw config keys Introduce data-driven channel definitions modeled after CapabilityDef. Each ChannelDef declares essential fields (credential + key settings) that drive validation, wizard rendering, bootstrap, and sanitization. - ChannelDef type + registry with Telegram, Discord, Slack, WhatsApp - channels and openclaw keys on InstanceConfig - Zod schemas for new config sections - Backward compat: top-level telegram key kept (deprecated) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/types/src/channels.ts | 244 +++++++++++++++++++++++++ packages/types/src/index.ts | 6 + packages/types/src/schemas/channels.ts | 22 +++ packages/types/src/schemas/index.ts | 5 + packages/types/src/types.ts | 18 +- 5 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 packages/types/src/channels.ts create mode 100644 packages/types/src/schemas/channels.ts diff --git a/packages/types/src/channels.ts b/packages/types/src/channels.ts new file mode 100644 index 0000000..a47436e --- /dev/null +++ b/packages/types/src/channels.ts @@ -0,0 +1,244 @@ +/** + * 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. + */ + postCommands?: (config: Record) => string[]; +} + +// --------------------------------------------------------------------------- +// 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: [ + { + 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.botToken ? "configured" : ""), + }, + postCommands: (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: [ + { + 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.token ? "configured" : ""), + }, +}; + +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: [ + { + 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.botToken ? "configured" : ""), + }, +}; + +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: [], + summary: () => "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/index.ts b/packages/types/src/index.ts index 167d983..31eb8d7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -37,8 +37,14 @@ export { 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..a5a1555 100644 --- a/packages/types/src/schemas/index.ts +++ b/packages/types/src/schemas/index.ts @@ -8,6 +8,7 @@ import { resourcesSchema, networkSchema, agentSchema, toolsSchema, mountsSchema 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,6 +26,9 @@ export const instanceConfigSchema = z.object({ agent: agentSchema.optional(), provider: providerSchema.optional(), bootstrap: bootstrapSchema.optional(), + channels: channelsSchema, + openclaw: openclawSchema, + /** @deprecated Use channels.telegram instead. */ telegram: telegramSchema.optional(), }); @@ -32,3 +36,4 @@ export { resourcesSchema, networkSchema, agentSchema, toolsSchema, mountsSchema export { providerSchema }; export { bootstrapSchema }; export { telegramSchema }; +export { channelsSchema, openclawSchema }; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index a1cc5d0..b4ce483 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -96,7 +96,23 @@ export interface InstanceConfig { }; }; - /** Telegram channel (optional). */ + /** + * 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; + + /** + * Telegram channel (optional). + * @deprecated Use `channels.telegram` instead. + */ telegram?: { /** Bot token from BotFather. */ botToken: string; From 657206ab5801854e3996b41424cb758226373781 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 12:16:07 +0200 Subject: [PATCH 03/12] feat: channel schema validation and telegram migration - buildChannelsSchema() derives Zod schemas from ChannelDef fields with passthrough for extra fields - loadConfig() migrates deprecated top-level telegram to channels.telegram - validateConfig() applies channel schemas like capability schemas - sanitizeConfig() generically strips channel secrets via ChannelDef Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/host-core/src/config.ts | 42 ++++++++++++++++++++++--- packages/host-core/src/index.ts | 1 + packages/host-core/src/schema-derive.ts | 40 ++++++++++++++++++++++- packages/types/src/config.ts | 14 +++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/host-core/src/config.ts b/packages/host-core/src/config.ts index c429392..988d929 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 type { InstanceConfig, VMConfig, CapabilityDef } from "@clawctl/types"; -import { buildCapabilitiesSchema, getSecretPaths } from "./schema-derive.js"; +import { resolveEnvRefs, validateConfig, CHANNEL_REGISTRY } from "@clawctl/types"; +import type { InstanceConfig, VMConfig, CapabilityDef, ChannelDef } from "@clawctl/types"; +import { buildCapabilitiesSchema, buildChannelsSchema, getSecretPaths } from "./schema-derive.js"; // Re-export validateConfig from types for convenience export { validateConfig } from "@clawctl/types"; @@ -43,11 +43,27 @@ export function sanitizeConfig( delete (clone.network as Record).gatewayToken; } - // telegram.botToken + // telegram.botToken (deprecated top-level key) 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) if (capabilities && clone.capabilities && typeof clone.capabilities === "object") { const caps = clone.capabilities as Record; @@ -110,6 +126,22 @@ export async function loadConfig( parsed = resolveEnvRefs(parsed as Record); } + // Migrate deprecated top-level telegram → channels.telegram + if (typeof parsed === "object" && parsed !== null) { + const obj = parsed as Record; + if (obj.telegram && !obj.channels?.hasOwnProperty?.("telegram")) { + const channels = (obj.channels ?? {}) as Record; + channels.telegram = obj.telegram; + obj.channels = channels; + delete obj.telegram; + console.warn( + 'Warning: top-level "telegram" config is deprecated. Use "channels.telegram" instead.', + ); + } + } + 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/schema-derive.ts b/packages/host-core/src/schema-derive.ts index 3069323..edd3b6b 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 @@ -194,6 +199,39 @@ 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/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) { From 3fa956390ef4bb5ed33e99418da1f3387b54a1c0 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 12:17:47 +0200 Subject: [PATCH 04/12] feat: generic channel bootstrap loop and openclaw passthrough Replace hardcoded Telegram bootstrap with a generic channel command generator that works for any channel. Each channel's config is walked and applied via openclaw config set commands. ChannelDef postCommands handle channel-specific ordering (e.g., Telegram dmPolicy after allowFrom). Add openclaw passthrough: arbitrary dotpath-to-value mappings applied during bootstrap for config not covered by ChannelDefs. Generalize infra-secrets patching to handle any channel's secret refs, not just Telegram. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/host-core/src/bootstrap.ts | 159 +++++++++++++++++++----- packages/host-core/src/infra-secrets.ts | 33 +++-- 2 files changed, 150 insertions(+), 42 deletions(-) diff --git a/packages/host-core/src/bootstrap.ts b/packages/host-core/src/bootstrap.ts index 25dbd21..f85dff1 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,27 @@ 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)) { + 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 +229,108 @@ 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 cmds: string[] = [ + `openclaw config set channels.${channelName}.enabled true`, + `openclaw config set plugins.entries.${pluginName}.enabled true`, + ]; + + // Set each config field — skip fields handled by postCommands for known channels + const postHandledKeys = new Set(); + if (def?.postCommands) { + // Telegram's postCommands handles allowFrom and groups specially + // We detect which top-level keys the postCommands references by + // checking the ChannelDef — fields NOT in configDef.fields are + // handled as generic config; postCommands handles the rest that + // need special ordering + const fieldPaths = new Set(def.configDef.fields.map((f) => f.path as string)); + for (const key of Object.keys(channelConfig)) { + // Skip keys that are in the essential fields (those are set via configToCommands) + // and also skip keys that postCommands will handle specially + if (!fieldPaths.has(key) && (key === "allowFrom" || key === "groups")) { + postHandledKeys.add(key); + } + } + } + + // Apply config fields + for (const [key, value] of Object.entries(channelConfig)) { + 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(channelConfig)); + } + + return cmds; +} + +/** + * Collect all secret values from channel configs for log redaction. + */ +function collectChannelSecrets(config: InstanceConfig): string[] { + const secrets: string[] = []; + // Deprecated top-level telegram + if (config.telegram?.botToken) secrets.push(config.telegram.botToken); + // New channels key + 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/infra-secrets.ts b/packages/host-core/src/infra-secrets.ts index 6afa1d8..88bf146 100644 --- a/packages/host-core/src/infra-secrets.ts +++ b/packages/host-core/src/infra-secrets.ts @@ -65,7 +65,10 @@ export async function patchMainConfig( driver: VMDriver, vmName: string, resolvedMap: ResolvedSecretRef[], - config: { telegram?: { botToken?: string } }, + config: { + telegram?: { botToken?: string }; + channels?: Record>; + }, onLine?: OnLine, ): Promise { onLine?.("Patching main config with file provider..."); @@ -82,14 +85,28 @@ 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) { - const channels = mainConfig.channels as Record; - if (channels.telegram) { - const telegram = channels.telegram as Record; - telegram.botToken = makeSecretRef(telegramRef.path); + // Replace channel secrets with SecretRefs if they were op:// refs. + // Matches both deprecated "telegram.botToken" and new "channels.telegram.botToken" paths. + for (const ref of resolvedMap) { + let channelName: string | undefined; + let fieldName: string | undefined; + + if (ref.path[0] === "channels" && ref.path.length >= 3) { + // New path: channels.. + channelName = ref.path[1]; + fieldName = ref.path[2]; + } else if (ref.path[0] === "telegram" && ref.path.length >= 2) { + // Deprecated path: telegram. + channelName = "telegram"; + fieldName = ref.path[1]; } + + if (!channelName || !fieldName) continue; + if (!mainConfig.channels) continue; + const channels = mainConfig.channels as Record; + const channel = channels[channelName]; + if (!channel || typeof channel !== "object") continue; + (channel as Record)[fieldName] = makeSecretRef(ref.path); } await writeVMJson(driver, vmName, CONFIG_PATH, mainConfig); From ce60a30d67e24ab11e0662bd99872d23339d3071 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 12:31:43 +0200 Subject: [PATCH 05/12] refactor: remove top-level telegram, unify wizard with DynamicSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all backward-compatibility code for the deprecated top-level `telegram` config key. Channels now live exclusively under `channels.*`. Introduce DynamicSection abstraction that unifies capabilities and channels in the wizard. Both are driven by configDef and rendered by the same CapabilitySection component. All focus-list, sidebar, and select-field logic operates on DynamicSection uniformly — no more scattered string prefix parsing. - Delete telegramSchema (orphaned) - Remove telegram migration in loadConfig - Remove hardcoded telegram wizard section and state - Single dynamicValues state for both capabilities and channels - Update tests and examples to use channels.telegram - Update config-review for dynamic channel rows Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/config.full.json | 12 +- examples/config.op.json | 6 +- packages/cli/src/components/config-review.tsx | 36 +- packages/cli/src/components/sidebar.tsx | 13 - packages/cli/src/steps/config-builder.tsx | 375 ++++++++---------- packages/host-core/src/bootstrap.ts | 3 - packages/host-core/src/config.test.ts | 131 +++--- packages/host-core/src/config.ts | 19 - packages/host-core/src/infra-secrets.test.ts | 6 +- packages/host-core/src/infra-secrets.ts | 22 +- packages/host-core/src/secrets-sync.test.ts | 6 +- packages/host-core/src/secrets.test.ts | 9 +- packages/types/src/index.ts | 1 - packages/types/src/schemas/index.ts | 4 - packages/types/src/schemas/telegram.ts | 10 - packages/types/src/types.ts | 13 - 16 files changed, 277 insertions(+), 389 deletions(-) delete mode 100644 packages/types/src/schemas/telegram.ts diff --git a/examples/config.full.json b/examples/config.full.json index d24811e..ffea193 100644 --- a/examples/config.full.json +++ b/examples/config.full.json @@ -46,11 +46,13 @@ } }, - "telegram": { - "botToken": "123456:ABC-...", - "allowFrom": ["123456789"], - "groups": { - "-100123456789": { "requireMention": true } + "channels": { + "telegram": { + "botToken": "123456:ABC-...", + "allowFrom": ["123456789"], + "groups": { + "-100123456789": { "requireMention": true } + } } } } diff --git a/examples/config.op.json b/examples/config.op.json index 88a1516..b380ea3 100644 --- a/examples/config.op.json +++ b/examples/config.op.json @@ -10,7 +10,9 @@ "type": "zai", "apiKey": "op://Sam/z.AI API Key/credential" }, - "telegram": { - "botToken": "op://Sam/Telegram Token/credential" + "channels": { + "telegram": { + "botToken": "op://Sam/Telegram Token/credential" + } } } 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..e63fc2f 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,60 @@ 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}`), + ...DYNAMIC_SECTIONS.map((s) => s.key), "bootstrap", - "telegram", ]; } -/** 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) + ); +} + +/** Check if a focus ID is a select-type field in a dynamic section. */ +function isDynamicSelectField(focusId: string): boolean { + const section = findSection(focusId); + if (!section) return false; + const path = fieldPathOf(section, focusId); + if (!path) return false; + const field = section.configDef.fields.find((f) => (f.path as string) === path); + return field?.type === "select"; +} + const MEMORY_OPTIONS = ["4GiB", "8GiB", "16GiB", "32GiB"]; const DISK_OPTIONS = ["30GiB", "50GiB", "100GiB", "200GiB"]; @@ -120,7 +186,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 +195,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 +205,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 +254,30 @@ 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; - // 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; + // Convert comma-separated text fields to arrays (e.g., allowFrom) + const field = section.configDef.fields.find((f) => (f.path as string) === k); + 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 +292,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 +301,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 +321,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 +356,6 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } case "bootstrap": return agentName ? "configured" : "unconfigured"; - case "telegram": - return botToken ? "configured" : "unconfigured"; } }; @@ -324,59 +369,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; - - /** 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) - ); - }; + selectingProviderType || selectingMemory || selectingDisk || selectingDynamicField !== null; - /** 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 +403,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 +413,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 +438,12 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { setSelectingMemory(true); } else if (currentFocus === "resources.disk") { setSelectingDisk(true); - } else if (isCapSelectField(currentFocus)) { - setSelectingCapField(currentFocus); + } else if (isDynamicSelectField(currentFocus)) { + setSelectingDynamicField(currentFocus); } else { setEditing(true); } } else if (key.escape) { - // Collapse parent section const parent = findParentSection(currentFocus); if (parent) { toggleSection(parent); @@ -445,7 +458,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 +477,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 +485,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 ( @@ -687,7 +718,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { )} - {/* 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 sections (capabilities + channels) */} + {DYNAMIC_SECTIONS.map((section) => ( + + ))} {/* Bootstrap / Agent Identity */} )} - - {/* Telegram */} - - {currentFocus === "telegram.botToken" && editing ? ( - - - Bot Token - - - - ) : ( - - )} - {currentFocus === "telegram.allowFrom" && editing ? ( - - - Allow From - - - - ) : ( - - )} - @@ -879,7 +841,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { - {/* Keybinding hints (pinned bottom) */} + {/* Keybinding hints */} [{"\u2191\u2193"}] navigate {"\u00b7"} [Enter] {editing ? "confirm" : "edit/expand"}{" "} @@ -888,7 +850,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 f85dff1..e7df3e3 100644 --- a/packages/host-core/src/bootstrap.ts +++ b/packages/host-core/src/bootstrap.ts @@ -318,9 +318,6 @@ function buildChannelCommands(channelName: string, channelConfig: Record { ], 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,8 @@ 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 +354,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 +528,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 988d929..0a8f2c1 100644 --- a/packages/host-core/src/config.ts +++ b/packages/host-core/src/config.ts @@ -43,11 +43,6 @@ export function sanitizeConfig( delete (clone.network as Record).gatewayToken; } - // telegram.botToken (deprecated top-level key) - 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; @@ -126,20 +121,6 @@ export async function loadConfig( parsed = resolveEnvRefs(parsed as Record); } - // Migrate deprecated top-level telegram → channels.telegram - if (typeof parsed === "object" && parsed !== null) { - const obj = parsed as Record; - if (obj.telegram && !obj.channels?.hasOwnProperty?.("telegram")) { - const channels = (obj.channels ?? {}) as Record; - channels.telegram = obj.telegram; - obj.channels = channels; - delete obj.telegram; - console.warn( - 'Warning: top-level "telegram" config is deprecated. Use "channels.telegram" instead.', - ); - } - } - const capabilitySchema = capabilities ? buildCapabilitiesSchema(capabilities) : undefined; const channelDefs = Object.values(CHANNEL_REGISTRY); const channelSchema = channelDefs.length > 0 ? buildChannelsSchema(channelDefs) : undefined; 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 88bf146..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"; @@ -66,7 +66,6 @@ export async function patchMainConfig( vmName: string, resolvedMap: ResolvedSecretRef[], config: { - telegram?: { botToken?: string }; channels?: Record>; }, onLine?: OnLine, @@ -86,22 +85,11 @@ export async function patchMainConfig( }; // Replace channel secrets with SecretRefs if they were op:// refs. - // Matches both deprecated "telegram.botToken" and new "channels.telegram.botToken" paths. for (const ref of resolvedMap) { - let channelName: string | undefined; - let fieldName: string | undefined; - - if (ref.path[0] === "channels" && ref.path.length >= 3) { - // New path: channels.. - channelName = ref.path[1]; - fieldName = ref.path[2]; - } else if (ref.path[0] === "telegram" && ref.path.length >= 2) { - // Deprecated path: telegram. - channelName = "telegram"; - fieldName = ref.path[1]; - } - - if (!channelName || !fieldName) continue; + 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; const channel = channels[channelName]; 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/index.ts b/packages/types/src/index.ts index 31eb8d7..ac8d49f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -36,7 +36,6 @@ export { mountsSchema, providerSchema, bootstrapSchema, - telegramSchema, channelsSchema, openclawSchema, } from "./schemas/index.js"; diff --git a/packages/types/src/schemas/index.ts b/packages/types/src/schemas/index.ts index a5a1555..75bff55 100644 --- a/packages/types/src/schemas/index.ts +++ b/packages/types/src/schemas/index.ts @@ -6,7 +6,6 @@ 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"; @@ -28,12 +27,9 @@ export const instanceConfigSchema = z.object({ bootstrap: bootstrapSchema.optional(), channels: channelsSchema, openclaw: openclawSchema, - /** @deprecated Use channels.telegram instead. */ - telegram: telegramSchema.optional(), }); 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 b4ce483..b347009 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -108,17 +108,4 @@ export interface InstanceConfig { * `openclaw config set` during bootstrap. No host-side validation. */ openclaw?: Record; - - /** - * Telegram channel (optional). - * @deprecated Use `channels.telegram` instead. - */ - telegram?: { - /** Bot token from BotFather. */ - botToken: string; - /** Telegram user IDs allowed to DM the bot. */ - allowFrom?: string[]; - /** Group IDs and their settings. */ - groups?: Record; - }; } From 5bf06547f47e5c45bac8a7c0d157b4d56f42fc88 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 12:32:58 +0200 Subject: [PATCH 06/12] docs: update config reference and task for channels + openclaw passthrough Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/config-reference.md | 60 ++++++++++++++++--- .../TASK.md | 44 +++++++++----- 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/docs/config-reference.md b/docs/config-reference.md index 97de6f8..defa2fc 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,9 +192,15 @@ 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. + +### `channels.telegram` | Field | Type | Description | | ---------------------------- | -------- | ------------------------------------------------ | @@ -201,6 +209,42 @@ Telegram channel configuration (optional). Applied during bootstrap after onboar | `groups` | object | Group IDs and their settings. | | `groups..requireMention` | boolean | Whether the bot requires @mention in this group. | +### `channels.discord` + +| Field | Type | Description | +| ------- | ------ | ------------------------------------ | +| `token` | string | Discord bot token (required). | + +### `channels.slack` + +| Field | Type | Description | +| ---------- | ------ | -------------------------------------------------- | +| `botToken` | string | Slack Bot Token `xoxb-...` (required). | +| `appToken` | string | Slack App Token `xapp-...` for Socket Mode (required). | + +### `channels.whatsapp` + +No required fields. 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 +260,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/tasks/2026-04-01_1212_generalized-channel-config/TASK.md b/tasks/2026-04-01_1212_generalized-channel-config/TASK.md index 619d528..508aaa8 100644 --- a/tasks/2026-04-01_1212_generalized-channel-config/TASK.md +++ b/tasks/2026-04-01_1212_generalized-channel-config/TASK.md @@ -1,6 +1,6 @@ # Generalized Channel Configuration + OpenClaw Passthrough -## Status: In Progress +## Status: Resolved ## Scope @@ -49,20 +49,36 @@ Implementation phases: ## Steps -- [ ] Create `packages/types/src/channels.ts` — ChannelDef type + registry -- [ ] Create `packages/types/src/schemas/channels.ts` — Zod schemas -- [ ] Update `packages/types/src/types.ts` — add channels/openclaw to InstanceConfig -- [ ] Update `packages/types/src/schemas/index.ts` — wire into master schema -- [ ] Update `packages/types/src/index.ts` — exports -- [ ] Update `packages/host-core/src/schema-derive.ts` — buildChannelsSchema() -- [ ] Update `packages/host-core/src/config.ts` — telegram migration + sanitization -- [ ] Update `packages/host-core/src/bootstrap.ts` — generic channel loop + passthrough -- [ ] Update `packages/host-core/src/infra-secrets.ts` — generalize for channels -- [ ] Update `packages/cli/src/steps/config-builder.tsx` — dynamic channel sections -- [ ] Update `packages/cli/src/components/config-review.tsx` — dynamic channel review -- [ ] Update examples and docs -- [ ] Add tests +- [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. From 8e1fe40b6bbbac7e5c3154e7364471af638c33fb Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 13:54:33 +0200 Subject: [PATCH 07/12] feat: add group headers to wizard (Infrastructure, Channels, Identity) Non-interactive visual separators that group related sections: - Infrastructure: Resources, Provider, Network, capabilities - Channels: Telegram, Discord, Slack, WhatsApp - Identity: Agent Identity / bootstrap Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/steps/config-builder.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index e63fc2f..b86350f 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -566,6 +566,11 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Sections */} + {/* ── Infrastructure ── */} + + {"\u2500\u2500"} Infrastructure {"\u2500\u2500"} + + {/* Resources */} - {/* Dynamic sections (capabilities + channels) */} - {DYNAMIC_SECTIONS.map((section) => ( + {/* 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 */} Date: Wed, 1 Apr 2026 14:04:17 +0200 Subject: [PATCH 08/12] feat: add toggle field type and enabled toggle to all channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "toggle" to ConfigFieldType — renders as [x]/[ ] checkbox in the wizard, toggles on Enter, derives z.boolean() in Zod schemas. Every channel now has an explicit "enabled" toggle as its first field: - JSON: { "whatsapp": { "enabled": true } } instead of empty object - Wizard: expand channel, first item is [x] Enabled toggle - Bootstrap: skips channels with enabled: false Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/config.full.json | 1 + examples/config.op.json | 1 + .../cli/src/components/capability-section.tsx | 12 +++++++++ packages/cli/src/steps/config-builder.tsx | 23 ++++++++++------ packages/host-core/src/bootstrap.ts | 9 ++++--- packages/host-core/src/schema-derive.ts | 4 +++ packages/types/src/capability.ts | 2 +- packages/types/src/channels.ts | 26 +++++++++++++++---- 8 files changed, 61 insertions(+), 17 deletions(-) diff --git a/examples/config.full.json b/examples/config.full.json index ffea193..d3eb683 100644 --- a/examples/config.full.json +++ b/examples/config.full.json @@ -48,6 +48,7 @@ "channels": { "telegram": { + "enabled": true, "botToken": "123456:ABC-...", "allowFrom": ["123456789"], "groups": { diff --git a/examples/config.op.json b/examples/config.op.json index b380ea3..3e2f1f2 100644 --- a/examples/config.op.json +++ b/examples/config.op.json @@ -12,6 +12,7 @@ }, "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/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index b86350f..ec01741 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -151,14 +151,14 @@ function isSection(id: string): boolean { ); } -/** Check if a focus ID is a select-type field in a dynamic section. */ -function isDynamicSelectField(focusId: string): boolean { +/** 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 false; + if (!section) return undefined; const path = fieldPathOf(section, focusId); - if (!path) return false; + if (!path) return undefined; const field = section.configDef.fields.find((f) => (f.path as string) === path); - return field?.type === "select"; + return field?.type; } const MEMORY_OPTIONS = ["4GiB", "8GiB", "16GiB", "32GiB"]; @@ -262,9 +262,10 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const sectionConfig: Record = {}; for (const [k, v] of Object.entries(vals)) { if (!v) continue; - // Convert comma-separated text fields to arrays (e.g., allowFrom) const field = section.configDef.fields.find((f) => (f.path as string) === k); - if (field?.type === "text" && v.includes(",")) { + 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; @@ -438,8 +439,14 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { setSelectingMemory(true); } else if (currentFocus === "resources.disk") { setSelectingDisk(true); - } else if (isDynamicSelectField(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); } diff --git a/packages/host-core/src/bootstrap.ts b/packages/host-core/src/bootstrap.ts index e7df3e3..c6cdc5c 100644 --- a/packages/host-core/src/bootstrap.ts +++ b/packages/host-core/src/bootstrap.ts @@ -111,6 +111,7 @@ export async function bootstrapOpenclaw( // 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) { @@ -270,9 +271,10 @@ function configToCommands(prefix: string, obj: Record): string[ 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 true`, - `openclaw config set plugins.entries.${pluginName}.enabled true`, + `openclaw config set channels.${channelName}.enabled ${enabled}`, + `openclaw config set plugins.entries.${pluginName}.enabled ${enabled}`, ]; // Set each config field — skip fields handled by postCommands for known channels @@ -293,8 +295,9 @@ function buildChannelCommands(channelName: string, channelConfig: Record o.value); if (values.length >= 2) { 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 index a47436e..8671f26 100644 --- a/packages/types/src/channels.ts +++ b/packages/types/src/channels.ts @@ -38,6 +38,16 @@ export interface ChannelDef { postCommands?: (config: Record) => string[]; } +// --------------------------------------------------------------------------- +// Shared enabled toggle — first field in every channel +// --------------------------------------------------------------------------- + +const enabledField: CapabilityConfigField = { + path: "enabled", + label: "Enabled", + type: "toggle", +}; + // --------------------------------------------------------------------------- // Channel definitions // --------------------------------------------------------------------------- @@ -57,6 +67,7 @@ const telegramChannel: ChannelDef = { ], }, fields: [ + enabledField, { path: "botToken", label: "Bot Token", @@ -83,7 +94,8 @@ const telegramChannel: ChannelDef = { }, }, ], - summary: (values) => (values.botToken ? "configured" : ""), + summary: (values) => + values.enabled === "true" ? (values.botToken ? "configured" : "enabled") : "", }, postCommands: (config) => { const cmds: string[] = []; @@ -129,6 +141,7 @@ const discordChannel: ChannelDef = { ], }, fields: [ + enabledField, { path: "token", label: "Bot Token", @@ -145,7 +158,8 @@ const discordChannel: ChannelDef = { }, }, ], - summary: (values) => (values.token ? "configured" : ""), + summary: (values) => + values.enabled === "true" ? (values.token ? "configured" : "enabled") : "", }, }; @@ -164,6 +178,7 @@ const slackChannel: ChannelDef = { ], }, fields: [ + enabledField, { path: "botToken", label: "Bot Token", @@ -195,7 +210,8 @@ const slackChannel: ChannelDef = { }, }, ], - summary: (values) => (values.botToken ? "configured" : ""), + summary: (values) => + values.enabled === "true" ? (values.botToken ? "configured" : "enabled") : "", }, }; @@ -213,8 +229,8 @@ const whatsappChannel: ChannelDef = { "After provisioning, pair via: clawctl oc -i channels login --channel whatsapp", ], }, - fields: [], - summary: () => "QR pairing", + fields: [enabledField], + summary: (values) => (values.enabled === "true" ? "QR pairing" : ""), }, }; From 4785cda14f1111e3ed99672cca050d84c0a0fc7b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 14:13:36 +0200 Subject: [PATCH 09/12] fix: wizard only emits channel config when enabled is toggled on Prevents emitting channels with enabled: false or channels where the user expanded the section but never toggled enabled. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/steps/config-builder.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index ec01741..28989b2 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -258,6 +258,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { 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; const sectionConfig: Record = {}; for (const [k, v] of Object.entries(vals)) { From 556fb782d8c2a9c5ea73d2c530898d0669a1c764 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Wed, 1 Apr 2026 14:14:30 +0200 Subject: [PATCH 10/12] docs: document enabled field semantics for channels Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/config-reference.md | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/config-reference.md b/docs/config-reference.md index defa2fc..dc597d9 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -200,10 +200,21 @@ 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. | @@ -211,20 +222,26 @@ Unknown channel names are accepted — they pass through to OpenClaw directly. ### `channels.discord` -| Field | Type | Description | -| ------- | ------ | ------------------------------------ | -| `token` | string | Discord bot token (required). | +| Field | Type | Description | +| --------- | ------- | ---------------------------------------- | +| `enabled` | boolean | Enable this channel (default: `true`). | +| `token` | string | Discord bot token (required). | ### `channels.slack` -| Field | Type | Description | -| ---------- | ------ | -------------------------------------------------- | -| `botToken` | string | Slack Bot Token `xoxb-...` (required). | -| `appToken` | string | Slack App Token `xapp-...` for Socket Mode (required). | +| 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` -No required fields. Uses QR code pairing after provisioning: +| 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 From 6014e56ece5984b8ea79027134104d1b1242bb23 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 7 Apr 2026 09:42:53 +0200 Subject: [PATCH 11/12] refactor: make postCommands declare handledKeys explicitly Replace hardcoded allowFrom/groups check in bootstrap with a handledKeys array on the ChannelDef postCommands object. The generic loop reads handled keys from the definition rather than maintaining channel-specific knowledge in bootstrap code. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/host-core/src/bootstrap.ts | 23 +++--------- packages/types/src/channels.ts | 55 +++++++++++++++++------------ 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/packages/host-core/src/bootstrap.ts b/packages/host-core/src/bootstrap.ts index c6cdc5c..379eec9 100644 --- a/packages/host-core/src/bootstrap.ts +++ b/packages/host-core/src/bootstrap.ts @@ -277,25 +277,10 @@ function buildChannelCommands(channelName: string, channelConfig: Record(); - if (def?.postCommands) { - // Telegram's postCommands handles allowFrom and groups specially - // We detect which top-level keys the postCommands references by - // checking the ChannelDef — fields NOT in configDef.fields are - // handled as generic config; postCommands handles the rest that - // need special ordering - const fieldPaths = new Set(def.configDef.fields.map((f) => f.path as string)); - for (const key of Object.keys(channelConfig)) { - // Skip keys that are in the essential fields (those are set via configToCommands) - // and also skip keys that postCommands will handle specially - if (!fieldPaths.has(key) && (key === "allowFrom" || key === "groups")) { - postHandledKeys.add(key); - } - } - } + // Keys handled by postCommands — skipped by the generic loop + const postHandledKeys = new Set(def?.postCommands?.handledKeys ?? []); - // Apply config fields (skip enabled — already handled above) + // 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; @@ -310,7 +295,7 @@ function buildChannelCommands(channelName: string, channelConfig: Record) => string[]; + postCommands?: { + /** Config keys that postCommands handles — skipped by the generic loop. */ + handledKeys: string[]; + /** Generate the commands for the handled keys. */ + run: (config: Record) => string[]; + }; } // --------------------------------------------------------------------------- @@ -97,32 +105,35 @@ const telegramChannel: ChannelDef = { summary: (values) => values.enabled === "true" ? (values.botToken ? "configured" : "enabled") : "", }, - postCommands: (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)}'`, - ); + 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"); } - for (const [id, settings] of Object.entries(groups as Record>)) { - if (settings.requireMention !== undefined) { + // 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.groups.${id}.requireMention ${settings.requireMention}`, + `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; + return cmds; + }, }, }; From b31a442c01c0c2bc7ebadc5b631d42d373aeaa69 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 7 Apr 2026 09:47:15 +0200 Subject: [PATCH 12/12] chore: fix formatting and remove unused import Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/config-reference.md | 16 +++++----- packages/cli/src/steps/config-builder.tsx | 30 +++++++++---------- packages/host-core/src/bootstrap.ts | 5 +++- packages/host-core/src/config.test.ts | 5 +++- packages/host-core/src/config.ts | 6 ++-- packages/host-core/src/schema-derive.ts | 8 ++--- packages/types/src/channels.ts | 4 ++- .../TASK.md | 6 ++++ 8 files changed, 44 insertions(+), 36 deletions(-) diff --git a/docs/config-reference.md b/docs/config-reference.md index dc597d9..b69cd45 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -204,10 +204,10 @@ Unknown channel names are accepted — they pass through to OpenClaw directly. 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." | +| `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` @@ -222,10 +222,10 @@ Every channel supports an `enabled` boolean: ### `channels.discord` -| Field | Type | Description | -| --------- | ------- | ---------------------------------------- | -| `enabled` | boolean | Enable this channel (default: `true`). | -| `token` | string | Discord bot token (required). | +| Field | Type | Description | +| --------- | ------- | -------------------------------------- | +| `enabled` | boolean | Enable this channel (default: `true`). | +| `token` | string | Discord bot token (required). | ### `channels.slack` diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index 28989b2..27a930c 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -115,13 +115,7 @@ const CORE_SECTION_CHILDREN: Record = { /** All section IDs in visual render order. */ function allSectionIds(): string[] { - return [ - "resources", - "provider", - "network", - ...DYNAMIC_SECTIONS.map((s) => s.key), - "bootstrap", - ]; + return ["resources", "provider", "network", ...DYNAMIC_SECTIONS.map((s) => s.key), "bootstrap"]; } function sectionChildren(sectionId: string): string[] { @@ -145,10 +139,7 @@ function buildFocusList(expanded: Set): string[] { } function isSection(id: string): boolean { - return ( - (CORE_SECTIONS as string[]).includes(id) || - DYNAMIC_SECTIONS.some((s) => s.key === id) - ); + 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. */ @@ -268,7 +259,10 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { 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); + sectionConfig[k] = v + .split(",") + .map((s) => s.trim()) + .filter(Boolean); } else { sectionConfig[k] = v; } @@ -577,7 +571,9 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* ── Infrastructure ── */} - {"\u2500\u2500"} Infrastructure {"\u2500\u2500"} + + {"\u2500\u2500"} Infrastructure {"\u2500\u2500"} + {/* Resources */} @@ -764,7 +760,9 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* ── Channels ── */} - {"\u2500\u2500"} Channels {"\u2500\u2500"} + + {"\u2500\u2500"} Channels {"\u2500\u2500"} + {DYNAMIC_SECTIONS.filter((s) => s.kind === "channel").map((section) => ( @@ -773,7 +771,9 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* ── Identity ── */} - {"\u2500\u2500"} Identity {"\u2500\u2500"} + + {"\u2500\u2500"} Identity {"\u2500\u2500"} + {/* Bootstrap / Agent Identity */} diff --git a/packages/host-core/src/bootstrap.ts b/packages/host-core/src/bootstrap.ts index 379eec9..3e012a9 100644 --- a/packages/host-core/src/bootstrap.ts +++ b/packages/host-core/src/bootstrap.ts @@ -268,7 +268,10 @@ function configToCommands(prefix: string, obj: Record): string[ * 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[] { +function buildChannelCommands( + channelName: string, + channelConfig: Record, +): string[] { const def: ChannelDef | undefined = CHANNEL_REGISTRY[channelName]; const pluginName = def?.pluginName ?? channelName; const enabled = channelConfig.enabled !== false; diff --git a/packages/host-core/src/config.test.ts b/packages/host-core/src/config.test.ts index 20bcef7..024fb19 100644 --- a/packages/host-core/src/config.test.ts +++ b/packages/host-core/src/config.test.ts @@ -64,7 +64,10 @@ describe("validateConfig", () => { expect(config.provider?.apiKey).toBe("sk-ant-xyz"); expect(config.provider?.model).toBe("anthropic/claude-opus-4-6"); expect(config.channels?.telegram?.botToken).toBe("123:ABC"); - expect((config.channels?.telegram?.groups as Record>)?.["-100"]?.requireMention).toBe(true); + expect( + (config.channels?.telegram?.groups as Record>)?.["-100"] + ?.requireMention, + ).toBe(true); }); test("throws on missing name", () => { diff --git a/packages/host-core/src/config.ts b/packages/host-core/src/config.ts index 0a8f2c1..3586857 100644 --- a/packages/host-core/src/config.ts +++ b/packages/host-core/src/config.ts @@ -1,6 +1,6 @@ import { readFile } from "fs/promises"; import { resolveEnvRefs, validateConfig, CHANNEL_REGISTRY } from "@clawctl/types"; -import type { InstanceConfig, VMConfig, CapabilityDef, ChannelDef } from "@clawctl/types"; +import type { InstanceConfig, VMConfig, CapabilityDef } from "@clawctl/types"; import { buildCapabilitiesSchema, buildChannelsSchema, getSecretPaths } from "./schema-derive.js"; // Re-export validateConfig from types for convenience @@ -50,9 +50,7 @@ export function sanitizeConfig( 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); + 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]; } diff --git a/packages/host-core/src/schema-derive.ts b/packages/host-core/src/schema-derive.ts index 122aefa..827020e 100644 --- a/packages/host-core/src/schema-derive.ts +++ b/packages/host-core/src/schema-derive.ts @@ -217,8 +217,7 @@ export function buildChannelsSchema(channels: ChannelDef[]): z.ZodTypeAny { 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; + 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) @@ -230,10 +229,7 @@ export function buildChannelsSchema(channels: ChannelDef[]): z.ZodTypeAny { return z.record(z.string(), z.record(z.string(), z.unknown())).optional(); } - return z - .object(knownShapes) - .catchall(z.record(z.string(), z.unknown())) - .optional(); + return z.object(knownShapes).catchall(z.record(z.string(), z.unknown())).optional(); } /** diff --git a/packages/types/src/channels.ts b/packages/types/src/channels.ts index 52be4c6..75ce2df 100644 --- a/packages/types/src/channels.ts +++ b/packages/types/src/channels.ts @@ -124,7 +124,9 @@ const telegramChannel: ChannelDef = { `openclaw config set channels.telegram.groupAllowFrom '${JSON.stringify(groupIds)}'`, ); } - for (const [id, settings] of Object.entries(groups as Record>)) { + 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}`, diff --git a/tasks/2026-04-01_1212_generalized-channel-config/TASK.md b/tasks/2026-04-01_1212_generalized-channel-config/TASK.md index 508aaa8..b08af72 100644 --- a/tasks/2026-04-01_1212_generalized-channel-config/TASK.md +++ b/tasks/2026-04-01_1212_generalized-channel-config/TASK.md @@ -7,6 +7,7 @@ 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 @@ -16,6 +17,7 @@ Introduce a data-driven `ChannelDef` system and an `openclaw` passthrough config - 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 @@ -25,6 +27,7 @@ Introduce a data-driven `ChannelDef` system and an `openclaw` passthrough config 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` @@ -34,6 +37,7 @@ This hybrid avoids maintaining 500+ field definitions while still providing prop ## 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 @@ -41,6 +45,7 @@ The ChannelDef approach was chosen over: 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) @@ -73,6 +78,7 @@ Implementation phases: ## Outcome Delivered: + - `ChannelDef` system with Telegram, Discord, Slack, WhatsApp - `channels` config key replacing top-level `telegram` - `openclaw` passthrough for arbitrary OpenClaw settings