From 65bb13b040d83a7ae0b7a925482f99fa7670bbe1 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Tue, 3 Mar 2026 21:31:43 +0000 Subject: [PATCH 1/6] fix: remove redundant openclaw symlink and add optional HTTPS support - Fix Docker build failure: upstream OpenClaw now provides /usr/local/bin/openclaw symlink - Add optional Caddy reverse proxy service with 'https' profile for Let's Encrypt - Add Caddyfile.example template for HTTPS configuration - Add Caddyfile to .gitignore (site-specific configuration) - Caddy service disabled by default, enable with: docker compose --profile https up -d --- .gitignore | 3 +++ Caddyfile.example | 6 ++++++ Dockerfile.botenv | 3 --- docker-compose.yml | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 Caddyfile.example diff --git a/.gitignore b/.gitignore index e04eccd..c32244c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ data/ !dashboard/src/wizard/data/ /secrets/ +# Site-specific configuration +Caddyfile + # Environment .env .env.local diff --git a/Caddyfile.example b/Caddyfile.example new file mode 100644 index 0000000..7300677 --- /dev/null +++ b/Caddyfile.example @@ -0,0 +1,6 @@ +# Example Caddyfile for HTTPS reverse proxy +# Copy this to Caddyfile and replace yourdomain.com with your actual domain + +yourdomain.com { + reverse_proxy botmaker:7100 +} diff --git a/Dockerfile.botenv b/Dockerfile.botenv index 9b85708..a6151e2 100644 --- a/Dockerfile.botenv +++ b/Dockerfile.botenv @@ -49,8 +49,5 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ && rustc --version \ && chmod -R a+w $RUSTUP_HOME $CARGO_HOME -# Make openclaw CLI available on PATH -RUN ln -s /app/openclaw.mjs /usr/local/bin/openclaw - # Switch back to non-root user USER node diff --git a/docker-compose.yml b/docker-compose.yml index ee613b9..be74c28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,23 @@ services: BASE_IMAGE: ${OPENCLAW_BASE_IMAGE:-ghcr.io/openclaw/openclaw:latest} image: ${BOTENV_IMAGE:-botmaker-env:latest} + caddy: + profiles: ["https"] # Optional HTTPS reverse proxy (requires Caddyfile) + image: caddy:2-alpine + container_name: caddy + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + networks: + - bm-internal + restart: unless-stopped + depends_on: + - botmaker + botmaker: build: . container_name: botmaker @@ -82,3 +99,5 @@ volumes: botmaker-data: botmaker-secrets: proxy-data: + caddy-data: + caddy-config: From c8c7a1988398685feabe6ae27615111d1417be13 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Tue, 3 Mar 2026 21:32:09 +0000 Subject: [PATCH 2/6] docs: add HTTPS configuration guide --- HTTPS.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 HTTPS.md diff --git a/HTTPS.md b/HTTPS.md new file mode 100644 index 0000000..2fbdbfe --- /dev/null +++ b/HTTPS.md @@ -0,0 +1,38 @@ +# HTTPS Configuration + +BotMaker can optionally run behind a Caddy reverse proxy for automatic HTTPS with Let's Encrypt certificates. + +## Setup + +1. **Copy the example Caddyfile:** + ```bash + cp Caddyfile.example Caddyfile + ``` + +2. **Edit Caddyfile with your domain:** + ``` + yourdomain.com { + reverse_proxy botmaker:7100 + } + ``` + +3. **Start with HTTPS profile:** + ```bash + docker compose --profile https up -d + ``` + +## Requirements + +- Your domain must be publicly accessible on ports 80 and 443 +- DNS A/AAAA records must point to this server +- Caddy will automatically obtain and renew Let's Encrypt certificates + +## Without HTTPS + +By default, BotMaker runs on HTTP port 7100 without the Caddy proxy: + +```bash +docker compose up -d +``` + +Access at `http://localhost:7100` or `http://your-server-ip:7100` From 1f2afda36e11021d9404da7fd52507b32018e483 Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Wed, 4 Mar 2026 04:50:03 +0000 Subject: [PATCH 3/6] feat: update wizard and templates for OpenClaw 2026.3.x Add tools profile selector (messaging/coding/full), hardcode streaming to "off" to prevent duplicate messages from lane rotation, expand Venice provider with 40+ models, support multi-channel bots and API key entry in wizard, update proxy to forward native Ollama paths, and add HTTPS support via optional Caddy profile in docker-compose. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 39 ++++-- dashboard/src/config/providers/index.ts | 5 + dashboard/src/config/providers/venice.ts | 60 ++++++++- dashboard/src/types.ts | 7 + dashboard/src/ui/BotLink.tsx | 10 +- .../src/wizard/context/WizardContext.test.tsx | 14 +- .../src/wizard/context/WizardContext.tsx | 6 +- .../src/wizard/context/wizardUtils.test.ts | 37 +++++- dashboard/src/wizard/context/wizardUtils.ts | 24 +++- dashboard/src/wizard/pages/Page3Toggles.css | 59 +++++++++ dashboard/src/wizard/pages/Page3Toggles.tsx | 59 ++++++++- dashboard/src/wizard/pages/Page4Config.css | 7 + dashboard/src/wizard/pages/Page4Config.tsx | 30 ++++- dashboard/src/wizard/pages/Page5Summary.css | 113 ++++++++++++++++ dashboard/src/wizard/pages/Page5Summary.tsx | 121 ++++++++++++++++++ docker-compose.yml | 13 +- proxy/src/services/upstream.ts | 8 +- proxy/src/types.ts | 2 +- src/bots/templates.test.ts | 42 ++++-- src/bots/templates.ts | 105 +++++++++++++-- src/config.ts | 6 + src/secrets/manager.ts | 13 +- 22 files changed, 705 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 865444f..d32c43a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,18 +113,37 @@ Direct SQL via better-sqlite3 (no ORM). Two separate SQLite databases: - Optional: enabled by `OLLAMA_UPSTREAM` env var on keyring-proxy - Uses `noAuth: true` + `forceNonStreaming: true` (OpenClaw can't parse streaming tool-call deltas) - Bots address it identically to cloud: `http://keyring-proxy:9101/v1/ollama` +- Supports both OpenAI-compatible paths (`/v1/*`) and native Ollama paths (`/api/embeddings`, `/api/chat`) - Context window set via `OLLAMA_CONTEXT_LENGTH` env var on Ollama container -## Known Issue: memorySearch - -OpenClaw's memorySearch auto-discovery looks for providers named exactly -`"openai"` or `"gemini"`. Our `-proxy` suffix means auto-discovery always -fails. The template generator must explicitly produce a `memorySearch` -section in openclaw.json pointing at the correct keyring-proxy embedding -endpoint, or explicitly disable it for providers without embedding support. - -~13/22 providers support OpenAI-compatible `/embeddings`; the rest -(anthropic, groq, cerebras, perplexity, moonshot) need `enabled: false`. +### OpenClaw 2026.3.x Updates + +BotMaker has been updated for OpenClaw 2026.3.2+: + +**Tools Profile (BREAKING):** +- OpenClaw 2026.3.2 changed default `tools.profile` from implicit "full" to `"messaging"` +- BotMaker wizard exposes 3 profiles: + - **Chat Bot** (`messaging`) - Send messages, view history. Safe for public use. + - **Developer Assistant** (`coding`) - File access, shell commands, memory search + - **Full Access** (`full`) - All tools including web browsing and automation +- Default: `messaging` (matches OpenClaw's new safe default) + +**Telegram Configuration:** +- `streaming: "off"` (default) - Wait for full response before sending (avoids duplicate messages from streaming lane rotation) +- `dmPolicy: "pairing"` - Unknown users must be approved +- `groupPolicy: "allowlist"` - Groups must be explicitly allowed +- `reactionLevel: "ack"` - Bot acknowledges with 👀 while processing + +**Discord Configuration:** +- `streaming: "off"` (default) - Wait for full response before sending (avoids duplicate messages from streaming lane rotation) +- `eventQueue.listenerTimeout: 120000` - 2-minute timeout to prevent killing long LLM calls +- `dmPolicy: "pairing"` + `groupPolicy: "allowlist"` for safety + +**Memory Search:** +- Ollama now uses `provider: "ollama"` natively (OpenClaw 2026.3.2+) +- Other providers use `provider: "openai"` for OpenAI-compatible endpoints +- `fallback: "none"` explicitly set (no silent fallbacks) +- ~13/22 providers support embeddings; rest get `enabled: false` ## CI diff --git a/dashboard/src/config/providers/index.ts b/dashboard/src/config/providers/index.ts index 19ad469..80a4fb6 100644 --- a/dashboard/src/config/providers/index.ts +++ b/dashboard/src/config/providers/index.ts @@ -67,3 +67,8 @@ export function getDefaultModel(providerId: string): string { export function getKeyHint(providerId: string): string { return getProvider(providerId)?.keyHint ?? 'API key'; } + +export function requiresAuth(providerId: string): boolean { + const provider = getProvider(providerId); + return provider ? !provider.noAuth : true; +} diff --git a/dashboard/src/config/providers/venice.ts b/dashboard/src/config/providers/venice.ts index 4c28b35..b1eea48 100644 --- a/dashboard/src/config/providers/venice.ts +++ b/dashboard/src/config/providers/venice.ts @@ -5,14 +5,60 @@ export const venice: ProviderConfig = { label: 'Venice', baseUrl: 'https://api.venice.ai/api/v1', keyHint: 'VENICE-INFERENCE-KEY-...', - defaultModel: 'llama-3.3-70b', + defaultModel: 'zai-org-glm-4.7', models: [ - { id: 'llama-3.3-70b', label: 'Llama 3.3 70B' }, - { id: 'venice-uncensored', label: 'Venice Uncensored' }, - { id: 'qwen3-235b-a22b-thinking-2507', label: 'Qwen3 235B Thinking' }, - { id: 'deepseek-v3.2', label: 'DeepSeek V3.2' }, - { id: 'zai-org-glm-4.7', label: 'ZAI GLM 4.7' }, - { id: 'mistral-31-24b', label: 'Mistral 31 24B' }, + // Venice-hosted models (recommended) + { id: 'venice-uncensored', label: 'Venice Uncensored 1.1' }, + { id: 'zai-org-glm-4.7', label: 'GLM 4.7 (Default)' }, + { id: 'zai-org-glm-5', label: 'GLM 5' }, + { id: 'zai-org-glm-4.7-flash', label: 'GLM 4.7 Flash' }, + { id: 'olafangensan-glm-4.7-flash-heretic', label: 'GLM 4.7 Flash Heretic' }, + { id: 'zai-org-glm-4.6', label: 'GLM 4.6' }, { id: 'kimi-k2-5', label: 'Kimi K2.5' }, + { id: 'kimi-k2-thinking', label: 'Kimi K2 Thinking' }, + { id: 'deepseek-v3.2', label: 'DeepSeek V3.2' }, + + // Qwen models + { id: 'qwen3-235b-a22b-thinking-2507', label: 'Qwen3 235B Thinking' }, + { id: 'qwen3-235b-a22b-instruct-2507', label: 'Qwen3 235B Instruct' }, + { id: 'qwen3-next-80b', label: 'Qwen3 Next 80B' }, + { id: 'qwen3-5-35b-a3b', label: 'Qwen3.5 35B (Beta)' }, + { id: 'qwen3-coder-480b-a35b-instruct', label: 'Qwen3 Coder 480B' }, + { id: 'qwen3-coder-480b-a35b-instruct-turbo', label: 'Qwen3 Coder 480B Turbo (Beta)' }, + { id: 'qwen3-vl-235b-a22b', label: 'Qwen3 VL 235B (Vision)' }, + { id: 'qwen3-4b', label: 'Qwen3 4B (Venice Small)' }, + + // Anthropic Claude + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (Beta)' }, + { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + { id: 'claude-opus-4-6', label: 'Claude Opus 4.6 (Beta)' }, + { id: 'claude-opus-4-5', label: 'Claude Opus 4.5' }, + + // OpenAI GPT + { id: 'openai-gpt-52', label: 'GPT-5.2' }, + { id: 'openai-gpt-52-codex', label: 'GPT-5.2 Codex' }, + { id: 'openai-gpt-53-codex', label: 'GPT-5.3 Codex (Beta)' }, + { id: 'openai-gpt-4o-2024-11-20', label: 'GPT-4o' }, + { id: 'openai-gpt-4o-mini-2024-07-18', label: 'GPT-4o Mini' }, + { id: 'openai-gpt-oss-120b', label: 'GPT OSS 120B' }, + + // Google Gemini + { id: 'gemini-3-1-pro-preview', label: 'Gemini 3.1 Pro Preview' }, + { id: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, + { id: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, + { id: 'google-gemma-3-27b-it', label: 'Google Gemma 3 27B' }, + + // xAI Grok + { id: 'grok-41-fast', label: 'Grok 4.1 Fast' }, + { id: 'grok-code-fast-1', label: 'Grok Code Fast 1' }, + + // Other models + { id: 'mistral-31-24b', label: 'Mistral 3.1 24B (Venice Medium)' }, + { id: 'minimax-m25', label: 'MiniMax M2.5' }, + { id: 'minimax-m21', label: 'MiniMax M2.1' }, + { id: 'llama-3.3-70b', label: 'Llama 3.3 70B' }, + { id: 'llama-3.2-3b', label: 'Llama 3.2 3B' }, + { id: 'hermes-3-llama-3.1-405b', label: 'Hermes 3 Llama 3.1 405B' }, + { id: 'nvidia-nemotron-3-nano-30b-a3b', label: 'NVIDIA Nemotron 3 Nano 30B (Beta)' }, ], }; diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts index 870ea36..d6b5aaa 100644 --- a/dashboard/src/types.ts +++ b/dashboard/src/types.ts @@ -56,6 +56,8 @@ export interface CleanupReport { } export type SessionScope = 'user' | 'channel' | 'global'; +export type ToolsProfile = 'minimal' | 'messaging' | 'coding' | 'full'; +export type StreamingMode = 'off' | 'partial'; export interface WizardFeatures { commands: boolean; @@ -64,12 +66,17 @@ export interface WizardFeatures { sandbox: boolean; sandboxTimeout?: number; sessionScope: SessionScope; + // NEW: OpenClaw 2026.3.x features + toolsProfile: ToolsProfile; + telegramStreaming: StreamingMode; + discordStreaming: StreamingMode; } export interface ProviderConfigInput { providerId: string; model: string; baseUrl?: string; // For direct providers — written to workspace config + apiKey?: string; // For providers that need auth — stored in keyring proxy } export interface ChannelConfigInput { diff --git a/dashboard/src/ui/BotLink.tsx b/dashboard/src/ui/BotLink.tsx index eab86ce..f07f5b8 100644 --- a/dashboard/src/ui/BotLink.tsx +++ b/dashboard/src/ui/BotLink.tsx @@ -3,6 +3,7 @@ import './BotLink.css'; interface BotLinkProps { port: number; hostname?: string; + gatewayToken?: string | null; disabled?: boolean; } @@ -24,9 +25,14 @@ function getLanHost(fallback?: string): string { return host; } -export function BotLink({ port, hostname, disabled }: BotLinkProps) { +export function BotLink({ port, hostname, gatewayToken, disabled }: BotLinkProps) { const host = getLanHost(hostname); - const url = `http://${host}:${port}/`; + // Use HTTPS if we're on a public hostname (not localhost/LAN IP) + const isPublicHost = !/^(localhost|127\.0\.0\.1|10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/.test(host); + const protocol = isPublicHost ? 'https' : 'http'; + const baseUrl = `${protocol}://${host}:${port}/`; + // Include gateway token for Control UI auth (skips device pairing) + const url = gatewayToken ? `${baseUrl}#token=${encodeURIComponent(gatewayToken)}` : baseUrl; if (disabled) { return ( diff --git a/dashboard/src/wizard/context/WizardContext.test.tsx b/dashboard/src/wizard/context/WizardContext.test.tsx index 2a81666..7215cce 100644 --- a/dashboard/src/wizard/context/WizardContext.test.tsx +++ b/dashboard/src/wizard/context/WizardContext.test.tsx @@ -132,7 +132,7 @@ describe('WizardContext', () => { enabledProviders: [], enabledChannels: [], routingTags: [], - features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const }, + features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const, toolsProfile: 'messaging' as const, telegramStreaming: 'off' as const, discordStreaming: 'off' as const }, providerConfigs: {}, channelConfigs: {}, }; @@ -152,7 +152,7 @@ describe('WizardContext', () => { enabledProviders: [], enabledChannels: [], routingTags: [], - features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const }, + features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const, toolsProfile: 'messaging' as const, telegramStreaming: 'off' as const, discordStreaming: 'off' as const }, providerConfigs: {}, channelConfigs: {}, }; @@ -173,7 +173,7 @@ describe('WizardContext', () => { enabledProviders: [], enabledChannels: [], routingTags: [], - features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const }, + features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const, toolsProfile: 'messaging' as const, telegramStreaming: 'off' as const, discordStreaming: 'off' as const }, providerConfigs: {}, channelConfigs: {}, }; @@ -194,7 +194,7 @@ describe('WizardContext', () => { enabledProviders: [], enabledChannels: [], routingTags: [], - features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const }, + features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const, toolsProfile: 'messaging' as const, telegramStreaming: 'off' as const, discordStreaming: 'off' as const }, providerConfigs: {}, channelConfigs: {}, }; @@ -215,8 +215,8 @@ describe('WizardContext', () => { enabledProviders: ['openai'], enabledChannels: ['telegram'], routingTags: [], - features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const }, - providerConfigs: { openai: { model: 'gpt-4' } }, + features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const, toolsProfile: 'messaging' as const, telegramStreaming: 'off' as const, discordStreaming: 'off' as const }, + providerConfigs: { openai: { model: 'gpt-4', apiKey: 'sk-test-key' } }, channelConfigs: { telegram: { token: '' } }, }; @@ -239,7 +239,7 @@ describe('WizardContext', () => { enabledProviders: ['openai'], enabledChannels: ['telegram'], routingTags: [], - features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const }, + features: { commands: true, tts: false, ttsVoice: 'alloy', sandbox: false, sandboxTimeout: 30, sessionScope: 'user' as const, toolsProfile: 'messaging' as const, telegramStreaming: 'off' as const, discordStreaming: 'off' as const }, providerConfigs: { openai: { model: 'gpt-4' } }, channelConfigs: { telegram: { token: 'tg-token' } }, }; diff --git a/dashboard/src/wizard/context/WizardContext.tsx b/dashboard/src/wizard/context/WizardContext.tsx index 475870e..0384fd5 100644 --- a/dashboard/src/wizard/context/WizardContext.tsx +++ b/dashboard/src/wizard/context/WizardContext.tsx @@ -25,7 +25,7 @@ type WizardAction = | { type: 'TOGGLE_CHANNEL'; channelId: string } | { type: 'SET_ROUTING_TAGS'; tags: string[] } | { type: 'SET_FEATURE'; feature: keyof WizardState['features']; value: unknown } - | { type: 'SET_PROVIDER_CONFIG'; providerId: string; config: { model?: string; baseUrl?: string } } + | { type: 'SET_PROVIDER_CONFIG'; providerId: string; config: { model?: string; baseUrl?: string; apiKey?: string } } | { type: 'SET_CHANNEL_CONFIG'; channelId: string; config: { token: string } } | { type: 'RESET' }; @@ -47,6 +47,10 @@ const initialState: WizardState = { sandbox: false, sandboxTimeout: 30, sessionScope: 'user', + // NEW: Smart defaults for OpenClaw 2026.3.x + toolsProfile: 'messaging', // Safe default (OpenClaw 2026.3.2+) + telegramStreaming: 'off', // Avoids duplicate messages from streaming lane rotation + discordStreaming: 'off', // Avoids duplicate messages from streaming lane rotation }, providerConfigs: {}, channelConfigs: {}, diff --git a/dashboard/src/wizard/context/wizardUtils.test.ts b/dashboard/src/wizard/context/wizardUtils.test.ts index a2a8358..8fb1725 100644 --- a/dashboard/src/wizard/context/wizardUtils.test.ts +++ b/dashboard/src/wizard/context/wizardUtils.test.ts @@ -21,6 +21,9 @@ function createDefaultState(): WizardState { sandbox: false, sandboxTimeout: 30, sessionScope: 'user', + toolsProfile: 'messaging', + telegramStreaming: 'off', + discordStreaming: 'off', }, providerConfigs: {}, channelConfigs: {}, @@ -141,11 +144,22 @@ describe('validatePage', () => { }); describe('page 3 (Config)', () => { - it('should require token for each enabled channel', () => { + it('should require API key for providers that need auth', () => { const state = createDefaultState(); state.enabledProviders = ['openai']; state.enabledChannels = ['telegram']; state.providerConfigs = { openai: { model: 'gpt-4' } }; + state.channelConfigs = { telegram: { token: 'bot-token' } }; + const result = validatePage(3, state); + expect(result.valid).toBe(false); + expect(result.error).toBe('API key required for OpenAI'); + }); + + it('should require token for each enabled channel', () => { + const state = createDefaultState(); + state.enabledProviders = ['openai']; + state.enabledChannels = ['telegram']; + state.providerConfigs = { openai: { model: 'gpt-4', apiKey: 'sk-test-key' } }; const result = validatePage(3, state); expect(result.valid).toBe(false); expect(result.error).toBe('Token required for telegram'); @@ -155,7 +169,7 @@ describe('validatePage', () => { const state = createDefaultState(); state.enabledProviders = ['openai']; state.enabledChannels = ['telegram']; - state.providerConfigs = { openai: { model: 'gpt-4' } }; + state.providerConfigs = { openai: { model: 'gpt-4', apiKey: 'sk-test-key' } }; state.channelConfigs = { telegram: { token: 'bot-token' } }; const result = validatePage(3, state); expect(result.valid).toBe(true); @@ -166,8 +180,8 @@ describe('validatePage', () => { state.enabledProviders = ['openai', 'anthropic']; state.enabledChannels = ['telegram', 'discord']; state.providerConfigs = { - openai: { model: 'gpt-4' }, - anthropic: { model: 'claude-3' }, + openai: { model: 'gpt-4', apiKey: 'sk-test-key' }, + anthropic: { model: 'claude-3', apiKey: 'sk-ant-test-key' }, }; state.channelConfigs = { telegram: { token: 'tg-token' }, @@ -176,6 +190,16 @@ describe('validatePage', () => { const result = validatePage(3, state); expect(result.valid).toBe(true); }); + + it('should not require API key for noAuth providers like Ollama', () => { + const state = createDefaultState(); + state.enabledProviders = ['ollama']; + state.enabledChannels = ['telegram']; + state.providerConfigs = { ollama: { model: 'llama3' } }; + state.channelConfigs = { telegram: { token: 'bot-token' } }; + const result = validatePage(3, state); + expect(result.valid).toBe(true); + }); }); describe('page 4 (Summary)', () => { @@ -214,7 +238,7 @@ describe('buildCreateBotInput', () => { hostname: 'test-bot', emoji: '🤖', avatarUrl: undefined, - providers: [{ providerId: 'openai', model: 'gpt-4' }], + providers: [{ providerId: 'openai', model: 'gpt-4', baseUrl: undefined }], primaryProvider: 'openai', channels: [{ channelType: 'telegram', token: 'bot-token' }], persona: { @@ -228,6 +252,9 @@ describe('buildCreateBotInput', () => { sandbox: false, sandboxTimeout: undefined, sessionScope: 'user', + toolsProfile: 'messaging', + telegramStreaming: 'off', + discordStreaming: 'off', }, tags: undefined, }); diff --git a/dashboard/src/wizard/context/wizardUtils.ts b/dashboard/src/wizard/context/wizardUtils.ts index ddad5b9..eb9aa74 100644 --- a/dashboard/src/wizard/context/wizardUtils.ts +++ b/dashboard/src/wizard/context/wizardUtils.ts @@ -1,4 +1,5 @@ -import type { SessionScope, CreateBotInput } from '../../types'; +import type { SessionScope, ToolsProfile, StreamingMode, CreateBotInput } from '../../types'; +import { requiresAuth, getProvider } from '../../config/providers'; export interface WizardState { selectedTemplateId: string | null; @@ -18,8 +19,11 @@ export interface WizardState { sandbox: boolean; sandboxTimeout: number; sessionScope: SessionScope; + toolsProfile: ToolsProfile; + telegramStreaming: StreamingMode; + discordStreaming: StreamingMode; }; - providerConfigs: Record; + providerConfigs: Record; channelConfigs: Record; } @@ -64,7 +68,17 @@ export function validatePage(page: number, state: WizardState): ValidationResult return { valid: true }; case 3: - // Config details + // Config details - check providers that need API keys + for (const providerId of state.enabledProviders) { + if (requiresAuth(providerId)) { + const config = state.providerConfigs[providerId]; + if (!config?.apiKey?.trim()) { + const provider = getProvider(providerId); + return { valid: false, error: `API key required for ${provider?.label ?? providerId}` }; + } + } + } + // Check channel tokens for (const channelId of state.enabledChannels) { const config = state.channelConfigs[channelId]; if (!config?.token.trim()) { @@ -87,6 +101,7 @@ export function buildCreateBotInput(state: WizardState): CreateBotInput { providerId, model: state.providerConfigs[providerId]?.model ?? '', baseUrl: state.providerConfigs[providerId]?.baseUrl, + apiKey: state.providerConfigs[providerId]?.apiKey, })); const channels = state.enabledChannels.map((channelType) => ({ @@ -113,6 +128,9 @@ export function buildCreateBotInput(state: WizardState): CreateBotInput { sandbox: state.features.sandbox, sandboxTimeout: state.features.sandbox ? state.features.sandboxTimeout : undefined, sessionScope: state.features.sessionScope, + toolsProfile: state.features.toolsProfile, + telegramStreaming: state.features.telegramStreaming, + discordStreaming: state.features.discordStreaming, }, tags: state.routingTags.length > 0 ? state.routingTags : undefined, }; diff --git a/dashboard/src/wizard/pages/Page3Toggles.css b/dashboard/src/wizard/pages/Page3Toggles.css index 8581471..295d321 100644 --- a/dashboard/src/wizard/pages/Page3Toggles.css +++ b/dashboard/src/wizard/pages/Page3Toggles.css @@ -20,6 +20,13 @@ margin: 0; } +.page3-section-hint { + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; + margin: 0; +} + .page3-provider-search { margin-bottom: var(--space-xs); } @@ -85,6 +92,11 @@ gap: var(--space-md); } +.page3-radio-options--capabilities { + flex-direction: column; + gap: var(--space-sm); +} + .page3-radio-option { display: flex; align-items: center; @@ -118,6 +130,53 @@ text-transform: capitalize; } +.page3-radio-option--stacked { + padding: var(--space-sm); + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + background: var(--bg-panel); + align-items: flex-start; +} + +.page3-radio-option--stacked:hover { + border-color: var(--accent-primary); + background: var(--accent-primary-glow); +} + +.page3-radio-option--stacked:has(input:checked) { + border-color: var(--accent-primary); + background: var(--accent-primary-glow); +} + +.page3-radio-option--stacked .page3-radio-box { + flex-shrink: 0; + margin-top: 2px; +} + +.page3-radio-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.page3-radio-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.page3-radio-option--stacked:has(input:checked) .page3-radio-title { + color: var(--accent-primary); +} + +.page3-radio-desc { + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; +} + /* Tags field */ .page3-tags-field { display: flex; diff --git a/dashboard/src/wizard/pages/Page3Toggles.tsx b/dashboard/src/wizard/pages/Page3Toggles.tsx index cacf691..b5e3144 100644 --- a/dashboard/src/wizard/pages/Page3Toggles.tsx +++ b/dashboard/src/wizard/pages/Page3Toggles.tsx @@ -3,7 +3,7 @@ import { useWizard } from '../context/WizardContext'; import { PROVIDERS, PROVIDER_CATEGORIES } from '../../config/providers'; import { POPULAR_CHANNELS, OTHER_CHANNELS } from '../../config/channels'; import { FeatureCheckbox } from '../components'; -import type { SessionScope } from '../../types'; +import type { SessionScope, ToolsProfile } from '../../types'; import './Page3Toggles.css'; const ALWAYS_VISIBLE_CATEGORIES = ['major', 'local']; @@ -48,6 +48,10 @@ export function Page3Toggles() { dispatch({ type: 'SET_FEATURE', feature: 'sessionScope', value: scope }); }; + const handleToolsProfileChange = (profile: ToolsProfile) => { + dispatch({ type: 'SET_FEATURE', feature: 'toolsProfile', value: profile }); + }; + const handleTagsChange = (e: React.ChangeEvent) => { const value = e.target.value; const tags = value @@ -186,6 +190,59 @@ export function Page3Toggles() { +
+

