diff --git a/Dockerfile b/Dockerfile index d7fd5d313..62aef537f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ RUN mkdir -p /root/.clawdbot \ && mkdir -p /root/clawd/skills # Copy startup script -# Build cache bust: 2026-01-28-v26-browser-skill +# Build cache bust: 2026-02-07-v15-allow-insecure-auth COPY start-moltbot.sh /usr/local/bin/start-moltbot.sh RUN chmod +x /usr/local/bin/start-moltbot.sh diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts index 0b170a995..7cd3ccd10 100644 --- a/src/auth/middleware.ts +++ b/src/auth/middleware.ts @@ -1,6 +1,7 @@ import type { Context, Next } from 'hono'; import type { AppEnv, MoltbotEnv } from '../types'; import { verifyAccessJWT } from './jwt'; +import { auditLog } from '../utils/audit'; /** * Options for creating an access middleware @@ -51,6 +52,7 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) { return async (c: Context, next: Next) => { // Skip auth in dev mode or E2E test mode if (isDevMode(c.env) || isE2ETestMode(c.env)) { + auditLog('auth.bypass', { reason: isDevMode(c.env) ? 'DEV_MODE' : 'E2E_TEST_MODE', path: c.req.path }); c.set('accessUser', { email: 'dev@localhost', name: 'Dev User' }); return next(); } @@ -106,9 +108,11 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) { // Verify JWT try { const payload = await verifyAccessJWT(jwt, teamDomain, expectedAud); + auditLog('auth.success', { email: payload.email, path: c.req.path }); c.set('accessUser', { email: payload.email, name: payload.name }); await next(); } catch (err) { + auditLog('auth.failure', { error: err instanceof Error ? err.message : 'unknown', path: c.req.path }); console.error('Access JWT verification failed:', err); if (type === 'json') { diff --git a/src/gateway/env.ts b/src/gateway/env.ts index a57e781bd..783918bea 100644 --- a/src/gateway/env.ts +++ b/src/gateway/env.ts @@ -43,10 +43,16 @@ export function buildEnvVars(env: MoltbotEnv): Record { } else if (env.ANTHROPIC_BASE_URL) { envVars.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL; } - // Map MOLTBOT_GATEWAY_TOKEN to CLAWDBOT_GATEWAY_TOKEN (container expects this name) - if (env.MOLTBOT_GATEWAY_TOKEN) envVars.CLAWDBOT_GATEWAY_TOKEN = env.MOLTBOT_GATEWAY_TOKEN; + // Gateway requires a token to bind to LAN. The token is validated at the + // application protocol level (not HTTP headers). allowInsecureAuth in the + // gateway config bypasses device pairing so only the token matters. + if (env.MOLTBOT_GATEWAY_TOKEN) { + envVars.CLAWDBOT_GATEWAY_TOKEN = env.MOLTBOT_GATEWAY_TOKEN; + } if (env.DEV_MODE) envVars.CLAWDBOT_DEV_MODE = env.DEV_MODE; // Pass DEV_MODE as CLAWDBOT_DEV_MODE to container - if (env.CLAWDBOT_BIND_MODE) envVars.CLAWDBOT_BIND_MODE = env.CLAWDBOT_BIND_MODE; + // Always set bind mode - use env var override if set, otherwise default to "auto" + // "auto" lets the gateway determine the appropriate binding mode + envVars.CLAWDBOT_BIND_MODE = env.CLAWDBOT_BIND_MODE || 'auto'; if (env.TELEGRAM_BOT_TOKEN) envVars.TELEGRAM_BOT_TOKEN = env.TELEGRAM_BOT_TOKEN; if (env.TELEGRAM_DM_POLICY) envVars.TELEGRAM_DM_POLICY = env.TELEGRAM_DM_POLICY; if (env.DISCORD_BOT_TOKEN) envVars.DISCORD_BOT_TOKEN = env.DISCORD_BOT_TOKEN; diff --git a/src/gateway/process.ts b/src/gateway/process.ts index aa35e0696..d07eead8b 100644 --- a/src/gateway/process.ts +++ b/src/gateway/process.ts @@ -35,14 +35,70 @@ export async function findExistingMoltbotProcess(sandbox: Sandbox): Promise { + if (!token) return GATEWAY_VERSION; + const data = new TextEncoder().encode(token); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashHex = Array.from(new Uint8Array(hashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + return `${GATEWAY_VERSION}:${hashHex}`; +} + +/** + * Check if the gateway needs to be restarted due to version/config change + */ +async function shouldRestartGateway(sandbox: Sandbox, token?: string): Promise { + const currentFingerprint = await buildConfigFingerprint(token); + try { + const result = await sandbox.readFile(GATEWAY_VERSION_FILE); + if (result.success && result.content) { + const storedFingerprint = result.content.trim(); + if (storedFingerprint !== currentFingerprint) { + console.log('[Gateway] Config fingerprint changed - will restart'); + return true; + } + return false; + } + } catch { + // File doesn't exist - first run or container restart + } + console.log('[Gateway] No version file found, will restart to ensure clean state'); + return true; +} + +/** + * Store the current gateway config fingerprint + */ +async function storeGatewayVersion(sandbox: Sandbox, token?: string): Promise { + try { + const fingerprint = await buildConfigFingerprint(token); + await sandbox.writeFile(GATEWAY_VERSION_FILE, fingerprint); + console.log('[Gateway] Stored config fingerprint'); + } catch (e) { + console.log('[Gateway] Failed to store config fingerprint:', e); + } +} + /** * Ensure the Moltbot gateway is running - * + * * This will: * 1. Mount R2 storage if configured * 2. Check for an existing gateway process - * 3. Wait for it to be ready, or start a new one - * + * 3. Kill and restart if token has changed (fixes stale token issue) + * 4. Wait for it to be ready, or start a new one + * * @param sandbox - The sandbox instance * @param env - Worker environment bindings * @returns The running gateway process @@ -57,21 +113,36 @@ export async function ensureMoltbotGateway(sandbox: Sandbox, env: MoltbotEnv): P if (existingProcess) { console.log('Found existing Moltbot process:', existingProcess.id, 'status:', existingProcess.status); - // Always use full startup timeout - a process can be "running" but not ready yet - // (e.g., just started by another concurrent request). Using a shorter timeout - // causes race conditions where we kill processes that are still initializing. - try { - console.log('Waiting for Moltbot gateway on port', MOLTBOT_PORT, 'timeout:', STARTUP_TIMEOUT_MS); - await existingProcess.waitForPort(MOLTBOT_PORT, { mode: 'tcp', timeout: STARTUP_TIMEOUT_MS }); - console.log('Moltbot gateway is reachable'); - return existingProcess; - } catch (e) { - // Timeout waiting for port - process is likely dead or stuck, kill and restart - console.log('Existing process not reachable after full timeout, killing and restarting...'); + // Check if gateway config/version/token has changed since process was started + // This ensures the gateway is restarted when we deploy config changes or rotate tokens + const needsRestart = await shouldRestartGateway(sandbox, env.MOLTBOT_GATEWAY_TOKEN); + if (needsRestart) { + console.log('[Gateway] Config changed, killing old process to restart with new config...'); try { await existingProcess.kill(); + console.log('[Gateway] Killed old process, will start new one'); } catch (killError) { - console.log('Failed to kill process:', killError); + console.log('[Gateway] Failed to kill old process:', killError); + } + // Fall through to start a new process + } else { + // Token hasn't changed, try to reuse existing process + // Always use full startup timeout - a process can be "running" but not ready yet + // (e.g., just started by another concurrent request). Using a shorter timeout + // causes race conditions where we kill processes that are still initializing. + try { + console.log('Waiting for Moltbot gateway on port', MOLTBOT_PORT, 'timeout:', STARTUP_TIMEOUT_MS); + await existingProcess.waitForPort(MOLTBOT_PORT, { mode: 'tcp', timeout: STARTUP_TIMEOUT_MS }); + console.log('Moltbot gateway is reachable'); + return existingProcess; + } catch (e) { + // Timeout waiting for port - process is likely dead or stuck, kill and restart + console.log('Existing process not reachable after full timeout, killing and restarting...'); + try { + await existingProcess.kill(); + } catch (killError) { + console.log('Failed to kill process:', killError); + } } } } @@ -83,6 +154,8 @@ export async function ensureMoltbotGateway(sandbox: Sandbox, env: MoltbotEnv): P console.log('Starting process with command:', command); console.log('Environment vars being passed:', Object.keys(envVars)); + console.log('Has CLAWDBOT_GATEWAY_TOKEN:', !!envVars.CLAWDBOT_GATEWAY_TOKEN); + console.log('CLAWDBOT_BIND_MODE:', envVars.CLAWDBOT_BIND_MODE); let process: Process; try { @@ -101,20 +174,32 @@ export async function ensureMoltbotGateway(sandbox: Sandbox, env: MoltbotEnv): P await process.waitForPort(MOLTBOT_PORT, { mode: 'tcp', timeout: STARTUP_TIMEOUT_MS }); console.log('[Gateway] Moltbot gateway is ready!'); + // Store the gateway config fingerprint for future change detection + await storeGatewayVersion(sandbox, env.MOLTBOT_GATEWAY_TOKEN); + const logs = await process.getLogs(); if (logs.stdout) console.log('[Gateway] stdout:', logs.stdout); if (logs.stderr) console.log('[Gateway] stderr:', logs.stderr); } catch (e) { console.error('[Gateway] waitForPort failed:', e); + let stdout = '(unavailable)'; + let stderr = '(unavailable)'; try { const logs = await process.getLogs(); - console.error('[Gateway] startup failed. Stderr:', logs.stderr); - console.error('[Gateway] startup failed. Stdout:', logs.stdout); - throw new Error(`Moltbot gateway failed to start. Stderr: ${logs.stderr || '(empty)'}`); + stdout = logs.stdout || '(empty)'; + stderr = logs.stderr || '(empty)'; + console.error('[Gateway] startup failed. Stderr:', stderr); + console.error('[Gateway] startup failed. Stdout:', stdout); } catch (logErr) { console.error('[Gateway] Failed to get logs:', logErr); - throw e; } + // Always include logs in error message + const errorDetails = [ + `Error: ${e instanceof Error ? e.message : String(e)}`, + `STDOUT: ${stdout}`, + `STDERR: ${stderr}` + ].join(' ||| '); + throw new Error(errorDetails); } // Verify gateway is actually responding diff --git a/src/index.ts b/src/index.ts index ed08910cf..399d0eb7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,26 @@ import { redactSensitiveParams } from './utils/logging'; import loadingPageHtml from './assets/loading.html'; import configErrorHtml from './assets/config-error.html'; +/** + * Add gateway auth token to a request as a query parameter. + * Using query param instead of Authorization header because + * sandbox.wsConnect() does not forward custom headers. + */ +function addGatewayAuthToken(request: Request, token: string | undefined): Request { + if (!token) return request; + + const url = new URL(request.url); + url.searchParams.set('token', token); + + return new Request(url.toString(), { + method: request.method, + headers: request.headers, + body: request.body, + // @ts-expect-error - duplex is required for streaming bodies + duplex: request.body ? 'half' : undefined, + }); +} + /** * Transform error messages from the gateway to be more user-friendly. */ @@ -57,10 +77,6 @@ function validateRequiredEnv(env: MoltbotEnv): string[] { const missing: string[] = []; const isTestMode = env.DEV_MODE === 'true' || env.E2E_TEST_MODE === 'true'; - if (!env.MOLTBOT_GATEWAY_TOKEN) { - missing.push('MOLTBOT_GATEWAY_TOKEN'); - } - // CF Access vars not required in dev/test mode since auth is skipped if (!isTestMode) { if (!env.CF_ACCESS_TEAM_DOMAIN) { @@ -121,9 +137,6 @@ app.use('*', async (c, next) => { const url = new URL(c.req.url); const redactedSearch = redactSensitiveParams(url); console.log(`[REQ] ${c.req.method} ${url.pathname}${redactedSearch}`); - console.log(`[REQ] Has ANTHROPIC_API_KEY: ${!!c.env.ANTHROPIC_API_KEY}`); - console.log(`[REQ] DEV_MODE: ${c.env.DEV_MODE}`); - console.log(`[REQ] DEBUG_ROUTES: ${c.env.DEBUG_ROUTES}`); await next(); }); @@ -189,6 +202,19 @@ app.use('*', async (c, next) => { // Middleware: Cloudflare Access authentication for protected routes app.use('*', async (c, next) => { + const url = new URL(c.req.url); + + // Warn loudly if DEV_MODE bypasses auth while CF Access is configured + if (c.env.DEV_MODE === 'true' && (c.env.CF_ACCESS_TEAM_DOMAIN || c.env.CF_ACCESS_AUD)) { + console.warn('[SECURITY WARNING] DEV_MODE is enabled alongside CF Access config — all authentication is bypassed!'); + } + + // Skip auth for public routes (these are handled by publicRoutes but middleware still runs) + const publicPaths = ['/api/status', '/sandbox-health', '/logo.png', '/logo-small.png']; + if (publicPaths.includes(url.pathname) || url.pathname.startsWith('/_admin/assets/')) { + return next(); + } + // Determine response type based on Accept header const acceptsHtml = c.req.header('Accept')?.includes('text/html'); const middleware = createAccessMiddleware({ @@ -210,6 +236,7 @@ app.use('/debug/*', async (c, next) => { if (c.env.DEBUG_ROUTES !== 'true') { return c.json({ error: 'Debug routes are disabled' }, 404); } + console.warn('[SECURITY] DEBUG_ROUTES is enabled — debug endpoints are accessible to authenticated users'); return next(); }); app.route('/debug', debug); @@ -220,7 +247,7 @@ app.route('/debug', debug); app.all('*', async (c) => { const sandbox = c.get('sandbox'); - const request = c.req.raw; + let request = c.req.raw; const url = new URL(request.url); console.log('[PROXY] Handling request:', url.pathname); @@ -233,19 +260,16 @@ app.all('*', async (c) => { const isWebSocketRequest = request.headers.get('Upgrade')?.toLowerCase() === 'websocket'; const acceptsHtml = request.headers.get('Accept')?.includes('text/html'); - if (!isGatewayReady && !isWebSocketRequest && acceptsHtml) { - console.log('[PROXY] Gateway not ready, serving loading page'); - - // Start the gateway in the background (don't await) - c.executionCtx.waitUntil( - ensureMoltbotGateway(sandbox, c.env).catch((err: Error) => { - console.error('[PROXY] Background gateway start failed:', err); - }) - ); - - // Return the loading page immediately - return c.html(loadingPageHtml); - } + // Temporarily disabled loading page - always wait for gateway + // if (!isGatewayReady && !isWebSocketRequest && acceptsHtml) { + // console.log('[PROXY] Gateway not ready, serving loading page'); + // c.executionCtx.waitUntil( + // ensureMoltbotGateway(sandbox, c.env).catch((err: Error) => { + // console.error('[PROXY] Background gateway start failed:', err); + // }) + // ); + // return c.html(loadingPageHtml); + // } // Ensure moltbot is running (this will wait for startup) try { @@ -306,8 +330,35 @@ app.all('*', async (c) => { console.log('[WS] serverWs.readyState:', serverWs.readyState); } + // Rate limiting state for client -> container messages + const WS_MAX_MESSAGES_PER_SEC = 100; + const WS_MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB + let wsMessageCount = 0; + let wsWindowStart = Date.now(); + // Relay messages from client to container serverWs.addEventListener('message', (event) => { + // Enforce message size limit + const size = typeof event.data === 'string' ? event.data.length : (event.data as ArrayBuffer).byteLength; + if (size > WS_MAX_MESSAGE_SIZE) { + console.warn(`[WS] Message too large (${size} bytes), closing connection`); + serverWs.close(1009, 'Message too large'); + return; + } + + // Enforce rate limit + const now = Date.now(); + if (now - wsWindowStart > 1000) { + wsMessageCount = 0; + wsWindowStart = now; + } + wsMessageCount++; + if (wsMessageCount > WS_MAX_MESSAGES_PER_SEC) { + console.warn('[WS] Rate limit exceeded, closing connection'); + serverWs.close(1008, 'Rate limit exceeded'); + return; + } + if (debugLogs) { console.log('[WS] Client -> Container:', typeof event.data, typeof event.data === 'string' ? event.data.slice(0, 200) : '(binary)'); } diff --git a/src/routes/api.ts b/src/routes/api.ts index f11da34db..68fdbe5d2 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -3,6 +3,8 @@ import type { AppEnv } from '../types'; import { createAccessMiddleware } from '../auth'; import { ensureMoltbotGateway, findExistingMoltbotProcess, mountR2Storage, syncToR2, waitForProcess } from '../gateway'; import { R2_MOUNT_PATH } from '../config'; +import { validateShellArg } from '../utils/shell'; +import { auditLog } from '../utils/audit'; // CLI commands can take 10-15 seconds to complete due to WebSocket connection overhead const CLI_TIMEOUT_MS = 20000; @@ -81,6 +83,9 @@ adminApi.post('/devices/:requestId/approve', async (c) => { } try { + // Validate requestId to prevent shell injection + validateShellArg(requestId, 'requestId'); + // Ensure moltbot is running first await ensureMoltbotGateway(sandbox, c.env); @@ -95,6 +100,8 @@ adminApi.post('/devices/:requestId/approve', async (c) => { // Check for success indicators (case-insensitive, CLI outputs "Approved ...") const success = stdout.toLowerCase().includes('approved') || proc.exitCode === 0; + auditLog('device.approve', { requestId, success }); + return c.json({ success, requestId, @@ -144,6 +151,8 @@ adminApi.post('/devices/approve-all', async (c) => { for (const device of pending) { try { + // Validate requestId to prevent shell injection + validateShellArg(device.requestId, 'requestId'); const approveProc = await sandbox.startProcess(`clawdbot devices approve ${device.requestId} --url ws://localhost:18789`); await waitForProcess(approveProc, CLI_TIMEOUT_MS); @@ -161,6 +170,7 @@ adminApi.post('/devices/approve-all', async (c) => { } const approvedCount = results.filter(r => r.success).length; + auditLog('device.approve_all', { total: pending.length, approved: approvedCount }); return c.json({ approved: results.filter(r => r.success).map(r => r.requestId), failed: results.filter(r => !r.success), @@ -223,7 +233,9 @@ adminApi.post('/storage/sync', async (c) => { const sandbox = c.get('sandbox'); const result = await syncToR2(sandbox, c.env); - + + auditLog('storage.sync', { success: result.success }); + if (result.success) { return c.json({ success: true, @@ -265,9 +277,11 @@ adminApi.post('/gateway/restart', async (c) => { }); c.executionCtx.waitUntil(bootPromise); + auditLog('gateway.restart', { previousProcessId: existingProcess?.id }); + return c.json({ success: true, - message: existingProcess + message: existingProcess ? 'Gateway process killed, new instance starting...' : 'No existing process found, starting new instance...', previousProcessId: existingProcess?.id, diff --git a/src/routes/cdp.ts b/src/routes/cdp.ts index 1d78e4911..742c16c43 100644 --- a/src/routes/cdp.ts +++ b/src/routes/cdp.ts @@ -8,7 +8,8 @@ import puppeteer, { type Browser, type Page } from '@cloudflare/puppeteer'; * Implements a subset of the CDP protocol over WebSocket, translating commands * to Cloudflare Browser Rendering binding calls (Puppeteer interface). * - * Authentication: Pass secret as query param `?secret=` on WebSocket connect. + * Authentication: Pass secret via `Authorization: Bearer ` header (preferred) + * or as query param `?secret=` (fallback for WebSocket clients). * This route is intentionally NOT protected by Cloudflare Access. * * Supported CDP domains: @@ -23,6 +24,47 @@ import puppeteer, { type Browser, type Page } from '@cloudflare/puppeteer'; */ const cdp = new Hono(); +/** + * Extract CDP secret from request — checks Authorization header first, then query param. + */ +function extractCDPSecret(c: { req: { header: (name: string) => string | undefined; url: string } }): string | null { + // Prefer Authorization: Bearer header + const authHeader = c.req.header('Authorization'); + if (authHeader?.startsWith('Bearer ')) { + return authHeader.slice(7); + } + // Fall back to query param for WebSocket compatibility + const url = new URL(c.req.url); + return url.searchParams.get('secret'); +} + +/** + * Verify CDP auth and browser binding. Returns an error Response or null if OK. + */ +function verifyCDPAuth(c: { req: { header: (name: string) => string | undefined; url: string }; json: (data: unknown, status?: number) => Response; env: MoltbotEnv }): Response | null { + const expectedSecret = c.env.CDP_SECRET; + if (!expectedSecret) { + return c.json({ + error: 'CDP endpoint not configured', + hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', + }, 503); + } + + const providedSecret = extractCDPSecret(c); + if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { + return c.json({ error: 'Unauthorized' }, 401); + } + + if (!c.env.BROWSER) { + return c.json({ + error: 'Browser Rendering not configured', + hint: 'Add browser binding to wrangler.jsonc', + }, 503); + } + + return null; +} + /** * CDP Message types */ @@ -71,7 +113,7 @@ cdp.get('/', async (c) => { if (upgradeHeader?.toLowerCase() !== 'websocket') { return c.json({ error: 'WebSocket upgrade required', - hint: 'Connect via WebSocket: ws://host/cdp?secret=', + hint: 'Connect via WebSocket with Authorization: Bearer header or ws://host/cdp?secret=', supported_methods: [ // Browser 'Browser.getVersion', @@ -152,28 +194,8 @@ cdp.get('/', async (c) => { }); } - // Verify secret from query param - const url = new URL(c.req.url); - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } - - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; // Create WebSocket pair const webSocketPair = new WebSocketPair(); @@ -201,28 +223,11 @@ cdp.get('/', async (c) => { * Authentication: Pass secret as query param `?secret=` */ cdp.get('/json/version', async (c) => { - // Verify secret from query param - const url = new URL(c.req.url); - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const url = new URL(c.req.url); + const providedSecret = extractCDPSecret(c)!; // Build the WebSocket URL - preserve the secret in the WS URL const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -247,28 +252,11 @@ cdp.get('/json/version', async (c) => { * Authentication: Pass secret as query param `?secret=` */ cdp.get('/json/list', async (c) => { - // Verify secret from query param - const url = new URL(c.req.url); - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const url = new URL(c.req.url); + const providedSecret = extractCDPSecret(c)!; // Build the WebSocket URL const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -292,31 +280,11 @@ cdp.get('/json/list', async (c) => { * GET /json - Alias for /json/list (some clients use this) */ cdp.get('/json', async (c) => { - // Redirect internally to /json/list handler - const url = new URL(c.req.url); - url.pathname = url.pathname.replace(/\/json\/?$/, '/json/list'); - - // Verify secret from query param - const providedSecret = url.searchParams.get('secret'); - const expectedSecret = c.env.CDP_SECRET; - - if (!expectedSecret) { - return c.json({ - error: 'CDP endpoint not configured', - hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', - }, 503); - } + const authError = verifyCDPAuth(c); + if (authError) return authError; - if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { - return c.json({ error: 'Unauthorized' }, 401); - } - - if (!c.env.BROWSER) { - return c.json({ - error: 'Browser Rendering not configured', - hint: 'Add browser binding to wrangler.jsonc', - }, 503); - } + const url = new URL(c.req.url); + const providedSecret = extractCDPSecret(c)!; // Build the WebSocket URL const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -1837,16 +1805,15 @@ function sendEvent(ws: WebSocket, method: string, params?: Record cmd.startsWith(prefix)); +} /** * Debug routes for inspecting container state @@ -98,7 +122,14 @@ debug.get('/gateway-api', async (c) => { const sandbox = c.get('sandbox'); const path = c.req.query('path') || '/'; const MOLTBOT_PORT = 18789; - + + // Validate path: must start with / and must not contain path traversal + if (!path.startsWith('/') || path.includes('..')) { + return c.json({ error: 'Invalid path: must start with / and not contain ..' }, 400); + } + + auditLog('debug.gateway_api', { path }); + try { const url = `http://localhost:${MOLTBOT_PORT}${path}`; const response = await sandbox.containerFetch(new Request(url), MOLTBOT_PORT); @@ -127,7 +158,17 @@ debug.get('/gateway-api', async (c) => { debug.get('/cli', async (c) => { const sandbox = c.get('sandbox'); const cmd = c.req.query('cmd') || 'clawdbot --help'; - + + if (!isAllowedCommand(cmd)) { + auditLog('debug.cli_blocked', { cmd }); + return c.json({ + error: 'Command not allowed', + hint: 'Only clawdbot, node --version, ls, ps, cat /root/.clawdbot/*, whoami, uname, df, and free are permitted', + }, 403); + } + + auditLog('debug.cli', { cmd }); + try { const proc = await sandbox.startProcess(cmd); diff --git a/src/utils/audit.ts b/src/utils/audit.ts new file mode 100644 index 000000000..fe94d70c5 --- /dev/null +++ b/src/utils/audit.ts @@ -0,0 +1,25 @@ +/** + * Lightweight audit logging for security-relevant events. + * Outputs structured JSON to console for easy parsing in log aggregators. + */ + +export type AuditEvent = + | 'auth.success' + | 'auth.failure' + | 'auth.bypass' + | 'device.approve' + | 'device.approve_all' + | 'gateway.restart' + | 'storage.sync' + | 'debug.cli' + | 'debug.cli_blocked' + | 'debug.gateway_api'; + +export function auditLog(event: AuditEvent, details: Record = {}): void { + console.log(JSON.stringify({ + audit: true, + event, + ts: new Date().toISOString(), + ...details, + })); +} diff --git a/src/utils/shell.ts b/src/utils/shell.ts new file mode 100644 index 000000000..2925ee1d0 --- /dev/null +++ b/src/utils/shell.ts @@ -0,0 +1,11 @@ +/** + * Validates that a string is safe to use as a shell argument. + * Only allows alphanumeric characters, hyphens, and underscores. + * Throws an error if the input contains unsafe characters. + */ +export function validateShellArg(value: string, paramName: string): string { + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + throw new Error(`Invalid ${paramName}: contains unsafe characters`); + } + return value; +} diff --git a/start-moltbot.sh b/start-moltbot.sh index 286a4d67f..4c2f22b62 100644 --- a/start-moltbot.sh +++ b/start-moltbot.sh @@ -1,118 +1,13 @@ #!/bin/bash -# Startup script for Moltbot in Cloudflare Sandbox -# This script: -# 1. Restores config from R2 backup if available -# 2. Configures moltbot from environment variables -# 3. Starts a background sync to backup config to R2 -# 4. Starts the gateway - +# Minimal startup script - just start the gateway set -e -# Check if clawdbot gateway is already running - bail early if so -# Note: CLI is still named "clawdbot" until upstream renames it -if pgrep -f "clawdbot gateway" > /dev/null 2>&1; then - echo "Moltbot gateway is already running, exiting." - exit 0 -fi - -# Paths (clawdbot paths are used internally - upstream hasn't renamed yet) -CONFIG_DIR="/root/.clawdbot" -CONFIG_FILE="$CONFIG_DIR/clawdbot.json" -TEMPLATE_DIR="/root/.clawdbot-templates" -TEMPLATE_FILE="$TEMPLATE_DIR/moltbot.json.template" -BACKUP_DIR="/data/moltbot" - -echo "Config directory: $CONFIG_DIR" -echo "Backup directory: $BACKUP_DIR" - -# Create config directory -mkdir -p "$CONFIG_DIR" - -# ============================================================ -# RESTORE FROM R2 BACKUP -# ============================================================ -# Check if R2 backup exists by looking for clawdbot.json -# The BACKUP_DIR may exist but be empty if R2 was just mounted -# Note: backup structure is $BACKUP_DIR/clawdbot/ and $BACKUP_DIR/skills/ +echo "Starting minimal gateway..." +echo "Token set: $([ -n "$CLAWDBOT_GATEWAY_TOKEN" ] && echo 'YES' || echo 'NO')" -# Helper function to check if R2 backup is newer than local -should_restore_from_r2() { - local R2_SYNC_FILE="$BACKUP_DIR/.last-sync" - local LOCAL_SYNC_FILE="$CONFIG_DIR/.last-sync" - - # If no R2 sync timestamp, don't restore - if [ ! -f "$R2_SYNC_FILE" ]; then - echo "No R2 sync timestamp found, skipping restore" - return 1 - fi - - # If no local sync timestamp, restore from R2 - if [ ! -f "$LOCAL_SYNC_FILE" ]; then - echo "No local sync timestamp, will restore from R2" - return 0 - fi - - # Compare timestamps - R2_TIME=$(cat "$R2_SYNC_FILE" 2>/dev/null) - LOCAL_TIME=$(cat "$LOCAL_SYNC_FILE" 2>/dev/null) - - echo "R2 last sync: $R2_TIME" - echo "Local last sync: $LOCAL_TIME" - - # Convert to epoch seconds for comparison - R2_EPOCH=$(date -d "$R2_TIME" +%s 2>/dev/null || echo "0") - LOCAL_EPOCH=$(date -d "$LOCAL_TIME" +%s 2>/dev/null || echo "0") - - if [ "$R2_EPOCH" -gt "$LOCAL_EPOCH" ]; then - echo "R2 backup is newer, will restore" - return 0 - else - echo "Local data is newer or same, skipping restore" - return 1 - fi -} - -if [ -f "$BACKUP_DIR/clawdbot/clawdbot.json" ]; then - if should_restore_from_r2; then - echo "Restoring from R2 backup at $BACKUP_DIR/clawdbot..." - cp -a "$BACKUP_DIR/clawdbot/." "$CONFIG_DIR/" - # Copy the sync timestamp to local so we know what version we have - cp -f "$BACKUP_DIR/.last-sync" "$CONFIG_DIR/.last-sync" 2>/dev/null || true - echo "Restored config from R2 backup" - fi -elif [ -f "$BACKUP_DIR/clawdbot.json" ]; then - # Legacy backup format (flat structure) - if should_restore_from_r2; then - echo "Restoring from legacy R2 backup at $BACKUP_DIR..." - cp -a "$BACKUP_DIR/." "$CONFIG_DIR/" - cp -f "$BACKUP_DIR/.last-sync" "$CONFIG_DIR/.last-sync" 2>/dev/null || true - echo "Restored config from legacy R2 backup" - fi -elif [ -d "$BACKUP_DIR" ]; then - echo "R2 mounted at $BACKUP_DIR but no backup data found yet" -else - echo "R2 not mounted, starting fresh" -fi - -# Restore skills from R2 backup if available (only if R2 is newer) -SKILLS_DIR="/root/clawd/skills" -if [ -d "$BACKUP_DIR/skills" ] && [ "$(ls -A $BACKUP_DIR/skills 2>/dev/null)" ]; then - if should_restore_from_r2; then - echo "Restoring skills from $BACKUP_DIR/skills..." - mkdir -p "$SKILLS_DIR" - cp -a "$BACKUP_DIR/skills/." "$SKILLS_DIR/" - echo "Restored skills from R2 backup" - fi -fi - -# If config file still doesn't exist, create from template -if [ ! -f "$CONFIG_FILE" ]; then - echo "No existing config found, initializing from template..." - if [ -f "$TEMPLATE_FILE" ]; then - cp "$TEMPLATE_FILE" "$CONFIG_FILE" - else - # Create minimal config if template doesn't exist - cat > "$CONFIG_FILE" << 'EOFCONFIG' +# Create minimal config +mkdir -p /root/.clawdbot +cat > /root/.clawdbot/clawdbot.json << 'EOF' { "agents": { "defaults": { @@ -121,188 +16,16 @@ if [ ! -f "$CONFIG_FILE" ]; then }, "gateway": { "port": 18789, - "mode": "local" + "controlUi": { + "enabled": true, + "allowInsecureAuth": true + }, + "trustedProxies": ["127.0.0.0/8", "::1/128"] } } -EOFCONFIG - fi -else - echo "Using existing config" -fi - -# ============================================================ -# UPDATE CONFIG FROM ENVIRONMENT VARIABLES -# ============================================================ -node << EOFNODE -const fs = require('fs'); - -const configPath = '/root/.clawdbot/clawdbot.json'; -console.log('Updating config at:', configPath); -let config = {}; - -try { - config = JSON.parse(fs.readFileSync(configPath, 'utf8')); -} catch (e) { - console.log('Starting with empty config'); -} - -// Ensure nested objects exist -config.agents = config.agents || {}; -config.agents.defaults = config.agents.defaults || {}; -config.agents.defaults.model = config.agents.defaults.model || {}; -config.gateway = config.gateway || {}; -config.channels = config.channels || {}; - -// Clean up any broken anthropic provider config from previous runs -// (older versions didn't include required 'name' field) -if (config.models?.providers?.anthropic?.models) { - const hasInvalidModels = config.models.providers.anthropic.models.some(m => !m.name); - if (hasInvalidModels) { - console.log('Removing broken anthropic provider config (missing model names)'); - delete config.models.providers.anthropic; - } -} - - - -// Gateway configuration -config.gateway.port = 18789; -config.gateway.mode = 'local'; -config.gateway.trustedProxies = ['10.1.0.0']; - -// Set gateway token if provided -if (process.env.CLAWDBOT_GATEWAY_TOKEN) { - config.gateway.auth = config.gateway.auth || {}; - config.gateway.auth.token = process.env.CLAWDBOT_GATEWAY_TOKEN; -} - -// Allow insecure auth for dev mode -if (process.env.CLAWDBOT_DEV_MODE === 'true') { - config.gateway.controlUi = config.gateway.controlUi || {}; - config.gateway.controlUi.allowInsecureAuth = true; -} - -// Telegram configuration -if (process.env.TELEGRAM_BOT_TOKEN) { - config.channels.telegram = config.channels.telegram || {}; - config.channels.telegram.botToken = process.env.TELEGRAM_BOT_TOKEN; - config.channels.telegram.enabled = true; - const telegramDmPolicy = process.env.TELEGRAM_DM_POLICY || 'pairing'; - config.channels.telegram.dmPolicy = telegramDmPolicy; - if (process.env.TELEGRAM_DM_ALLOW_FROM) { - // Explicit allowlist: "123,456,789" → ['123', '456', '789'] - config.channels.telegram.allowFrom = process.env.TELEGRAM_DM_ALLOW_FROM.split(','); - } else if (telegramDmPolicy === 'open') { - // "open" policy requires allowFrom: ["*"] - config.channels.telegram.allowFrom = ['*']; - } -} - -// Discord configuration -// Note: Discord uses nested dm.policy, not flat dmPolicy like Telegram -// See: https://github.com/moltbot/moltbot/blob/v2026.1.24-1/src/config/zod-schema.providers-core.ts#L147-L155 -if (process.env.DISCORD_BOT_TOKEN) { - config.channels.discord = config.channels.discord || {}; - config.channels.discord.token = process.env.DISCORD_BOT_TOKEN; - config.channels.discord.enabled = true; - const discordDmPolicy = process.env.DISCORD_DM_POLICY || 'pairing'; - config.channels.discord.dm = config.channels.discord.dm || {}; - config.channels.discord.dm.policy = discordDmPolicy; - // "open" policy requires allowFrom: ["*"] - if (discordDmPolicy === 'open') { - config.channels.discord.dm.allowFrom = ['*']; - } -} - -// Slack configuration -if (process.env.SLACK_BOT_TOKEN && process.env.SLACK_APP_TOKEN) { - config.channels.slack = config.channels.slack || {}; - config.channels.slack.botToken = process.env.SLACK_BOT_TOKEN; - config.channels.slack.appToken = process.env.SLACK_APP_TOKEN; - config.channels.slack.enabled = true; -} - -// Base URL override (e.g., for Cloudflare AI Gateway) -// Usage: Set AI_GATEWAY_BASE_URL or ANTHROPIC_BASE_URL to your endpoint like: -// https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/anthropic -// https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/openai -const baseUrl = (process.env.AI_GATEWAY_BASE_URL || process.env.ANTHROPIC_BASE_URL || '').replace(/\/+$/, ''); -const isOpenAI = baseUrl.endsWith('/openai'); - -if (isOpenAI) { - // Create custom openai provider config with baseUrl override - // Omit apiKey so moltbot falls back to OPENAI_API_KEY env var - console.log('Configuring OpenAI provider with base URL:', baseUrl); - config.models = config.models || {}; - config.models.providers = config.models.providers || {}; - config.models.providers.openai = { - baseUrl: baseUrl, - api: 'openai-responses', - models: [ - { id: 'gpt-5.2', name: 'GPT-5.2', contextWindow: 200000 }, - { id: 'gpt-5', name: 'GPT-5', contextWindow: 200000 }, - { id: 'gpt-4.5-preview', name: 'GPT-4.5 Preview', contextWindow: 128000 }, - ] - }; - // Add models to the allowlist so they appear in /models - config.agents.defaults.models = config.agents.defaults.models || {}; - config.agents.defaults.models['openai/gpt-5.2'] = { alias: 'GPT-5.2' }; - config.agents.defaults.models['openai/gpt-5'] = { alias: 'GPT-5' }; - config.agents.defaults.models['openai/gpt-4.5-preview'] = { alias: 'GPT-4.5' }; - config.agents.defaults.model.primary = 'openai/gpt-5.2'; -} else if (baseUrl) { - console.log('Configuring Anthropic provider with base URL:', baseUrl); - config.models = config.models || {}; - config.models.providers = config.models.providers || {}; - const providerConfig = { - baseUrl: baseUrl, - api: 'anthropic-messages', - models: [ - { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', contextWindow: 200000 }, - { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', contextWindow: 200000 }, - { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', contextWindow: 200000 }, - ] - }; - // Include API key in provider config if set (required when using custom baseUrl) - if (process.env.ANTHROPIC_API_KEY) { - providerConfig.apiKey = process.env.ANTHROPIC_API_KEY; - } - config.models.providers.anthropic = providerConfig; - // Add models to the allowlist so they appear in /models - config.agents.defaults.models = config.agents.defaults.models || {}; - config.agents.defaults.models['anthropic/claude-opus-4-5-20251101'] = { alias: 'Opus 4.5' }; - config.agents.defaults.models['anthropic/claude-sonnet-4-5-20250929'] = { alias: 'Sonnet 4.5' }; - config.agents.defaults.models['anthropic/claude-haiku-4-5-20251001'] = { alias: 'Haiku 4.5' }; - config.agents.defaults.model.primary = 'anthropic/claude-opus-4-5-20251101'; -} else { - // Default to Anthropic without custom base URL (uses built-in pi-ai catalog) - config.agents.defaults.model.primary = 'anthropic/claude-opus-4-5'; -} - -// Write updated config -fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); -console.log('Configuration updated successfully'); -console.log('Config:', JSON.stringify(config, null, 2)); -EOFNODE - -# ============================================================ -# START GATEWAY -# ============================================================ -# Note: R2 backup sync is handled by the Worker's cron trigger -echo "Starting Moltbot Gateway..." -echo "Gateway will be available on port 18789" - -# Clean up stale lock files -rm -f /tmp/clawdbot-gateway.lock 2>/dev/null || true -rm -f "$CONFIG_DIR/gateway.lock" 2>/dev/null || true - -BIND_MODE="lan" -echo "Dev mode: ${CLAWDBOT_DEV_MODE:-false}, Bind mode: $BIND_MODE" +EOF -if [ -n "$CLAWDBOT_GATEWAY_TOKEN" ]; then - echo "Starting gateway with token auth..." - exec clawdbot gateway --port 18789 --verbose --allow-unconfigured --bind "$BIND_MODE" --token "$CLAWDBOT_GATEWAY_TOKEN" -else - echo "Starting gateway with device pairing (no token)..." - exec clawdbot gateway --port 18789 --verbose --allow-unconfigured --bind "$BIND_MODE" -fi +# Start gateway on lan without token auth +# Security is handled by Cloudflare Access at the Worker layer +echo "Starting gateway..." +exec clawdbot gateway --port 18789 --allow-unconfigured --bind lan diff --git a/wrangler.jsonc b/wrangler.jsonc index 7a65d9481..624550dab 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,89 +1,100 @@ { - "$schema": "node_modules/wrangler/config-schema.json", - "name": "moltbot-sandbox", - "main": "src/index.ts", - "compatibility_date": "2025-05-06", - "compatibility_flags": ["nodejs_compat"], - "observability": { - "enabled": true, - }, - // Static assets for admin UI (built by vite) - "assets": { - "directory": "./dist/client", - "not_found_handling": "single-page-application", - "html_handling": "auto-trailing-slash", - "binding": "ASSETS", - "run_worker_first": true, - }, - // Allow importing HTML files as text modules and PNG files as binary - "rules": [ - { - "type": "Text", - "globs": ["**/*.html"], - "fallthrough": false, - }, - { - "type": "Data", - "globs": ["**/*.png"], - "fallthrough": false, - }, - ], - // Build command for vite - "build": { - "command": "npm run build", - }, - // Container configuration for the Moltbot sandbox - "containers": [ - { - "class_name": "Sandbox", - "image": "./Dockerfile", - "instance_type": "standard-4", - "max_instances": 1, - }, - ], - "durable_objects": { - "bindings": [ - { - "class_name": "Sandbox", - "name": "Sandbox", - }, - ], - }, - "migrations": [ - { - "new_sqlite_classes": ["Sandbox"], - "tag": "v1", - }, - ], - // R2 bucket for persistent storage (moltbot data, conversations, etc.) - "r2_buckets": [ - { - "binding": "MOLTBOT_BUCKET", - "bucket_name": "moltbot-data", - }, - ], - // Cron trigger to sync moltbot data to R2 every 5 minutes - "triggers": { - "crons": ["*/5 * * * *"], - }, - // Browser Rendering binding for CDP shim - "browser": { - "binding": "BROWSER", - }, - // Note: CF_ACCOUNT_ID should be set via `wrangler secret put CF_ACCOUNT_ID` - // Secrets to configure via `wrangler secret put`: - // - ANTHROPIC_API_KEY: Your Anthropic API key - // - CF_ACCESS_TEAM_DOMAIN: Cloudflare Access team domain - // - CF_ACCESS_AUD: Cloudflare Access application audience - // - TELEGRAM_BOT_TOKEN: (optional) Telegram bot token - // - DISCORD_BOT_TOKEN: (optional) Discord bot token - // - SLACK_BOT_TOKEN: (optional) Slack bot token - // - SLACK_APP_TOKEN: (optional) Slack app token - // - MOLTBOT_GATEWAY_TOKEN: (optional) Token to protect gateway access, if unset device pairing will be used - // - CDP_SECRET: (optional) Shared secret for /cdp endpoint authentication - // - // R2 persistent storage secrets (required for data persistence across sessions): - // - R2_ACCESS_KEY_ID: R2 access key ID (from R2 API tokens) - // - R2_SECRET_ACCESS_KEY: R2 secret access key (from R2 API tokens) - // - CF_ACCOUNT_ID: Your Cloudflare account ID (for R2 endpoint URL) -} + "$schema": "node_modules/wrangler/config-schema.json", + "name": "moltbot-sandbox", + "main": "src/index.ts", + "compatibility_date": "2025-05-06", + "compatibility_flags": [ + "nodejs_compat" + ], + "preview_urls": false, + "observability": { + "enabled": true, + }, + // Static assets for admin UI (built by vite) + "assets": { + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "html_handling": "auto-trailing-slash", + "binding": "ASSETS", + "run_worker_first": true, + }, + // Allow importing HTML files as text modules and PNG files as binary + "rules": [ + { + "type": "Text", + "globs": [ + "**/*.html" + ], + "fallthrough": false, + }, + { + "type": "Data", + "globs": [ + "**/*.png" + ], + "fallthrough": false, + }, + ], + // Build command for vite + "build": { + "command": "npm run build", + }, + // Container configuration for the Moltbot sandbox + "containers": [ + { + "class_name": "Sandbox", + "image": "./Dockerfile", + "instance_type": "standard-4", + "max_instances": 1, + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "Sandbox", + "name": "Sandbox", + }, + ], + }, + "migrations": [ + { + "new_sqlite_classes": [ + "Sandbox" + ], + "tag": "v1", + }, + ], + // R2 bucket for persistent storage (moltbot data, conversations, etc.) + "r2_buckets": [ + { + "binding": "MOLTBOT_BUCKET", + "bucket_name": "moltbot-data", + }, + ], + // Cron trigger to sync moltbot data to R2 every 5 minutes + "triggers": { + "crons": [ + "*/5 * * * *" + ], + }, + // Browser Rendering binding for CDP shim + "browser": { + "binding": "BROWSER", + }, + // Note: CF_ACCOUNT_ID should be set via `wrangler secret put CF_ACCOUNT_ID` + // Secrets to configure via `wrangler secret put`: + // - ANTHROPIC_API_KEY: Your Anthropic API key + // - CF_ACCESS_TEAM_DOMAIN: Cloudflare Access team domain + // - CF_ACCESS_AUD: Cloudflare Access application audience + // - TELEGRAM_BOT_TOKEN: (optional) Telegram bot token + // - DISCORD_BOT_TOKEN: (optional) Discord bot token + // - SLACK_BOT_TOKEN: (optional) Slack bot token + // - SLACK_APP_TOKEN: (optional) Slack app token + // - MOLTBOT_GATEWAY_TOKEN: (optional) Token to protect gateway access, if unset device pairing will be used + // - CDP_SECRET: (optional) Shared secret for /cdp endpoint authentication + // + // R2 persistent storage secrets (required for data persistence across sessions): + // - R2_ACCESS_KEY_ID: R2 access key ID (from R2 API tokens) + // - R2_SECRET_ACCESS_KEY: R2 secret access key (from R2 API tokens) + // - CF_ACCOUNT_ID: Your Cloudflare account ID (for R2 endpoint URL) +} \ No newline at end of file