repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | /** |
| #2 | * Moltbot + Cloudflare Sandbox |
| #3 | * |
| #4 | * This Worker runs Moltbot personal AI assistant in a Cloudflare Sandbox container. |
| #5 | * It proxies all requests to the Moltbot Gateway's web UI and WebSocket endpoint. |
| #6 | * |
| #7 | * Features: |
| #8 | * - Web UI (Control Dashboard + WebChat) at / |
| #9 | * - WebSocket support for real-time communication |
| #10 | * - Admin UI at /_admin/ for device management |
| #11 | * - Configuration via environment secrets |
| #12 | * |
| #13 | * Required secrets (set via `wrangler secret put`): |
| #14 | * - ANTHROPIC_API_KEY: Your Anthropic API key |
| #15 | * |
| #16 | * Optional secrets: |
| #17 | * - MOLTBOT_GATEWAY_TOKEN: Token to protect gateway access |
| #18 | * - TELEGRAM_BOT_TOKEN: Telegram bot token |
| #19 | * - DISCORD_BOT_TOKEN: Discord bot token |
| #20 | * - SLACK_BOT_TOKEN + SLACK_APP_TOKEN: Slack tokens |
| #21 | */ |
| #22 | |
| #23 | import { Hono } from 'hono'; |
| #24 | import { getSandbox, Sandbox, type SandboxOptions } from '@cloudflare/sandbox'; |
| #25 | |
| #26 | import type { AppEnv, MoltbotEnv } from './types'; |
| #27 | import { MOLTBOT_PORT } from './config'; |
| #28 | import { createAccessMiddleware } from './auth'; |
| #29 | import { ensureMoltbotGateway, findExistingMoltbotProcess, syncToR2 } from './gateway'; |
| #30 | import { publicRoutes, api, adminUi, debug, cdp } from './routes'; |
| #31 | import loadingPageHtml from './assets/loading.html'; |
| #32 | import configErrorHtml from './assets/config-error.html'; |
| #33 | |
| #34 | /** |
| #35 | * Transform error messages from the gateway to be more user-friendly. |
| #36 | */ |
| #37 | function transformErrorMessage(message: string, host: string): string { |
| #38 | if (message.includes('gateway token missing') || message.includes('gateway token mismatch')) { |
| #39 | return `Invalid or missing token. Visit https://${host}?token={REPLACE_WITH_YOUR_TOKEN}`; |
| #40 | } |
| #41 | |
| #42 | if (message.includes('pairing required')) { |
| #43 | return `Pairing required. Visit https://${host}/_admin/`; |
| #44 | } |
| #45 | |
| #46 | return message; |
| #47 | } |
| #48 | |
| #49 | export { Sandbox }; |
| #50 | |
| #51 | /** |
| #52 | * Validate required environment variables. |
| #53 | * Returns an array of missing variable descriptions, or empty array if all are set. |
| #54 | */ |
| #55 | function validateRequiredEnv(env: MoltbotEnv): string[] { |
| #56 | const missing: string[] = []; |
| #57 | |
| #58 | if (!env.MOLTBOT_GATEWAY_TOKEN) { |
| #59 | missing.push('MOLTBOT_GATEWAY_TOKEN'); |
| #60 | } |
| #61 | |
| #62 | if (!env.CF_ACCESS_TEAM_DOMAIN) { |
| #63 | missing.push('CF_ACCESS_TEAM_DOMAIN'); |
| #64 | } |
| #65 | |
| #66 | if (!env.CF_ACCESS_AUD) { |
| #67 | missing.push('CF_ACCESS_AUD'); |
| #68 | } |
| #69 | |
| #70 | // Check for AI Gateway or direct Anthropic configuration |
| #71 | if (env.AI_GATEWAY_API_KEY) { |
| #72 | // AI Gateway requires both API key and base URL |
| #73 | if (!env.AI_GATEWAY_BASE_URL) { |
| #74 | missing.push('AI_GATEWAY_BASE_URL (required when using AI_GATEWAY_API_KEY)'); |
| #75 | } |
| #76 | } else if (!env.ANTHROPIC_API_KEY) { |
| #77 | // Direct Anthropic access requires API key |
| #78 | missing.push('ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY'); |
| #79 | } |
| #80 | |
| #81 | return missing; |
| #82 | } |
| #83 | |
| #84 | /** |
| #85 | * Build sandbox options based on environment configuration. |
| #86 | * |
| #87 | * SANDBOX_SLEEP_AFTER controls how long the container stays alive after inactivity: |
| #88 | * - 'never' (default): Container stays alive indefinitely (recommended due to long cold starts) |
| #89 | * - Duration string: e.g., '10m', '1h', '30s' - container sleeps after this period of inactivity |
| #90 | * |
| #91 | * To reduce costs at the expense of cold start latency, set SANDBOX_SLEEP_AFTER to a duration: |
| #92 | * npx wrangler secret put SANDBOX_SLEEP_AFTER |
| #93 | * # Enter: 10m (or 1h, 30m, etc.) |
| #94 | */ |
| #95 | function buildSandboxOptions(env: MoltbotEnv): SandboxOptions { |
| #96 | const sleepAfter = env.SANDBOX_SLEEP_AFTER?.toLowerCase() || 'never'; |
| #97 | |
| #98 | // 'never' means keep the container alive indefinitely |
| #99 | if (sleepAfter === 'never') { |
| #100 | return { keepAlive: true }; |
| #101 | } |
| #102 | |
| #103 | // Otherwise, use the specified duration |
| #104 | return { sleepAfter }; |
| #105 | } |
| #106 | |
| #107 | // Main app |
| #108 | const app = new Hono<AppEnv>(); |
| #109 | |
| #110 | // ============================================================================= |
| #111 | // MIDDLEWARE: Applied to ALL routes |
| #112 | // ============================================================================= |
| #113 | |
| #114 | // Middleware: Log every request |
| #115 | app.use('*', async (c, next) => { |
| #116 | const url = new URL(c.req.url); |
| #117 | console.log(`[REQ] ${c.req.method} ${url.pathname}${url.search}`); |
| #118 | console.log(`[REQ] Has ANTHROPIC_API_KEY: ${!!c.env.ANTHROPIC_API_KEY}`); |
| #119 | console.log(`[REQ] DEV_MODE: ${c.env.DEV_MODE}`); |
| #120 | console.log(`[REQ] DEBUG_ROUTES: ${c.env.DEBUG_ROUTES}`); |
| #121 | await next(); |
| #122 | }); |
| #123 | |
| #124 | // Middleware: Initialize sandbox for all requests |
| #125 | app.use('*', async (c, next) => { |
| #126 | const options = buildSandboxOptions(c.env); |
| #127 | const sandbox = getSandbox(c.env.Sandbox, 'moltbot', options); |
| #128 | c.set('sandbox', sandbox); |
| #129 | await next(); |
| #130 | }); |
| #131 | |
| #132 | // ============================================================================= |
| #133 | // PUBLIC ROUTES: No Cloudflare Access authentication required |
| #134 | // ============================================================================= |
| #135 | |
| #136 | // Mount public routes first (before auth middleware) |
| #137 | // Includes: /sandbox-health, /logo.png, /logo-small.png, /api/status, /_admin/assets/* |
| #138 | app.route('/', publicRoutes); |
| #139 | |
| #140 | // Mount CDP routes (uses shared secret auth via query param, not CF Access) |
| #141 | app.route('/cdp', cdp); |
| #142 | |
| #143 | // ============================================================================= |
| #144 | // PROTECTED ROUTES: Cloudflare Access authentication required |
| #145 | // ============================================================================= |
| #146 | |
| #147 | // Middleware: Validate required environment variables (skip in dev mode and for debug routes) |
| #148 | app.use('*', async (c, next) => { |
| #149 | const url = new URL(c.req.url); |
| #150 | |
| #151 | // Skip validation for debug routes (they have their own enable check) |
| #152 | if (url.pathname.startsWith('/debug')) { |
| #153 | return next(); |
| #154 | } |
| #155 | |
| #156 | // Skip validation in dev mode |
| #157 | if (c.env.DEV_MODE === 'true') { |
| #158 | return next(); |
| #159 | } |
| #160 | |
| #161 | const missingVars = validateRequiredEnv(c.env); |
| #162 | if (missingVars.length > 0) { |
| #163 | console.error('[CONFIG] Missing required environment variables:', missingVars.join(', ')); |
| #164 | |
| #165 | const acceptsHtml = c.req.header('Accept')?.includes('text/html'); |
| #166 | if (acceptsHtml) { |
| #167 | // Return a user-friendly HTML error page |
| #168 | const html = configErrorHtml.replace('{{MISSING_VARS}}', missingVars.join(', ')); |
| #169 | return c.html(html, 503); |
| #170 | } |
| #171 | |
| #172 | // Return JSON error for API requests |
| #173 | return c.json({ |
| #174 | error: 'Configuration error', |
| #175 | message: 'Required environment variables are not configured', |
| #176 | missing: missingVars, |
| #177 | hint: 'Set these using: wrangler secret put <VARIABLE_NAME>', |
| #178 | }, 503); |
| #179 | } |
| #180 | |
| #181 | return next(); |
| #182 | }); |
| #183 | |
| #184 | // Middleware: Cloudflare Access authentication for protected routes |
| #185 | app.use('*', async (c, next) => { |
| #186 | // Determine response type based on Accept header |
| #187 | const acceptsHtml = c.req.header('Accept')?.includes('text/html'); |
| #188 | const middleware = createAccessMiddleware({ |
| #189 | type: acceptsHtml ? 'html' : 'json', |
| #190 | redirectOnMissing: acceptsHtml |
| #191 | }); |
| #192 | |
| #193 | return middleware(c, next); |
| #194 | }); |
| #195 | |
| #196 | // Mount API routes (protected by Cloudflare Access) |
| #197 | app.route('/api', api); |
| #198 | |
| #199 | // Mount Admin UI routes (protected by Cloudflare Access) |
| #200 | app.route('/_admin', adminUi); |
| #201 | |
| #202 | // Mount debug routes (protected by Cloudflare Access, only when DEBUG_ROUTES is enabled) |
| #203 | app.use('/debug/*', async (c, next) => { |
| #204 | if (c.env.DEBUG_ROUTES !== 'true') { |
| #205 | return c.json({ error: 'Debug routes are disabled' }, 404); |
| #206 | } |
| #207 | return next(); |
| #208 | }); |
| #209 | app.route('/debug', debug); |
| #210 | |
| #211 | // ============================================================================= |
| #212 | // CATCH-ALL: Proxy to Moltbot gateway |
| #213 | // ============================================================================= |
| #214 | |
| #215 | app.all('*', async (c) => { |
| #216 | const sandbox = c.get('sandbox'); |
| #217 | const request = c.req.raw; |
| #218 | const url = new URL(request.url); |
| #219 | |
| #220 | console.log('[PROXY] Handling request:', url.pathname); |
| #221 | |
| #222 | // Check if gateway is already running |
| #223 | const existingProcess = await findExistingMoltbotProcess(sandbox); |
| #224 | const isGatewayReady = existingProcess !== null && existingProcess.status === 'running'; |
| #225 | |
| #226 | // For browser requests (non-WebSocket, non-API), show loading page if gateway isn't ready |
| #227 | const isWebSocketRequest = request.headers.get('Upgrade')?.toLowerCase() === 'websocket'; |
| #228 | const acceptsHtml = request.headers.get('Accept')?.includes('text/html'); |
| #229 | |
| #230 | if (!isGatewayReady && !isWebSocketRequest && acceptsHtml) { |
| #231 | console.log('[PROXY] Gateway not ready, serving loading page'); |
| #232 | |
| #233 | // Start the gateway in the background (don't await) |
| #234 | c.executionCtx.waitUntil( |
| #235 | ensureMoltbotGateway(sandbox, c.env).catch((err: Error) => { |
| #236 | console.error('[PROXY] Background gateway start failed:', err); |
| #237 | }) |
| #238 | ); |
| #239 | |
| #240 | // Return the loading page immediately |
| #241 | return c.html(loadingPageHtml); |
| #242 | } |
| #243 | |
| #244 | // Ensure moltbot is running (this will wait for startup) |
| #245 | try { |
| #246 | await ensureMoltbotGateway(sandbox, c.env); |
| #247 | } catch (error) { |
| #248 | console.error('[PROXY] Failed to start Moltbot:', error); |
| #249 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; |
| #250 | |
| #251 | let hint = 'Check worker logs with: wrangler tail'; |
| #252 | if (!c.env.ANTHROPIC_API_KEY) { |
| #253 | hint = 'ANTHROPIC_API_KEY is not set. Run: wrangler secret put ANTHROPIC_API_KEY'; |
| #254 | } else if (errorMessage.includes('heap out of memory') || errorMessage.includes('OOM')) { |
| #255 | hint = 'Gateway ran out of memory. Try again or check for memory leaks.'; |
| #256 | } |
| #257 | |
| #258 | return c.json({ |
| #259 | error: 'Moltbot gateway failed to start', |
| #260 | details: errorMessage, |
| #261 | hint, |
| #262 | }, 503); |
| #263 | } |
| #264 | |
| #265 | // Proxy to Moltbot with WebSocket message interception |
| #266 | if (isWebSocketRequest) { |
| #267 | console.log('[WS] Proxying WebSocket connection to Moltbot'); |
| #268 | console.log('[WS] URL:', request.url); |
| #269 | console.log('[WS] Search params:', url.search); |
| #270 | |
| #271 | // Get WebSocket connection to the container |
| #272 | const containerResponse = await sandbox.wsConnect(request, MOLTBOT_PORT); |
| #273 | console.log('[WS] wsConnect response status:', containerResponse.status); |
| #274 | |
| #275 | // Get the container-side WebSocket |
| #276 | const containerWs = containerResponse.webSocket; |
| #277 | if (!containerWs) { |
| #278 | console.error('[WS] No WebSocket in container response - falling back to direct proxy'); |
| #279 | return containerResponse; |
| #280 | } |
| #281 | |
| #282 | console.log('[WS] Got container WebSocket, setting up interception'); |
| #283 | |
| #284 | // Create a WebSocket pair for the client |
| #285 | const [clientWs, serverWs] = Object.values(new WebSocketPair()); |
| #286 | |
| #287 | // Accept both WebSockets |
| #288 | serverWs.accept(); |
| #289 | containerWs.accept(); |
| #290 | |
| #291 | console.log('[WS] Both WebSockets accepted'); |
| #292 | console.log('[WS] containerWs.readyState:', containerWs.readyState); |
| #293 | console.log('[WS] serverWs.readyState:', serverWs.readyState); |
| #294 | |
| #295 | // Relay messages from client to container |
| #296 | serverWs.addEventListener('message', (event) => { |
| #297 | console.log('[WS] Client -> Container:', typeof event.data, typeof event.data === 'string' ? event.data.slice(0, 200) : '(binary)'); |
| #298 | if (containerWs.readyState === WebSocket.OPEN) { |
| #299 | containerWs.send(event.data); |
| #300 | } else { |
| #301 | console.log('[WS] Container not open, readyState:', containerWs.readyState); |
| #302 | } |
| #303 | }); |
| #304 | |
| #305 | // Relay messages from container to client, with error transformation |
| #306 | containerWs.addEventListener('message', (event) => { |
| #307 | console.log('[WS] Container -> Client (raw):', typeof event.data, typeof event.data === 'string' ? event.data.slice(0, 500) : '(binary)'); |
| #308 | let data = event.data; |
| #309 | |
| #310 | // Try to intercept and transform error messages |
| #311 | if (typeof data === 'string') { |
| #312 | try { |
| #313 | const parsed = JSON.parse(data); |
| #314 | console.log('[WS] Parsed JSON, has error.message:', !!parsed.error?.message); |
| #315 | if (parsed.error?.message) { |
| #316 | console.log('[WS] Original error.message:', parsed.error.message); |
| #317 | parsed.error.message = transformErrorMessage(parsed.error.message, url.host); |
| #318 | console.log('[WS] Transformed error.message:', parsed.error.message); |
| #319 | data = JSON.stringify(parsed); |
| #320 | } |
| #321 | } catch (e) { |
| #322 | console.log('[WS] Not JSON or parse error:', e); |
| #323 | } |
| #324 | } |
| #325 | |
| #326 | if (serverWs.readyState === WebSocket.OPEN) { |
| #327 | serverWs.send(data); |
| #328 | } else { |
| #329 | console.log('[WS] Server not open, readyState:', serverWs.readyState); |
| #330 | } |
| #331 | }); |
| #332 | |
| #333 | // Handle close events |
| #334 | serverWs.addEventListener('close', (event) => { |
| #335 | console.log('[WS] Client closed:', event.code, event.reason); |
| #336 | containerWs.close(event.code, event.reason); |
| #337 | }); |
| #338 | |
| #339 | containerWs.addEventListener('close', (event) => { |
| #340 | console.log('[WS] Container closed:', event.code, event.reason); |
| #341 | // Transform the close reason (truncate to 123 bytes max for WebSocket spec) |
| #342 | let reason = transformErrorMessage(event.reason, url.host); |
| #343 | if (reason.length > 123) { |
| #344 | reason = reason.slice(0, 120) + '...'; |
| #345 | } |
| #346 | console.log('[WS] Transformed close reason:', reason); |
| #347 | serverWs.close(event.code, reason); |
| #348 | }); |
| #349 | |
| #350 | // Handle errors |
| #351 | serverWs.addEventListener('error', (event) => { |
| #352 | console.error('[WS] Client error:', event); |
| #353 | containerWs.close(1011, 'Client error'); |
| #354 | }); |
| #355 | |
| #356 | containerWs.addEventListener('error', (event) => { |
| #357 | console.error('[WS] Container error:', event); |
| #358 | serverWs.close(1011, 'Container error'); |
| #359 | }); |
| #360 | |
| #361 | console.log('[WS] Returning intercepted WebSocket response'); |
| #362 | return new Response(null, { |
| #363 | status: 101, |
| #364 | webSocket: clientWs, |
| #365 | }); |
| #366 | } |
| #367 | |
| #368 | console.log('[HTTP] Proxying:', url.pathname + url.search); |
| #369 | const httpResponse = await sandbox.containerFetch(request, MOLTBOT_PORT); |
| #370 | console.log('[HTTP] Response status:', httpResponse.status); |
| #371 | |
| #372 | // Add debug header to verify worker handled the request |
| #373 | const newHeaders = new Headers(httpResponse.headers); |
| #374 | newHeaders.set('X-Worker-Debug', 'proxy-to-moltbot'); |
| #375 | newHeaders.set('X-Debug-Path', url.pathname); |
| #376 | |
| #377 | return new Response(httpResponse.body, { |
| #378 | status: httpResponse.status, |
| #379 | statusText: httpResponse.statusText, |
| #380 | headers: newHeaders, |
| #381 | }); |
| #382 | }); |
| #383 | |
| #384 | /** |
| #385 | * Scheduled handler for cron triggers. |
| #386 | * Syncs moltbot config/state from container to R2 for persistence. |
| #387 | */ |
| #388 | async function scheduled( |
| #389 | _event: ScheduledEvent, |
| #390 | env: MoltbotEnv, |
| #391 | _ctx: ExecutionContext |
| #392 | ): Promise<void> { |
| #393 | const options = buildSandboxOptions(env); |
| #394 | const sandbox = getSandbox(env.Sandbox, 'moltbot', options); |
| #395 | |
| #396 | console.log('[cron] Starting backup sync to R2...'); |
| #397 | const result = await syncToR2(sandbox, env); |
| #398 | |
| #399 | if (result.success) { |
| #400 | console.log('[cron] Backup sync completed successfully at', result.lastSync); |
| #401 | } else { |
| #402 | console.error('[cron] Backup sync failed:', result.error, result.details || ''); |
| #403 | } |
| #404 | } |
| #405 | |
| #406 | export default { |
| #407 | fetch: app.fetch, |
| #408 | scheduled, |
| #409 | }; |
| #410 |