Bot Capabilities

+

+ Choose what tools your bot can use. This affects security and functionality. +

+
+
+ + + +
+
+
+

API Routing Tags

diff --git a/dashboard/src/wizard/pages/Page4Config.css b/dashboard/src/wizard/pages/Page4Config.css index 595be71..0d264d4 100644 --- a/dashboard/src/wizard/pages/Page4Config.css +++ b/dashboard/src/wizard/pages/Page4Config.css @@ -55,3 +55,10 @@ .page4-refresh-btn:hover { background: var(--bg-hover, #333); } + +.page4-key-hint { + display: block; + font-size: 11px; + color: var(--text-muted); + margin-top: var(--space-xs); +} diff --git a/dashboard/src/wizard/pages/Page4Config.tsx b/dashboard/src/wizard/pages/Page4Config.tsx index a74885a..fd7bc9c 100644 --- a/dashboard/src/wizard/pages/Page4Config.tsx +++ b/dashboard/src/wizard/pages/Page4Config.tsx @@ -40,8 +40,10 @@ export function Page4Config() { // Track per-provider base URL overrides (for baseUrlEditable providers) const [baseUrls, setBaseUrls] = useState>({}); - // Track per-provider API key for dynamic model fetching - const [apiKeys, setApiKeys] = useState>({}); + + const handleApiKeyChange = (providerId: string, apiKey: string) => { + dispatch({ type: 'SET_PROVIDER_CONFIG', providerId, config: { apiKey } }); + }; const handleModelChange = (providerId: string, model: string) => { dispatch({ type: 'SET_PROVIDER_CONFIG', providerId, config: { model } }); @@ -84,10 +86,8 @@ export function Page4Config() { setBaseUrls((prev) => ({ ...prev, [providerId]: url })); dispatch({ type: 'SET_PROVIDER_CONFIG', providerId, config: { baseUrl: url } }); }} - apiKey={apiKeys[providerId] ?? ''} - onApiKeyChange={(key) => { - setApiKeys((prev) => ({ ...prev, [providerId]: key })); - }} + apiKey={state.providerConfigs[providerId]?.apiKey ?? ''} + onApiKeyChange={(key) => { handleApiKeyChange(providerId, key); }} model={state.providerConfigs[providerId]?.model ?? ''} onModelChange={(model) => { handleModelChange(providerId, model); }} /> @@ -96,6 +96,7 @@ export function Page4Config() { const models = getModels(providerId); const config = state.providerConfigs[providerId] ?? { model: '' }; + const needsApiKey = !provider?.noAuth; return ( + {/* API Key - show if provider needs auth */} + {needsApiKey && ( +
+ + { handleApiKeyChange(providerId, e.target.value); }} + placeholder={provider?.keyHint ?? 'API key required'} + /> + + API key will be securely stored in the keyring proxy + +
+ )} +
{ setKeyInputs(prev => ({ ...prev, [providerId]: e.target.value })); }} + disabled={isAdding} + /> + +
+
+ ); + })} + + + )} +
@@ -116,6 +228,15 @@ export function Page5Summary() { Session Scope {state.features.sessionScope}
+
+ Capabilities + + {state.features.toolsProfile === 'messaging' && 'Chat Bot'} + {state.features.toolsProfile === 'coding' && 'Developer Assistant'} + {state.features.toolsProfile === 'full' && 'Full Access'} + {state.features.toolsProfile === 'minimal' && 'Minimal'} + +
{state.routingTags.length > 0 && (
Routing Tags diff --git a/docker-compose.yml b/docker-compose.yml index be74c28..163e1e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,15 +12,15 @@ services: profiles: ["https"] # Optional HTTPS reverse proxy (requires Caddyfile) image: caddy:2-alpine container_name: caddy - ports: - - "80:80" - - "443:443" + network_mode: host # Avoids 100+ docker-proxy processes for port range + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - PUBLIC_HOST=${PUBLIC_HOST:-localhost} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config - networks: - - bm-internal restart: unless-stopped depends_on: - botmaker @@ -53,6 +53,9 @@ services: - PROXY_ADMIN_TOKEN_FILE=/secrets/proxy_admin_token # Dashboard login password (file-based, minimum 12 characters) - ADMIN_PASSWORD_FILE=/secrets/admin_password + # HTTPS configuration (set when using https profile) + - CADDY_ENABLED=${CADDY_ENABLED:-false} + - PUBLIC_HOST=${PUBLIC_HOST:-} depends_on: - keyring-proxy networks: diff --git a/proxy/src/services/upstream.ts b/proxy/src/services/upstream.ts index 1b8c9e6..5a8d65f 100644 --- a/proxy/src/services/upstream.ts +++ b/proxy/src/services/upstream.ts @@ -47,10 +47,16 @@ export async function forwardToUpstream( upstreamHeaders[vendorConfig.authHeader.toLowerCase()] = vendorConfig.authFormat(apiKey); } + // Skip forceNonStreaming for naturally non-streaming endpoints (embeddings, model info) + const isNonStreamingEndpoint = path.includes('/embeddings') || + path.includes('/api/tags') || + path.includes('/api/show'); + const shouldForceNonStreaming = forceNonStreaming && !isNonStreamingEndpoint; + // Handle forceNonStreaming: strip stream:true from request body let finalBody = body; let wasStreaming = false; - if (forceNonStreaming && body) { + if (shouldForceNonStreaming && body) { try { const json = JSON.parse(body.toString('utf8')) as Record; if (json.stream === true) { diff --git a/proxy/src/types.ts b/proxy/src/types.ts index 2d4f087..a1f474c 100644 --- a/proxy/src/types.ts +++ b/proxy/src/types.ts @@ -172,7 +172,7 @@ export function initOllamaVendor(upstream: string): void { host: url.hostname, port: parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80), protocol: url.protocol === 'https:' ? 'https' : 'http', - basePath: '/v1', + basePath: '', // Empty to support both /v1/* (OpenAI compat) and /api/* (native) authHeader: 'Authorization', authFormat: () => '', noAuth: true, diff --git a/src/bots/templates.test.ts b/src/bots/templates.test.ts index 4e99a29..ba153f5 100644 --- a/src/bots/templates.test.ts +++ b/src/bots/templates.test.ts @@ -24,13 +24,18 @@ describe('templates', () => { botName: 'Test Bot', aiProvider: 'openai', model: 'gpt-4', - channel: { type: 'telegram', token: 'tg-token-123' }, + channels: [{ type: 'telegram', token: 'tg-token-123' }], persona: { name: 'TestBot', identity: 'A helpful test assistant', description: 'I help with testing', }, port: 19000, + features: { + toolsProfile: 'messaging', + telegramStreaming: 'partial', + discordStreaming: 'partial', + }, ...overrides, }; } @@ -64,15 +69,16 @@ describe('templates', () => { const proxy = { baseUrl: 'http://proxy:9101/v1/openai', token: 'tok-123' }; it.each([ - ['openai', 'text-embedding-3-small'], - ['mistral', 'mistral-embed'], - ['ollama', 'nomic-embed-text'], - ['deepseek', 'text-embedding-3-small'], - ])('%s with proxy returns embedding model %s', (provider, model) => { + ['openai', 'openai', 'text-embedding-3-small'], + ['mistral', 'openai', 'mistral-embed'], + ['ollama', 'ollama', 'nomic-embed-text'], + ['deepseek', 'openai', 'text-embedding-3-small'], + ])('%s with proxy returns embedding model %s', (provider, expectedProvider, model) => { expect(getMemorySearchConfig(provider, proxy)).toEqual({ - provider: 'openai', + provider: expectedProvider, model, remote: { baseUrl: proxy.baseUrl, apiKey: proxy.token }, + fallback: 'none', }); }); @@ -134,14 +140,25 @@ describe('templates', () => { const openclawPath = join(testDir, 'bots', config.botHostname, 'openclaw.json'); const openclawConfig = JSON.parse(readFileSync(openclawPath, 'utf-8')) as Record; + // Internal port is always BOT_INTERNAL_PORT (8080) for Caddy integration expect(openclawConfig.gateway).toEqual({ mode: 'local', - port: 19000, + port: 8080, bind: 'lan', auth: { mode: 'token' }, - controlUi: { allowInsecureAuth: true }, + controlUi: { allowInsecureAuth: true, dangerouslyDisableDeviceAuth: true }, + }); + expect((openclawConfig.channels as Record).telegram).toEqual({ + enabled: true, + tokenFile: '/run/secrets/TELEGRAM_TOKEN', + streaming: 'off', + dmPolicy: 'pairing', + groupPolicy: 'allowlist', + reactionLevel: 'ack', + ackReaction: '👀', + historyLimit: 50, + textChunkLimit: 4000, }); - expect((openclawConfig.channels as Record).telegram).toEqual({ enabled: true }); const defaults = (openclawConfig.agents as Record).defaults as Record; expect(defaults.model).toEqual({ primary: 'openai/gpt-4' }); expect(defaults.memorySearch).toEqual({ enabled: false }); @@ -179,6 +196,7 @@ describe('templates', () => { baseUrl: 'http://proxy:9101/v1', apiKey: 'proxy-token-123', }, + fallback: 'none', }); }); @@ -193,7 +211,7 @@ describe('templates', () => { it('should handle discord channel', () => { const config = createTestConfig({ - channel: { type: 'discord', token: 'dc-token-123' }, + channels: [{ type: 'discord', token: 'dc-token-123' }], }); createBotWorkspace(testDir, config); @@ -201,7 +219,7 @@ describe('templates', () => { const openclawConfig = JSON.parse(readFileSync(openclawPath, 'utf-8')) as Record; const channels = openclawConfig.channels as Record; - expect(channels.discord).toEqual({ enabled: true }); + expect(channels.discord).toBeDefined(); expect(channels.telegram).toBeUndefined(); }); diff --git a/src/bots/templates.ts b/src/bots/templates.ts index 5cbf688..3709b42 100644 --- a/src/bots/templates.ts +++ b/src/bots/templates.ts @@ -25,12 +25,22 @@ function tryChown(path: string, uid: number, gid: number): boolean { const OPENCLAW_UID = 1000; const OPENCLAW_GID = 1000; +/** + * Internal port that all bot containers listen on. + * This is the port OpenClaw gateway binds to inside the container. + * External access is via unique ports (19000+) proxied by Caddy. + */ +export const BOT_INTERNAL_PORT = 8080; + function setOwnership(path: string, mode: number): void { const owned = tryChown(path, OPENCLAW_UID, OPENCLAW_GID); // If chown failed, widen permissions so container UID 1000 can still write chmodSync(path, owned ? mode : mode | 0o022); } +export type ToolsProfile = 'minimal' | 'messaging' | 'coding' | 'full'; +export type StreamingMode = 'off' | 'partial'; + export interface BotPersona { name: string; identity: string; @@ -38,7 +48,7 @@ export interface BotPersona { } export interface ChannelConfig { - type: 'telegram' | 'discord'; + type: string; // Support all OpenClaw channels token: string; } @@ -47,16 +57,25 @@ export interface ProxyConfig { token: string; } +export interface BotFeatures { + toolsProfile: ToolsProfile; + telegramStreaming: StreamingMode; + discordStreaming: StreamingMode; +} + export interface BotWorkspaceConfig { botId: string; botHostname: string; botName: string; aiProvider: string; model: string; - channel: ChannelConfig; + channels: ChannelConfig[]; // Support multiple channels persona: BotPersona; port: number; proxy?: ProxyConfig; + features: BotFeatures; + publicHost?: string; // Public hostname for CORS allowedOrigins + gatewayToken?: string; // Auth token for Control UI access } /** @@ -128,9 +147,16 @@ export const EMBEDDING_MODELS: Record = { ovhcloud: null, }; +export type MemorySearchProvider = 'openai' | 'gemini' | 'local' | 'voyage' | 'mistral' | 'ollama'; + export type MemorySearchConfig = | { enabled: false } - | { provider: 'openai'; model: string; remote: { baseUrl: string; apiKey: string } }; + | { + provider: MemorySearchProvider; + model: string; + remote: { baseUrl: string; apiKey: string }; + fallback?: MemorySearchProvider | 'none'; + }; /** * Build memorySearch config for openclaw.json. @@ -147,13 +173,18 @@ export function getMemorySearchConfig( return { enabled: false }; } + // Ollama uses native provider type (OpenClaw 2026.3.2+) + // Others use openai-compatible endpoint + const memoryProvider: MemorySearchProvider = provider === 'ollama' ? 'ollama' : 'openai'; + return { - provider: 'openai', + provider: memoryProvider, model: embeddingModel, remote: { baseUrl: proxy.baseUrl, apiKey: proxy.token, }, + fallback: 'none', // Explicit - no silent fallback }; } @@ -189,23 +220,79 @@ function generateOpenclawConfig(config: BotWorkspaceConfig): object { modelsConfig = undefined; } + // Build channel configurations with smart defaults + const channels: Record = {}; + + for (const channel of config.channels) { + if (channel.type === 'telegram') { + channels.telegram = { + enabled: true, + // Token is stored in secrets volume, mounted at /run/secrets in container + tokenFile: '/run/secrets/TELEGRAM_TOKEN', + streaming: 'off', // Always off: "partial" causes duplicate messages via lane rotation + dmPolicy: 'pairing', + groupPolicy: 'allowlist', + reactionLevel: 'ack', + ackReaction: '👀', + textChunkLimit: 4000, + historyLimit: 50, + }; + } else if (channel.type === 'discord') { + channels.discord = { + enabled: true, + streaming: 'off', // Always off: "partial" causes duplicate messages via lane rotation + dmPolicy: 'pairing', + groupPolicy: 'allowlist', + textChunkLimit: 2000, + maxLinesPerMessage: 17, + eventQueue: { + listenerTimeout: 120000, // 2 min for long LLM calls + }, + }; + } else { + // Other channels: minimal config, OpenClaw handles defaults + channels[channel.type] = { + enabled: true, + }; + } + } + return { gateway: { mode: 'local', - port: config.port, + // Internal port - all bots use same port inside container + // External access is via unique ports (19000+) proxied by Caddy + port: BOT_INTERNAL_PORT, bind: 'lan', auth: { mode: 'token', + // Explicit token so BotMaker dashboard can link with auth + ...(config.gatewayToken && { token: config.gatewayToken }), }, controlUi: { allowInsecureAuth: true, + // Skip device pairing for Control UI connections with valid gateway token. + // BotMaker always uses token auth, and the token is included in the dashboard + // link URL. Device pairing is redundant when shared auth (token) is present. + // The "dangerous" label is misleading - it's safe when combined with token auth. + dangerouslyDisableDeviceAuth: true, + // Include public hostname for CORS (OpenClaw auto-seeds localhost/127.0.0.1) + // Use config.port for external-facing origins (the unique port per bot) + ...(config.publicHost && { + allowedOrigins: [ + `http://localhost:${config.port}`, + `http://127.0.0.1:${config.port}`, + `http://${config.publicHost}:${config.port}`, + `https://${config.publicHost}:${config.port}`, // Port-based HTTPS via Caddy + ] + }), }, }, - channels: { - [config.channel.type]: { - enabled: true, - }, + // NEW: Tools profile (OpenClaw 2026.3.2+ defaults to "messaging") + tools: { + profile: config.features.toolsProfile, }, + channels, agents: { defaults: { model: { diff --git a/src/config.ts b/src/config.ts index 97d937b..e2b8a56 100644 --- a/src/config.ts +++ b/src/config.ts @@ -37,6 +37,10 @@ export interface AppConfig { adminPassword: string; /** Session token expiry in milliseconds (default 24 hours) */ sessionExpiryMs: number; + /** Public hostname for bot UI access (optional, for CORS allowedOrigins) */ + publicHost: string | null; + /** Whether Caddy HTTPS proxy is enabled (set at compose time) */ + caddyEnabled: boolean; } function getEnvOrDefault(key: string, defaultValue: string): string { @@ -96,6 +100,8 @@ export function getConfig(): AppConfig { proxyAdminToken, adminPassword, sessionExpiryMs: getEnvIntOrDefault('SESSION_EXPIRY_MS', 24 * 60 * 60 * 1000), + publicHost: process.env.PUBLIC_HOST ?? null, + caddyEnabled: process.env.CADDY_ENABLED === 'true', }; } diff --git a/src/secrets/manager.ts b/src/secrets/manager.ts index 4f9caa1..392d38c 100644 --- a/src/secrets/manager.ts +++ b/src/secrets/manager.ts @@ -34,7 +34,9 @@ export function validateHostname(hostname: string): void { /** * Creates a secrets directory for a specific bot. - * Directory is created with mode 0700 (owner read/write/execute only). + * Directory is created with mode 0755 (world-readable) because bot containers + * run as non-root user 'node' (uid 1000) but secrets are written by root. + * Security is maintained by per-bot isolation (each bot only sees its own dir). * * @param hostname - Hostname of the bot * @returns Path to the created directory @@ -46,15 +48,16 @@ export function createBotSecretsDir(hostname: string): string { const secretsRoot = getSecretsRoot(); const botDir = join(secretsRoot, hostname); - mkdirSync(botDir, { mode: 0o700, recursive: true }); + mkdirSync(botDir, { mode: 0o755, recursive: true }); return botDir; } /** * Writes a secret file for a bot. - * File is written with mode 0600 (owner read/write only). - * Creates the bot's secrets directory if it doesn't exist. + * File is written with mode 0644 (world-readable) because bot containers + * run as non-root user 'node' (uid 1000) but secrets are written by root. + * Security is maintained by per-bot isolation (each bot only sees its own dir). * * @param hostname - Hostname of the bot * @param name - Name of the secret (becomes filename) @@ -71,7 +74,7 @@ export function writeSecret(hostname: string, name: string, value: string): void const botDir = createBotSecretsDir(hostname); const filePath = join(botDir, name); - writeFileSync(filePath, value, { mode: 0o600 }); + writeFileSync(filePath, value, { mode: 0o644 }); } /** From 17573c2831d29b3195152134312d57b04df31b9d Mon Sep 17 00:00:00 2001 From: Jeff Garzik Date: Wed, 4 Mar 2026 04:50:14 +0000 Subject: [PATCH 4/6] feat: add Telegram pairing approval, CaddyService, and exec support Add DockerService.execCommand() for running commands inside bot containers, CaddyService for dynamic HTTPS routing via Caddy admin API, and POST /api/bots/:hostname/pair endpoint for approving Telegram pairing codes from the dashboard. Includes inline pairing UI in BotCard with code input, success/error animations, and full test coverage. Co-Authored-By: Claude Opus 4.6 --- dashboard/src/App.tsx | 2 + dashboard/src/api.test.ts | 31 ++++- dashboard/src/api.ts | 9 ++ dashboard/src/dashboard/BotCard.css | 104 ++++++++++++++ dashboard/src/dashboard/BotCard.tsx | 104 +++++++++++++- dashboard/src/dashboard/DashboardTab.tsx | 7 + dashboard/src/dashboard/StatusSection.tsx | 3 + dashboard/src/hooks/useBots.test.ts | 38 +++++- dashboard/src/hooks/useBots.ts | 13 +- src/server.ts | 127 ++++++++++++++++- src/services/CaddyService.test.ts | 140 +++++++++++++++++++ src/services/CaddyService.ts | 159 ++++++++++++++++++++++ src/services/DockerService.test.ts | 129 ++++++++++++++++++ src/services/DockerService.ts | 101 +++++++++++++- src/types/container.ts | 7 + 15 files changed, 957 insertions(+), 17 deletions(-) create mode 100644 src/services/CaddyService.test.ts create mode 100644 src/services/CaddyService.ts create mode 100644 src/services/DockerService.test.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index e785962..91bf2d1 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -22,6 +22,7 @@ export default function App() { handleStart, handleStop, handleDelete, + handlePair, } = useBots(); // Show login form if not authenticated @@ -47,6 +48,7 @@ export default function App() { onStart={(id) => { void handleStart(id); }} onStop={(id) => { void handleStop(id); }} onDelete={(id) => { void handleDelete(id); }} + onPair={handlePair} onCreateClick={() => { setShowWizard(true); }} /> )} diff --git a/dashboard/src/api.test.ts b/dashboard/src/api.test.ts index 02b39d9..5db6809 100644 --- a/dashboard/src/api.test.ts +++ b/dashboard/src/api.test.ts @@ -6,6 +6,7 @@ import { deleteBot, startBot, stopBot, + pairBot, fetchContainerStats, fetchOrphans, runCleanup, @@ -104,7 +105,7 @@ describe('API Client', () => { primaryProvider: 'openai', channels: [{ channelType: 'telegram', token: 'tk' }], persona: { name: 'New Bot', soulMarkdown: 'test' }, - features: { commands: false, tts: false, sandbox: false, sessionScope: 'user' as const }, + features: { commands: false, tts: false, sandbox: false, sessionScope: 'user' as const, toolsProfile: 'messaging' as const, telegramStreaming: 'off' as const, discordStreaming: 'off' as const }, }; const createdBot = { id: '1', ...input }; @@ -169,6 +170,34 @@ describe('API Client', () => { }); }); + describe('pairBot', () => { + it('should send pairing code and return result', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, message: 'Pairing code approved' }), + }); + + const result = await pairBot('bot-1', 'ABCD2345'); + + expect(result).toEqual({ success: true, message: 'Pairing code approved' }); + expect(mockFetch).toHaveBeenCalledWith('/api/bots/bot-1/pair', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...AUTH_HEADER }, + body: JSON.stringify({ code: 'ABCD2345' }), + }); + }); + + it('should throw on error response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 422, + json: () => Promise.resolve({ error: 'Invalid pairing code' }), + }); + + await expect(pairBot('bot-1', 'BADCODE')).rejects.toThrow('Invalid pairing code'); + }); + }); + describe('fetchContainerStats', () => { it('should fetch container stats', async () => { const stats = [ diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 41e8e9e..11e188c 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -179,6 +179,15 @@ export async function fetchProxyHealth(): Promise { return handleResponse(response); } +export async function pairBot(hostname: string, code: string): Promise<{ success: boolean; message: string }> { + const response = await fetch(`${API_BASE}/bots/${hostname}/pair`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, + body: JSON.stringify({ code }), + }); + return handleResponse<{ success: boolean; message: string }>(response); +} + export async function fetchDynamicModels(baseUrl: string, apiKey?: string): Promise { const body: { baseUrl: string; apiKey?: string } = { baseUrl }; if (apiKey) { diff --git a/dashboard/src/dashboard/BotCard.css b/dashboard/src/dashboard/BotCard.css index df1a46f..c0cdda4 100644 --- a/dashboard/src/dashboard/BotCard.css +++ b/dashboard/src/dashboard/BotCard.css @@ -87,6 +87,110 @@ border-bottom: 1px solid var(--border-color-subtle); } +/* Pairing section */ +.bot-card-pairing { + border-top: 1px solid var(--border-color-subtle); + padding-top: var(--space-sm); +} + +.bot-card-pairing-header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: none; + border: none; + padding: var(--space-xs) 0; + cursor: pointer; + color: var(--text-muted); +} + +.bot-card-pairing-header:hover { + color: var(--text-secondary); +} + +.bot-card-pairing-title { + font-family: var(--font-display); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +.bot-card-pairing-chevron { + transition: transform 0.2s ease; +} + +.bot-card-pairing-chevron--expanded { + transform: rotate(180deg); +} + +.bot-card-pairing-body { + padding: var(--space-sm) 0 var(--space-xs); +} + +.bot-card-pairing-input-row { + display: flex; + gap: var(--space-sm); + align-items: center; +} + +.bot-card-pairing-input { + flex: 1; + font-family: var(--font-mono); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.25em; + text-align: center; + text-transform: uppercase; + padding: var(--space-xs) var(--space-sm); + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.bot-card-pairing-input::placeholder { + color: var(--text-muted); + letter-spacing: 0.15em; + font-weight: 400; + font-size: 11px; +} + +.bot-card-pairing-input:focus { + border-color: var(--accent-primary); + box-shadow: 0 0 0 1px var(--accent-primary); +} + +.bot-card-pairing-input--error { + animation: pairing-error-flash 0.4s ease; + border-color: var(--color-danger); +} + +.bot-card-pairing-input--success { + color: var(--color-success); + border-color: var(--color-success); + animation: pairing-success-flash 0.6s ease; +} + +.bot-card-pairing-error { + font-size: 11px; + color: var(--color-danger); + margin-top: var(--space-xs); +} + +@keyframes pairing-success-flash { + 0% { text-shadow: 0 0 8px var(--color-success); } + 100% { text-shadow: none; } +} + +@keyframes pairing-error-flash { + 0%, 100% { border-color: var(--color-danger); } + 50% { border-color: transparent; } +} + /* Actions */ .bot-card-actions { display: flex; diff --git a/dashboard/src/dashboard/BotCard.tsx b/dashboard/src/dashboard/BotCard.tsx index 5bb7c9f..3c7d2f1 100644 --- a/dashboard/src/dashboard/BotCard.tsx +++ b/dashboard/src/dashboard/BotCard.tsx @@ -9,11 +9,14 @@ import { BotLink } from '../ui/BotLink'; import { getEffectiveStatus } from '../utils/bot-status'; import './BotCard.css'; +type PairingState = 'idle' | 'loading' | 'success' | 'error'; + interface BotCardProps { bot: Bot; onStart: (hostname: string) => void; onStop: (hostname: string) => void; onDelete: (hostname: string) => void; + onPair: (hostname: string, code: string) => Promise; loading: boolean; } @@ -31,11 +34,19 @@ const channelIcons: Record = { discord: 'DC', }; -export function BotCard({ bot, onStart, onStop, onDelete, loading }: BotCardProps) { +// Valid pairing code alphabet (no I, O, 0, 1) +const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +export function BotCard({ bot, onStart, onStop, onDelete, onPair, loading }: BotCardProps) { const [confirmDelete, setConfirmDelete] = useState(false); + const [pairingExpanded, setPairingExpanded] = useState(false); + const [pairingCode, setPairingCode] = useState(''); + const [pairingState, setPairingState] = useState('idle'); + const [pairingError, setPairingError] = useState(''); const status = getEffectiveStatus(bot); const isStarting = status === 'starting'; const isRunning = status === 'running'; + const showPairing = bot.channel_type === 'telegram' && isRunning; const handleDelete = () => { if (confirmDelete) { @@ -48,6 +59,41 @@ export function BotCard({ bot, onStart, onStop, onDelete, loading }: BotCardProp } }; + const handlePairingInput = (value: string) => { + const filtered = value + .toUpperCase() + .split('') + .filter(ch => PAIRING_ALPHABET.includes(ch)) + .join('') + .slice(0, 8); + setPairingCode(filtered); + if (pairingState === 'error') { + setPairingState('idle'); + setPairingError(''); + } + }; + + const handlePairSubmit = async () => { + if (pairingCode.length !== 8 || pairingState === 'loading') return; + setPairingState('loading'); + setPairingError(''); + try { + await onPair(bot.hostname, pairingCode); + setPairingState('success'); + setTimeout(() => { + setPairingState('idle'); + setPairingCode(''); + }, 2000); + } catch (err) { + setPairingState('error'); + setPairingError(err instanceof Error ? err.message : 'Pairing failed'); + setTimeout(() => { + setPairingState('idle'); + setPairingError(''); + }, 3000); + } + }; + return (
@@ -91,7 +137,7 @@ export function BotCard({ bot, onStart, onStop, onDelete, loading }: BotCardProp {bot.port && (isRunning || isStarting) && (
- +
)} @@ -101,6 +147,60 @@ export function BotCard({ bot, onStart, onStop, onDelete, loading }: BotCardProp
)} + {showPairing && ( +
+ + + {pairingExpanded && ( +
+
+ { handlePairingInput(e.target.value); }} + onKeyDown={(e) => { if (e.key === 'Enter') void handlePairSubmit(); }} + placeholder="ENTER CODE" + maxLength={8} + disabled={pairingState === 'loading' || pairingState === 'success'} + autoComplete="off" + spellCheck={false} + /> + +
+ {pairingError && ( +
{pairingError}
+ )} +
+ )} +
+ )} +
{isRunning ? (