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/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/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/HTTPS.md b/HTTPS.md new file mode 100644 index 0000000..ccab5cb --- /dev/null +++ b/HTTPS.md @@ -0,0 +1,46 @@ +# 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. **Set required environment variables** (e.g., in `.env`): + ```bash + PUBLIC_HOST=yourdomain.com + CADDY_ENABLED=true + ``` + Without these, BotMaker will bind bot ports directly instead of routing through Caddy, and per-bot HTTPS routes won't be registered. + +4. **Start with HTTPS profile:** + ```bash + docker compose --profile https up -d + ``` + +## Requirements + +- Your domain must be publicly accessible on ports 80 and 443 +- Bot Control UI ports (19000+) must also be reachable from clients for `https://PUBLIC_HOST:` access +- 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` 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/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/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 ? (
+
+

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 ee613b9..163e1e4 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 + 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 + restart: unless-stopped + depends_on: + - botmaker + botmaker: build: . container_name: botmaker @@ -36,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: @@ -82,3 +102,5 @@ volumes: botmaker-data: botmaker-secrets: proxy-data: + caddy-data: + caddy-config: 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..2b328ca 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,20 +140,53 @@ 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: 'partial', + 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 }); expect(openclawConfig.models).toBeUndefined(); }); + it('should use default streaming off when not overridden', () => { + const config = createTestConfig({ + features: { + toolsProfile: 'messaging', + telegramStreaming: 'off', + discordStreaming: 'off', + }, + channels: [ + { type: 'telegram', token: 'tg-token-123' }, + { type: 'discord', token: 'dc-token-123' }, + ], + }); + createBotWorkspace(testDir, config); + + const openclawPath = join(testDir, 'bots', config.botHostname, 'openclaw.json'); + const openclawConfig = JSON.parse(readFileSync(openclawPath, 'utf-8')) as Record; + const channels = openclawConfig.channels as Record>; + + expect(channels.telegram.streaming).toBe('off'); + expect(channels.discord.streaming).toBe('off'); + }); + it('should create openclaw.json with proxy', () => { const config = createTestConfig({ proxy: { @@ -179,6 +218,7 @@ describe('templates', () => { baseUrl: 'http://proxy:9101/v1', apiKey: 'proxy-token-123', }, + fallback: 'none', }); }); @@ -193,7 +233,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 +241,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..004b159 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: config.features.telegramStreaming, // Default '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: config.features.discordStreaming, // Default '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.test.ts b/src/config.test.ts index 82367b1..f0d9af4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -19,6 +19,8 @@ describe('Config', () => { delete process.env.PROXY_ADMIN_TOKEN_FILE; delete process.env.ADMIN_PASSWORD; delete process.env.ADMIN_PASSWORD_FILE; + delete process.env.PUBLIC_HOST; + delete process.env.CADDY_ENABLED; // Set valid admin password for tests (required, min 12 chars) process.env.ADMIN_PASSWORD = 'test-password-12chars'; @@ -139,4 +141,64 @@ describe('Config', () => { const config = getConfig(); expect(config.adminPassword).toBe('valid-password-12'); }); + + describe('PUBLIC_HOST normalization', () => { + it('should normalize empty PUBLIC_HOST to null', async () => { + const { getConfig } = await import('./config.js'); + // Clear after import (dotenv may re-read .env on fresh import) + process.env.PUBLIC_HOST = ''; + delete process.env.CADDY_ENABLED; + const config = getConfig(); + expect(config.publicHost).toBeNull(); + }); + + it('should normalize whitespace-only PUBLIC_HOST to null', async () => { + const { getConfig } = await import('./config.js'); + process.env.PUBLIC_HOST = ' '; + delete process.env.CADDY_ENABLED; + const config = getConfig(); + expect(config.publicHost).toBeNull(); + }); + + it('should preserve valid PUBLIC_HOST', async () => { + const { getConfig } = await import('./config.js'); + process.env.PUBLIC_HOST = 'us1.example.com'; + delete process.env.CADDY_ENABLED; + const config = getConfig(); + expect(config.publicHost).toBe('us1.example.com'); + }); + + it('should trim whitespace from valid PUBLIC_HOST', async () => { + const { getConfig } = await import('./config.js'); + process.env.PUBLIC_HOST = ' us1.example.com '; + delete process.env.CADDY_ENABLED; + const config = getConfig(); + expect(config.publicHost).toBe('us1.example.com'); + }); + }); + + describe('CADDY_ENABLED validation', () => { + it('should throw when CADDY_ENABLED=true without PUBLIC_HOST', async () => { + const { getConfig } = await import('./config.js'); + process.env.CADDY_ENABLED = 'true'; + delete process.env.PUBLIC_HOST; + expect(() => getConfig()).toThrow('PUBLIC_HOST is required when CADDY_ENABLED=true'); + }); + + it('should not throw when CADDY_ENABLED=true with PUBLIC_HOST', async () => { + const { getConfig } = await import('./config.js'); + process.env.CADDY_ENABLED = 'true'; + process.env.PUBLIC_HOST = 'us1.example.com'; + const config = getConfig(); + expect(config.caddyEnabled).toBe(true); + expect(config.publicHost).toBe('us1.example.com'); + }); + + it('should default caddyEnabled to false', async () => { + const { getConfig } = await import('./config.js'); + delete process.env.CADDY_ENABLED; + const config = getConfig(); + expect(config.caddyEnabled).toBe(false); + }); + }); }); diff --git a/src/config.ts b/src/config.ts index 97d937b..af89e94 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 { @@ -82,7 +86,7 @@ export function getConfig(): AppConfig { throw new Error('ADMIN_PASSWORD must be at least 12 characters'); } - return { + const config: AppConfig = { port: getEnvIntOrDefault('PORT', 7100), host: getEnvOrDefault('HOST', '0.0.0.0'), dataDir: getEnvOrDefault('DATA_DIR', './data'), @@ -96,7 +100,15 @@ export function getConfig(): AppConfig { proxyAdminToken, adminPassword, sessionExpiryMs: getEnvIntOrDefault('SESSION_EXPIRY_MS', 24 * 60 * 60 * 1000), + publicHost: process.env.PUBLIC_HOST?.trim() || null, + caddyEnabled: process.env.CADDY_ENABLED === 'true', }; + + if (config.caddyEnabled && !config.publicHost) { + throw new Error('PUBLIC_HOST is required when CADDY_ENABLED=true (bots would have no port binding and no Caddy route)'); + } + + return config; } export default getConfig; diff --git a/src/secrets/manager.ts b/src/secrets/manager.ts index 4f9caa1..42da88a 100644 --- a/src/secrets/manager.ts +++ b/src/secrets/manager.ts @@ -2,15 +2,45 @@ * Secrets Manager * * Per-bot credential isolation with Unix file permissions. - * Each bot gets its own directory (0700) and secret files (0600). + * Each bot gets its own directory and secret files, chowned to the bot + * container UID (1000) with tight permissions (0700/0600) when running as + * root, falling back to world-readable (0755/0644) otherwise. */ -import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { mkdirSync, writeFileSync, readFileSync, rmSync, chownSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; const HOSTNAME_REGEX = /^[a-z0-9-]{1,64}$/; const SECRET_NAME_REGEX = /^[A-Z0-9_]{1,64}$/; +/** UID/GID of the 'node' user inside OpenClaw bot containers */ +const BOT_UID = 1000; +const BOT_GID = 1000; + +/** + * Try to chown a path to the bot container user. + * Returns true on success, false if not permitted (non-root). + */ +function tryChown(path: string): boolean { + try { + chownSync(path, BOT_UID, BOT_GID); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EPERM') return false; + throw err; + } +} + +/** + * Set ownership and permissions on a secrets path. + * If chown succeeds: use tight permissions (only bot user can access). + * If chown fails (not root): fall back to world-readable so bot container can still read. + */ +function setSecretOwnership(path: string, tightMode: number, fallbackMode: number): void { + const owned = tryChown(path); + chmodSync(path, owned ? tightMode : fallbackMode); +} + /** * Returns the root directory for secrets storage. * Uses SECRETS_DIR environment variable if set, otherwise './secrets'. @@ -34,7 +64,8 @@ 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). + * Chowns to bot UID (1000) with mode 0700 when possible; falls back to + * 0755 if not running as root so the bot container can still read. * * @param hostname - Hostname of the bot * @returns Path to the created directory @@ -46,15 +77,16 @@ export function createBotSecretsDir(hostname: string): string { const secretsRoot = getSecretsRoot(); const botDir = join(secretsRoot, hostname); - mkdirSync(botDir, { mode: 0o700, recursive: true }); + mkdirSync(botDir, { recursive: true }); + setSecretOwnership(botDir, 0o700, 0o755); 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. + * Chowns to bot UID (1000) with mode 0600 when possible; falls back to + * 0644 if not running as root so the bot container can still read. * * @param hostname - Hostname of the bot * @param name - Name of the secret (becomes filename) @@ -71,7 +103,8 @@ 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); + setSecretOwnership(filePath, 0o600, 0o644); } /** diff --git a/src/server.ts b/src/server.ts index ab57b53..d302f99 100644 --- a/src/server.ts +++ b/src/server.ts @@ -19,10 +19,11 @@ import { getNextBotPort, } from './bots/store.js'; import { getDb } from './db/index.js'; -import { createBotWorkspace, deleteBotWorkspace } from './bots/templates.js'; +import { createBotWorkspace, deleteBotWorkspace, BOT_INTERNAL_PORT } from './bots/templates.js'; import { writeSecret, deleteBotSecrets } from './secrets/manager.js'; import { DockerService } from './services/DockerService.js'; import { ReconciliationService } from './services/ReconciliationService.js'; +import { CaddyService } from './services/CaddyService.js'; import { ContainerError } from './services/docker-errors.js'; import { getProxyConfig, @@ -36,6 +37,12 @@ import { } from './proxy/client.js'; const docker = new DockerService(); +let caddy: CaddyService | null = null; + +function getCaddy(publicHost: string): CaddyService { + caddy ??= new CaddyService(publicHost); + return caddy; +} function safeCompare(a: string, b: string): boolean { const aBuf = Buffer.from(a); @@ -86,13 +93,15 @@ function invalidateSession(token: string): void { export { sessions, createSession, validateSession, invalidateSession }; type SessionScope = 'user' | 'channel' | 'global'; +type ToolsProfile = 'minimal' | 'messaging' | 'coding' | 'full'; +type StreamingMode = 'off' | 'partial'; interface CreateBotBody { name: string; hostname: string; emoji: string; avatarUrl?: string; - providers?: { providerId: string; model: string }[]; + providers?: { providerId: string; model: string; apiKey?: string }[]; primaryProvider?: string; channels?: { channelType: string; token: string }[]; persona: { @@ -106,6 +115,9 @@ interface CreateBotBody { sandbox: boolean; sandboxTimeout?: number; sessionScope: SessionScope; + toolsProfile: ToolsProfile; + telegramStreaming: StreamingMode; + discordStreaming: StreamingMode; }; tags?: string[]; } @@ -349,6 +361,17 @@ export async function buildServer(): Promise { const report = await reconciliation.reconcileOnStartup(); server.log.info({ report }, 'Startup reconciliation complete'); + // Restore Caddy routes for running bots (dynamic routes are lost on Caddy restart) + if (config.caddyEnabled && config.publicHost) { + const runningBots = listBots() + .filter(b => b.status === 'running' && b.port != null) + .map(b => ({ hostname: b.hostname, port: b.port as number })); + const restored = await getCaddy(config.publicHost).restoreRoutes( + runningBots, BOT_INTERNAL_PORT, server.log, + ); + server.log.info({ restored, total: runningBots.length }, 'Caddy route restoration complete'); + } + // Health check (rate limiting disabled for monitoring/load balancers) server.get('/health', { config: { rateLimit: false } }, () => { return { status: 'ok', timestamp: new Date().toISOString() }; @@ -506,6 +529,17 @@ export async function buildServer(): Promise { if (proxyConfig) { const registration = await registerBotWithProxy(proxyConfig, bot.id, bot.hostname, body.tags); proxyToken = registration.token; + + // Add API keys to proxy for providers that have them + for (const provider of body.providers) { + if (provider.apiKey) { + await addProxyKey(proxyConfig, { + vendor: provider.providerId, + secret: provider.apiKey, + label: `${body.hostname} - wizard`, + }); + } + } } for (const channel of body.channels) { @@ -530,17 +564,17 @@ export async function buildServer(): Promise { }; } - // Create workspace + // Create workspace with features and multiple channels createBotWorkspace(config.dataDir, { botId: bot.id, botHostname: bot.hostname, botName: body.name, aiProvider: primaryProvider.providerId, model: primaryProvider.model, - channel: { - type: primaryChannel.channelType as 'telegram' | 'discord', - token: primaryChannel.token, - }, + channels: body.channels.map(c => ({ + type: c.channelType, + token: c.token, + })), persona: { name: body.persona.name, identity: body.persona.soulMarkdown || '', @@ -548,6 +582,13 @@ export async function buildServer(): Promise { }, port, proxy: workspaceProxyConfig, + features: { + toolsProfile: body.features.toolsProfile, + telegramStreaming: body.features.telegramStreaming, + discordStreaming: body.features.discordStreaming, + }, + publicHost: config.publicHost ?? undefined, + gatewayToken, // For Control UI auth }); // Build environment @@ -562,15 +603,25 @@ export async function buildServer(): Promise { `PORT=${port}`, ]; + // Discord doesn't support tokenFile, must use env var + // (Telegram uses tokenFile configured in openclaw.json) + const discordChannel = body.channels.find(c => c.channelType === 'discord'); + if (discordChannel) { + environment.push(`DISCORD_BOT_TOKEN=${discordChannel.token}`); + } + const containerId = await docker.createContainer(bot.hostname, bot.id, { image: config.openclawImage, environment, port, + internalPort: BOT_INTERNAL_PORT, hostWorkspacePath, hostSecretsPath, hostSandboxPath, gatewayToken, networkName: proxyConfig ? 'bm-internal' : undefined, + caddyEnabled: config.caddyEnabled, + publicHost: config.publicHost ?? undefined, }); const db = getDb(); @@ -578,6 +629,17 @@ export async function buildServer(): Promise { updateBot(bot.id, { container_id: containerId, image_version: config.openclawImage }); })(); await docker.startContainer(bot.hostname); + + // Add Caddy HTTPS route if enabled + if (config.caddyEnabled && config.publicHost) { + try { + await getCaddy(config.publicHost).addBotRoute(bot.hostname, port, BOT_INTERNAL_PORT); + } catch (err) { + // Log but don't fail - bot still works via internal network + console.error(`Warning: Failed to add Caddy route for ${bot.hostname}:`, err); + } + } + db.transaction(() => { updateBot(bot.id, { status: 'running' }); })(); @@ -631,6 +693,15 @@ export async function buildServer(): Promise { } } + // Remove Caddy route if enabled + if (config.caddyEnabled && config.publicHost) { + try { + await getCaddy(config.publicHost).removeBotRoute(bot.hostname); + } catch { + // Ignore Caddy errors during deletion + } + } + // Delete workspace directory deleteBotWorkspace(config.dataDir, bot.hostname); @@ -692,6 +763,59 @@ export async function buildServer(): Promise { } }); + // Approve a Telegram pairing code + server.post<{ Params: { hostname: string }; Body: { code?: string } }>( + '/api/bots/:hostname/pair', + async (request, reply) => { + const bot = getBotByHostname(request.params.hostname); + + if (!bot) { + reply.code(404); + return { error: 'Bot not found' }; + } + + if (bot.channel_type !== 'telegram') { + reply.code(422); + return { error: 'Pairing is only supported for Telegram bots' }; + } + + const { code } = request.body; + if (!code || !/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$/.test(code)) { + reply.code(422); + return { error: 'Invalid pairing code. Must be 8 characters (A-Z without I/O, 2-9).' }; + } + + const status = await docker.getContainerStatus(bot.hostname); + if (!status?.running) { + reply.code(409); + return { error: 'Bot container is not running' }; + } + + try { + const result = await docker.execCommand(bot.hostname, [ + 'node', 'openclaw.mjs', 'pairing', 'approve', 'telegram', code, '--notify', + ]); + + if (result.exitCode !== 0) { + const msg = result.stderr.trim() || result.stdout.trim() || 'Pairing approval failed'; + reply.code(422); + return { error: msg }; + } + + return { + success: true, + message: result.stdout.trim() || 'Pairing code approved', + }; + } catch (err) { + if (err instanceof ContainerError) { + reply.code(500); + return { error: `Container error: ${err.message}` }; + } + throw err; + } + } + ); + // Admin cleanup endpoint - removes orphaned containers, workspaces, and secrets server.post('/api/admin/cleanup', async () => { const cleanupReport = await reconciliation.cleanupOrphans(); diff --git a/src/services/CaddyService.test.ts b/src/services/CaddyService.test.ts new file mode 100644 index 0000000..d59615b --- /dev/null +++ b/src/services/CaddyService.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CaddyService } from './CaddyService.js'; + +// Mock dockerode +vi.mock('dockerode', () => { + return { + default: vi.fn().mockImplementation(() => ({ + getContainer: vi.fn(), + })), + }; +}); + +// Mock global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +describe('CaddyService', () => { + let caddy: CaddyService; + + beforeEach(() => { + vi.clearAllMocks(); + caddy = new CaddyService('us1.example.com'); + }); + + describe('isAvailable', () => { + it('should return true when Caddy admin API responds OK', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + expect(await caddy.isAvailable()).toBe(true); + }); + + it('should return false when Caddy admin API fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + expect(await caddy.isAvailable()).toBe(false); + }); + + it('should return false when Caddy returns non-OK status', async () => { + mockFetch.mockResolvedValueOnce({ ok: false }); + expect(await caddy.isAvailable()).toBe(false); + }); + }); + + describe('addBotRoute', () => { + it('should PUT correct config to Caddy admin API', async () => { + // Mock Docker container inspect for IP lookup + const dockerInstance = (await import('dockerode')).default; + const mockContainer = { + inspect: vi.fn().mockResolvedValue({ + NetworkSettings: { + Networks: { + 'bm-internal': { IPAddress: '172.18.0.5' }, + }, + }, + }), + }; + vi.mocked(dockerInstance).mockImplementation(() => ({ + getContainer: vi.fn().mockReturnValue(mockContainer), + }) as unknown as InstanceType); + + // Re-create caddy with fresh mock + caddy = new CaddyService('us1.example.com'); + + mockFetch.mockResolvedValueOnce({ ok: true }); + + await caddy.addBotRoute('bob', 19000, 8080); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://host.docker.internal:2019/config/apps/http/servers/bot-bob', + expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + }), + ); + + // Verify the body contains the right config + const callArgs = mockFetch.mock.calls[0] as [string, { body: string }]; + const body = JSON.parse(callArgs[1].body) as Record; + expect(body.listen).toEqual([':19000']); + expect(body.routes).toEqual([ + { + match: [{ host: ['us1.example.com'] }], + handle: [ + { + handler: 'reverse_proxy', + upstreams: [{ dial: '172.18.0.5:8080' }], + }, + ], + terminal: true, + }, + ]); + }); + + it('should throw when container is not on expected network', async () => { + const dockerInstance = (await import('dockerode')).default; + const mockContainer = { + inspect: vi.fn().mockResolvedValue({ + NetworkSettings: { + Networks: { + 'some-other-network': { IPAddress: '172.18.0.5' }, + }, + }, + }), + }; + vi.mocked(dockerInstance).mockImplementation(() => ({ + getContainer: vi.fn().mockReturnValue(mockContainer), + }) as unknown as InstanceType); + + caddy = new CaddyService('us1.example.com'); + + await expect(caddy.addBotRoute('bob', 19000, 8080)).rejects.toThrow( + 'not connected to network bm-internal', + ); + }); + + it('should throw when Caddy returns error', async () => { + const dockerInstance = (await import('dockerode')).default; + const mockContainer = { + inspect: vi.fn().mockResolvedValue({ + NetworkSettings: { + Networks: { + 'bm-internal': { IPAddress: '172.18.0.5' }, + }, + }, + }), + }; + vi.mocked(dockerInstance).mockImplementation(() => ({ + getContainer: vi.fn().mockReturnValue(mockContainer), + }) as unknown as InstanceType); + + caddy = new CaddyService('us1.example.com'); + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve('internal error'), + }); + + await expect(caddy.addBotRoute('bob', 19000, 8080)).rejects.toThrow( + 'Failed to add Caddy route for bob', + ); + }); + }); + + describe('restoreRoutes', () => { + const mockLogger = { info: vi.fn(), warn: vi.fn() }; + + it('should restore routes for all running bots', async () => { + const dockerInstance = (await import('dockerode')).default; + const mockContainer = { + inspect: vi.fn().mockResolvedValue({ + NetworkSettings: { + Networks: { + 'bm-internal': { IPAddress: '172.18.0.5' }, + }, + }, + }), + }; + vi.mocked(dockerInstance).mockImplementation(() => ({ + getContainer: vi.fn().mockReturnValue(mockContainer), + }) as unknown as InstanceType); + + caddy = new CaddyService('us1.example.com'); + + // isAvailable check + two addBotRoute calls + mockFetch + .mockResolvedValueOnce({ ok: true }) // isAvailable + .mockResolvedValueOnce({ ok: true }) // addBotRoute for alice + .mockResolvedValueOnce({ ok: true }); // addBotRoute for bob + + const bots = [ + { hostname: 'alice', port: 19000 }, + { hostname: 'bob', port: 19001 }, + ]; + + const restored = await caddy.restoreRoutes(bots, 8080, mockLogger); + + expect(restored).toBe(2); + // isAvailable + 2 addBotRoute PUTs + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('should return 0 when no bots to restore', async () => { + const restored = await caddy.restoreRoutes([], 8080, mockLogger); + expect(restored).toBe(0); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should skip when Caddy is unavailable', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const bots = [{ hostname: 'alice', port: 19000 }]; + const restored = await caddy.restoreRoutes(bots, 8080, mockLogger); + + expect(restored).toBe(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Caddy admin API not available — skipping route restoration', + ); + }); + + it('should continue restoring when one bot fails', async () => { + const dockerInstance = (await import('dockerode')).default; + vi.mocked(dockerInstance).mockImplementation(() => ({ + getContainer: vi.fn().mockReturnValue({ + inspect: vi.fn() + .mockRejectedValueOnce(new Error('no such container')) // alice fails + .mockResolvedValueOnce({ // bob succeeds + NetworkSettings: { + Networks: { 'bm-internal': { IPAddress: '172.18.0.6' } }, + }, + }), + }), + }) as unknown as InstanceType); + + caddy = new CaddyService('us1.example.com'); + + mockFetch + .mockResolvedValueOnce({ ok: true }) // isAvailable + .mockResolvedValueOnce({ ok: true }); // addBotRoute for bob + + const bots = [ + { hostname: 'alice', port: 19000 }, + { hostname: 'bob', port: 19001 }, + ]; + const restored = await caddy.restoreRoutes(bots, 8080, mockLogger); + + expect(restored).toBe(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ hostname: 'alice' }), + 'Failed to restore Caddy route', + ); + }); + }); + + describe('removeBotRoute', () => { + it('should send DELETE to Caddy admin API', async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + await caddy.removeBotRoute('bob'); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://host.docker.internal:2019/config/apps/http/servers/bot-bob', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + + it('should not throw when DELETE fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + // Should not throw + await caddy.removeBotRoute('bob'); + }); + }); +}); diff --git a/src/services/CaddyService.ts b/src/services/CaddyService.ts new file mode 100644 index 0000000..5292d06 --- /dev/null +++ b/src/services/CaddyService.ts @@ -0,0 +1,196 @@ +/** + * Caddy Service + * + * Manages dynamic HTTPS routing for bot gateways via Caddy admin API. + * + * Key insight: Caddy auto-enables TLS for servers with a `host` matcher. + * Bot servers must include a host matcher pointing to PUBLIC_HOST so that + * Caddy applies the same certificate used by the main dashboard. + * + * Architecture: + * - Caddy runs in host network mode (avoids 100+ docker-proxy processes) + * - All bot containers listen on the same internal port (e.g., 8080) + * - Each bot has a unique external port (e.g., 19000, 19001) + * - Caddy listens on external port, proxies to container IP + internal port + * - Admin API accessed via host.docker.internal:2019 from botmaker container + */ + +import Docker from 'dockerode'; + +// Caddy admin API base URL (reachable from inside Docker via extra_hosts) +const CADDY_ADMIN_URL = 'http://host.docker.internal:2019'; + +/** + * Service for managing Caddy reverse proxy configuration dynamically. + * Adds/removes HTTPS listeners for bot containers via Caddy's admin API. + */ +export class CaddyService { + private publicHost: string; + private networkName: string; + private docker: Docker; + + constructor(publicHost: string, networkName = 'bm-internal') { + this.publicHost = publicHost; + this.networkName = networkName; + this.docker = new Docker({ socketPath: '/var/run/docker.sock' }); + } + + /** + * Get the IP address of a container on the configured network. + * Required because Caddy runs in host network mode and can't use Docker DNS. + * + * @param containerName - Full container name (e.g., "botmaker-bob") + * @returns Container IP address + * @throws Error if container or network not found + */ + private async getContainerIp(containerName: string): Promise { + try { + const container = this.docker.getContainer(containerName); + const info = await container.inspect(); + const networks = info.NetworkSettings.Networks; + const networkInfo = networks[this.networkName]; + + if (!networkInfo) { + throw new Error(`Container ${containerName} is not connected to network ${this.networkName}`); + } + if (!networkInfo.IPAddress) { + throw new Error(`Container ${containerName} has no IP on network ${this.networkName}`); + } + return networkInfo.IPAddress; + } catch (err) { + const error = err as { message?: string }; + throw new Error(`Failed to get IP for ${containerName}: ${error.message ?? 'Unknown error'}`); + } + } + + /** + * Check if Caddy admin API is available. + * @returns true if Caddy is reachable + */ + async isAvailable(): Promise { + try { + const response = await fetch(`${CADDY_ADMIN_URL}/config/`, { + signal: AbortSignal.timeout(3000), + }); + return response.ok; + } catch { + return false; + } + } + + /** + * Add an HTTPS listener for a bot gateway. + * Creates a new server entry in Caddy with: + * - Port-based listener on externalPort + * - Host matcher (enables auto-TLS with same cert as dashboard) + * - Reverse proxy to container IP + internal port + * + * @param hostname - Bot hostname (e.g., "bob") + * @param externalPort - External port number (e.g., 19000) + * @param internalPort - Internal port that OpenClaw listens on (e.g., 8080) + */ + async addBotRoute(hostname: string, externalPort: number, internalPort: number): Promise { + const serverName = `bot-${hostname}`; + const containerName = `botmaker-${hostname}`; + + // Get container IP (Caddy is on host network, can't use Docker DNS) + const containerIp = await this.getContainerIp(containerName); + + // Config with host matcher to enable Caddy's auto-TLS + // Caddy listens on external port, proxies to container IP + internal port + const config = { + listen: [`:${externalPort}`], + routes: [ + { + match: [{ host: [this.publicHost] }], + handle: [ + { + handler: 'reverse_proxy', + upstreams: [ + { dial: `${containerIp}:${internalPort}` } + ] + } + ], + terminal: true + } + ] + }; + + try { + const response = await fetch( + `${CADDY_ADMIN_URL}/config/apps/http/servers/${serverName}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + signal: AbortSignal.timeout(10000), + } + ); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`HTTP ${response.status}: ${body}`); + } + } catch (err) { + const error = err as { message?: string }; + throw new Error(`Failed to add Caddy route for ${hostname}: ${error.message ?? 'Unknown error'}`); + } + } + + /** + * Restore Caddy routes for all running bots. + * Called on startup to re-register dynamic routes lost during Caddy restart. + * + * @param bots - List of running bots with hostname and port + * @param internalPort - Internal port that OpenClaw listens on (e.g., 8080) + * @param logger - Logger for status messages + * @returns Number of routes successfully restored + */ + async restoreRoutes( + bots: Array<{ hostname: string; port: number }>, + internalPort: number, + logger: { info: (msg: string | object, ...args: unknown[]) => void; warn: (msg: string | object, ...args: unknown[]) => void }, + ): Promise { + if (bots.length === 0) return 0; + + if (!await this.isAvailable()) { + logger.warn('Caddy admin API not available — skipping route restoration'); + return 0; + } + + let restored = 0; + for (const bot of bots) { + try { + await this.addBotRoute(bot.hostname, bot.port, internalPort); + restored++; + } catch (err) { + const error = err as { message?: string }; + logger.warn({ hostname: bot.hostname, error: error.message }, 'Failed to restore Caddy route'); + } + } + return restored; + } + + /** + * Remove the HTTPS listener for a bot gateway. + * Deletes the server entry from Caddy config. + * + * @param hostname - Bot hostname (e.g., "bob") + */ + async removeBotRoute(hostname: string): Promise { + const serverName = `bot-${hostname}`; + + try { + await fetch( + `${CADDY_ADMIN_URL}/config/apps/http/servers/${serverName}`, + { + method: 'DELETE', + signal: AbortSignal.timeout(10000), + } + ); + // Ignore response - server may not exist + } catch { + // Ignore errors - server may not exist + } + } +} diff --git a/src/services/DockerService.test.ts b/src/services/DockerService.test.ts new file mode 100644 index 0000000..8087548 --- /dev/null +++ b/src/services/DockerService.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; + +// Mock dockerode before importing DockerService +const mockExec = vi.fn(); +const mockExecStart = vi.fn(); +const mockExecInspect = vi.fn(); +const mockGetContainer = vi.fn(); +const mockDemuxStream = vi.fn(); + +vi.mock('dockerode', () => { + return { + default: vi.fn().mockImplementation(() => ({ + getContainer: mockGetContainer, + modem: { + demuxStream: mockDemuxStream, + }, + })), + }; +}); + +import { DockerService } from './DockerService.js'; +import { ContainerError } from './docker-errors.js'; + +describe('DockerService.execCommand', () => { + let docker: DockerService; + + beforeEach(() => { + vi.clearAllMocks(); + docker = new DockerService(); + + // Default mock chain: getContainer -> exec -> start + mockGetContainer.mockReturnValue({ + exec: mockExec, + }); + mockExec.mockResolvedValue({ + start: mockExecStart, + inspect: mockExecInspect, + }); + }); + + it('should execute command and return stdout', async () => { + const stream = new EventEmitter(); + + mockExecStart.mockResolvedValue(stream); + mockExecInspect.mockResolvedValue({ ExitCode: 0 }); + + // When demuxStream is called, write to stdout and end the stream + mockDemuxStream.mockImplementation((_stream: EventEmitter, stdout: PassThrough) => { + stdout.write(Buffer.from('hello world\n')); + process.nextTick(() => { stream.emit('end'); }); + }); + + const result = await docker.execCommand('bob', ['echo', 'hello']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello world\n'); + expect(result.stderr).toBe(''); + + expect(mockGetContainer).toHaveBeenCalledWith('botmaker-bob'); + expect(mockExec).toHaveBeenCalledWith({ + Cmd: ['echo', 'hello'], + AttachStdout: true, + AttachStderr: true, + }); + }); + + it('should capture stderr separately', async () => { + const stream = new EventEmitter(); + + mockExecStart.mockResolvedValue(stream); + mockExecInspect.mockResolvedValue({ ExitCode: 1 }); + + mockDemuxStream.mockImplementation((_stream: EventEmitter, _stdout: PassThrough, stderr: PassThrough) => { + stderr.write(Buffer.from('error occurred\n')); + process.nextTick(() => { stream.emit('end'); }); + }); + + const result = await docker.execCommand('bob', ['bad-command']); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe('error occurred\n'); + }); + + it('should timeout and reject', async () => { + const stream = new EventEmitter() as EventEmitter & { destroy: () => void }; + stream.destroy = vi.fn(); + + mockExecStart.mockResolvedValue(stream); + + mockDemuxStream.mockImplementation(() => { + // Never emit 'end' — simulates a hanging command + }); + + await expect(docker.execCommand('bob', ['sleep', '999'], 50)).rejects.toThrow( + 'Exec timed out after 50ms', + ); + + expect(stream.destroy).toHaveBeenCalled(); + }); + + it('should throw plain Error for exec timeout, not ContainerError', async () => { + const stream = new EventEmitter() as EventEmitter & { destroy: () => void }; + stream.destroy = vi.fn(); + + mockExecStart.mockResolvedValue(stream); + + mockDemuxStream.mockImplementation(() => { + // Never emit 'end' — simulates a hanging command + }); + + try { + await docker.execCommand('bob', ['sleep', '999'], 50); + expect.fail('should have thrown'); + } catch (err) { + // Must be a plain Error, NOT a ContainerError with NETWORK_ERROR code + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(ContainerError); + expect((err as Error).message).toContain('Exec timed out'); + } + }); + + it('should handle stream errors', async () => { + const stream = new EventEmitter(); + + mockExecStart.mockResolvedValue(stream); + + mockDemuxStream.mockImplementation(() => { + process.nextTick(() => { stream.emit('error', new Error('stream broke')); }); + }); + + await expect(docker.execCommand('bob', ['cmd'])).rejects.toThrow('stream broke'); + }); + + it('should return -1 exit code when ExitCode is null', async () => { + const stream = new EventEmitter(); + + mockExecStart.mockResolvedValue(stream); + mockExecInspect.mockResolvedValue({ ExitCode: null }); + + mockDemuxStream.mockImplementation((_stream: EventEmitter, stdout: PassThrough) => { + stdout.write(Buffer.from('output')); + process.nextTick(() => { stream.emit('end'); }); + }); + + const result = await docker.execCommand('bob', ['cmd']); + expect(result.exitCode).toBe(-1); + }); +}); diff --git a/src/services/DockerService.ts b/src/services/DockerService.ts index 9b90a76..2973663 100644 --- a/src/services/DockerService.ts +++ b/src/services/DockerService.ts @@ -6,9 +6,16 @@ */ import Docker from 'dockerode'; +import { PassThrough } from 'node:stream'; import type { ContainerStatus, ContainerInfo, ContainerConfig, ContainerStats } from '../types/container.js'; import { wrapDockerError } from './docker-errors.js'; +export interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + /** Label used to identify BotMaker-managed containers */ const LABEL_MANAGED = 'botmaker.managed'; const LABEL_BOT_ID = 'botmaker.bot-id'; @@ -26,6 +33,29 @@ export class DockerService { this.docker = new Docker(); } + /** + * Determine port bindings based on Caddy availability and public host setting. + * + * Architecture: + * - All bots listen on the same internal port (e.g., 8080) inside their container + * - Each bot has a unique external port (19000+) for access + * - When Caddy enabled: No port binding (Caddy proxies via container IP) + * - When Caddy disabled: Map external port to internal port + */ + private getPortBindings(config: ContainerConfig): Record { + if (config.caddyEnabled) { + // Caddy runs in host network mode, proxies to container IP:internalPort + // No host port binding needed + return {}; + } + + // Dev mode: bind external port to internal port + const bindIp = config.publicHost ? '0.0.0.0' : '127.0.0.1'; + return { + [`${config.internalPort}/tcp`]: [{ HostIp: bindIp, HostPort: String(config.port) }] + }; + } + /** * Creates a new container for a bot. * @@ -50,7 +80,7 @@ export class DockerService { Cmd: ['node', 'openclaw.mjs', 'gateway'], Env: env, ExposedPorts: { - [`${config.port}/tcp`]: {} + [`${config.internalPort}/tcp`]: {} }, Labels: { [LABEL_MANAGED]: 'true', @@ -58,7 +88,7 @@ export class DockerService { [LABEL_BOT_HOSTNAME]: hostname }, Healthcheck: { - Test: ['CMD', 'curl', '-sf', `http://localhost:${config.port}/`], + Test: ['CMD', 'curl', '-sf', `http://localhost:${config.internalPort}/`], Interval: 2_000_000_000, // 2s in nanoseconds Timeout: 3_000_000_000, // 3s in nanoseconds Retries: 30, @@ -70,9 +100,7 @@ export class DockerService { `${config.hostWorkspacePath}:/app/botdata:rw`, `${config.hostSandboxPath}:/app/workspace:rw` ], - PortBindings: { - [`${config.port}/tcp`]: [{ HostPort: String(config.port) }] - }, + PortBindings: this.getPortBindings(config), RestartPolicy: { Name: 'unless-stopped' }, @@ -332,6 +360,74 @@ export class DockerService { return stats.filter((s): s is ContainerStats => s !== null); } + + /** + * Executes a command inside a running container. + * + * @param hostname - Hostname of the bot + * @param cmd - Command and arguments to execute + * @param timeoutMs - Timeout in milliseconds (default: 30000) + * @returns Exit code, stdout, and stderr + */ + async execCommand(hostname: string, cmd: string[], timeoutMs = 30_000): Promise { + const containerName = `botmaker-${hostname}`; + + try { + const container = this.docker.getContainer(containerName); + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }); + + const stream = await exec.start({ hijack: true, stdin: false }); + + return await new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + const timeout = setTimeout(() => { + stream.destroy(); + reject(new Error(`Exec timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + + stdoutStream.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk); }); + stderrStream.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); }); + + this.docker.modem.demuxStream(stream, stdoutStream, stderrStream); + + stream.on('end', () => { + clearTimeout(timeout); + exec.inspect() + .then((inspectResult) => { + resolve({ + exitCode: inspectResult.ExitCode ?? -1, + stdout: Buffer.concat(stdoutChunks).toString('utf-8'), + stderr: Buffer.concat(stderrChunks).toString('utf-8'), + }); + }) + .catch((err: unknown) => { + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + stream.on('error', (err: Error) => { + clearTimeout(timeout); + reject(err); + }); + }); + } catch (err) { + // Don't wrap exec timeouts — wrapDockerError would misclassify + // "Exec timed out" as NETWORK_ERROR ("Docker daemon connection timeout") + if (err instanceof Error && err.message.startsWith('Exec timed out')) { + throw err; + } + throw wrapDockerError(err, hostname); + } + } } export default DockerService; diff --git a/src/types/container.ts b/src/types/container.ts index e0b5c0b..001c2e5 100644 --- a/src/types/container.ts +++ b/src/types/container.ts @@ -42,7 +42,10 @@ export interface ContainerInfo { export interface ContainerConfig { image: string; environment: string[]; + /** External port for this bot (unique per bot, e.g., 19000) */ port: number; + /** Internal port that OpenClaw gateway listens on (same for all bots, e.g., 8080) */ + internalPort: number; /** Host path for workspace bind mount (Docker daemon perspective) */ hostWorkspacePath: string; /** Host path for secrets bind mount (Docker daemon perspective) */ @@ -54,6 +57,10 @@ export interface ContainerConfig { networkName?: string; /** Extra /etc/hosts entries (e.g., ["host.docker.internal:host-gateway"]) */ extraHosts?: string[]; + /** Whether Caddy HTTPS proxy is enabled (affects port binding) */ + caddyEnabled: boolean; + /** Public hostname for external access (used for binding decision) */ + publicHost?: string; } /** Container resource statistics */