Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/auth/middleware.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,6 +52,7 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) {
return async (c: Context<AppEnv>, 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();
}
Expand Down Expand Up @@ -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') {
Expand Down
12 changes: 9 additions & 3 deletions src/gateway/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,16 @@ export function buildEnvVars(env: MoltbotEnv): Record<string, string> {
} 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;
Expand Down
123 changes: 104 additions & 19 deletions src/gateway/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,70 @@ export async function findExistingMoltbotProcess(sandbox: Sandbox): Promise<Proc
return null;
}

/** Marker file to track gateway version/config */
const GATEWAY_VERSION_FILE = '/tmp/.moltbot-gateway-version';

/** Current gateway version - increment this to force restart on deploy */
const GATEWAY_VERSION = '17'; // v17: security hardening — tighten trustedProxies, SHA-256 token fingerprint

/**
* Build a fingerprint that includes the gateway version and a SHA-256 hash of the token.
* When either the version or token changes, the gateway will be restarted.
*/
async function buildConfigFingerprint(token?: string): Promise<string> {
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<boolean> {
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<void> {
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
Expand All @@ -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);
}
}
}
}
Expand All @@ -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 {
Expand All @@ -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
Expand Down
93 changes: 72 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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) {
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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)');
}
Expand Down
Loading