diff --git a/Dockerfile b/Dockerfile index 9c6aa7036..a7a16bd70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,18 @@ FROM node:22-bookworm RUN curl -fsSL https://bun.sh/install | bash ENV PATH="/root/.bun/bin:${PATH}" +# Install Docker CLI from official Docker repo (Debian docker.io is too old for Docker Desktop) +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl && \ + install -m 0755 -d /etc/apt/keyrings && \ + curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \ + chmod a+r /etc/apt/keyrings/docker.asc && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends docker-ce-cli && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + RUN corepack enable WORKDIR /app diff --git a/docker-compose.yml b/docker-compose.yml index 8ce610d6a..7cfdcc209 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: moltbot-gateway: image: ${CLAWDBOT_IMAGE:-moltbot:local} + # Required for Docker socket access when creating sandbox containers (DinD) + user: root environment: HOME: /home/node TERM: xterm-256color @@ -8,9 +10,13 @@ services: CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} + # Docker-in-Docker: host paths for sandbox container volume mounts + CLAWDBOT_SANDBOX_HOST_CONFIG_DIR: ${CLAWDBOT_CONFIG_DIR} + CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR: ${CLAWDBOT_WORKSPACE_DIR} volumes: - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd + - /var/run/docker.sock:/var/run/docker.sock ports: - "${CLAWDBOT_GATEWAY_PORT:-18789}:18789" - "${CLAWDBOT_BRIDGE_PORT:-18790}:18790" diff --git a/docs/install/docker.md b/docs/install/docker.md index 8ca80e53b..685cfb55b 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -373,6 +373,38 @@ Use config: } ``` +#### Docker-in-Docker (gateway in container) + +If the gateway itself runs in a Docker container and you want sandbox browsers, +set `cdpHost` to `host.docker.internal` (Docker Desktop) so the gateway can +reach the browser container's CDP endpoint: + +```json5 +{ + agents: { + defaults: { + sandbox: { + browser: { + enabled: true, + cdpHost: "host.docker.internal" + } + } + } + }, + tools: { + sandbox: { + tools: { + allow: ["*"] // browser is denied by default + } + } + } +} +``` + +The gateway automatically remaps volume mount paths for Docker-in-Docker when +`CLAWDBOT_SANDBOX_HOST_CONFIG_DIR` and `CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR` +environment variables are set (already configured in `docker-compose.yml`). + Custom browser image: ```json5 diff --git a/scripts/sandbox-browser-entrypoint.sh b/scripts/sandbox-browser-entrypoint.sh index 7a1d6fdf7..660850aba 100755 --- a/scripts/sandbox-browser-entrypoint.sh +++ b/scripts/sandbox-browser-entrypoint.sh @@ -34,6 +34,7 @@ fi CHROME_ARGS+=( "--remote-debugging-address=127.0.0.1" "--remote-debugging-port=${CHROME_CDP_PORT}" + "--remote-allow-origins=*" "--user-data-dir=${HOME}/.chrome" "--no-first-run" "--no-default-browser-check" diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index e085030b5..4a6648c7f 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -1,3 +1,4 @@ +import dns from "node:dns/promises"; import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; import { @@ -11,15 +12,45 @@ import { dockerContainerState, execDocker, readDockerPort, + remapPathForDinD, } from "./docker.js"; import { updateBrowserRegistry } from "./registry.js"; import { slugifySessionKey } from "./shared.js"; import { isToolAllowed } from "./tool-policy.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; -async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }): Promise { +/** + * Resolve a hostname to an IPv4 address for CDP connections. + * Chrome's CDP HTTP endpoints reject non-IP Host headers, so we resolve + * hostnames like "host.docker.internal" to their IP addresses. + */ +async function resolveHostToIp(host: string): Promise { + // If already an IP address (v4 or v6), return as-is + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(host) || host.includes(":")) { + return host; + } + // localhost is special-cased by Chrome + if (host === "localhost") { + return host; + } + try { + const result = await dns.lookup(host, { family: 4 }); + return result.address; + } catch { + // If DNS resolution fails, return original host and let caller handle the error + return host; + } +} + +async function waitForSandboxCdp(params: { + cdpHost: string; + cdpPort: number; + timeoutMs: number; +}): Promise { const deadline = Date.now() + Math.max(0, params.timeoutMs); - const url = `http://127.0.0.1:${params.cdpPort}/json/version`; + // Resolve hostname to IP for Chrome CDP compatibility + const resolvedHost = await resolveHostToIp(params.cdpHost); + const url = `http://${resolvedHost}:${params.cdpPort}/json/version`; while (Date.now() < deadline) { try { const ctrl = new AbortController(); @@ -40,18 +71,20 @@ async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }) function buildSandboxBrowserResolvedConfig(params: { controlPort: number; + cdpHost: string; cdpPort: number; headless: boolean; evaluateEnabled: boolean; }): ResolvedBrowserConfig { - const cdpHost = "127.0.0.1"; + const isLoopback = + params.cdpHost === "127.0.0.1" || params.cdpHost === "localhost" || params.cdpHost === "::1"; return { enabled: true, evaluateEnabled: params.evaluateEnabled, controlPort: params.controlPort, cdpProtocol: "http", - cdpHost, - cdpIsLoopback: true, + cdpHost: params.cdpHost, + cdpIsLoopback: isLoopback, remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, color: DEFAULT_CLAWD_BROWSER_COLOR, @@ -102,12 +135,15 @@ export async function ensureSandboxBrowser(params: { params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; - args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`); + // Remap paths for Docker-in-Docker scenarios + const hostWorkspaceDir = remapPathForDinD(params.workspaceDir); + args.push("-v", `${hostWorkspaceDir}:${params.cfg.docker.workdir}${mainMountSuffix}`); if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) { const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""; + const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir); args.push( "-v", - `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, + `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, ); } args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`); @@ -153,6 +189,9 @@ export async function ensureSandboxBrowser(params: { const ensureBridge = async () => { if (bridge) return bridge; + // Resolve hostname to IP for Chrome CDP compatibility + const resolvedCdpHost = await resolveHostToIp(params.cfg.browser.cdpHost); + const onEnsureAttachTarget = params.cfg.browser.autoStart ? async () => { const state = await dockerContainerState(containerName); @@ -160,12 +199,13 @@ export async function ensureSandboxBrowser(params: { await execDocker(["start", containerName]); } const ok = await waitForSandboxCdp({ + cdpHost: resolvedCdpHost, cdpPort: mappedCdp, timeoutMs: params.cfg.browser.autoStartTimeoutMs, }); if (!ok) { throw new Error( - `Sandbox browser CDP did not become reachable on 127.0.0.1:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`, + `Sandbox browser CDP did not become reachable on ${resolvedCdpHost}:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`, ); } } @@ -174,6 +214,7 @@ export async function ensureSandboxBrowser(params: { return await startBrowserBridgeServer({ resolved: buildSandboxBrowserResolvedConfig({ controlPort: 0, + cdpHost: resolvedCdpHost, cdpPort: mappedCdp, headless: params.cfg.browser.headless, evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, @@ -203,7 +244,7 @@ export async function ensureSandboxBrowser(params: { const noVncUrl = mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless - ? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` + ? `http://${params.cfg.browser.cdpHost}:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` : undefined; return { diff --git a/src/agents/sandbox/config.ts b/src/agents/sandbox/config.ts index aa848c54b..5eb2976a1 100644 --- a/src/agents/sandbox/config.ts +++ b/src/agents/sandbox/config.ts @@ -2,6 +2,7 @@ import type { MoltbotConfig } from "../../config/config.js"; import { resolveAgentConfig } from "../agent-scope.js"; import { DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS, + DEFAULT_SANDBOX_BROWSER_CDP_HOST, DEFAULT_SANDBOX_BROWSER_CDP_PORT, DEFAULT_SANDBOX_BROWSER_IMAGE, DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, @@ -93,6 +94,7 @@ export function resolveSandboxBrowserConfig(params: { agentBrowser?.containerPrefix ?? globalBrowser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX, + cdpHost: agentBrowser?.cdpHost ?? globalBrowser?.cdpHost ?? DEFAULT_SANDBOX_BROWSER_CDP_HOST, cdpPort: agentBrowser?.cdpPort ?? globalBrowser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT, vncPort: agentBrowser?.vncPort ?? globalBrowser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT, noVncPort: diff --git a/src/agents/sandbox/constants.ts b/src/agents/sandbox/constants.ts index 6a844ecc7..c227ea65e 100644 --- a/src/agents/sandbox/constants.ts +++ b/src/agents/sandbox/constants.ts @@ -41,6 +41,7 @@ export const DEFAULT_SANDBOX_BROWSER_IMAGE = "moltbot-sandbox-browser:bookworm-s export const DEFAULT_SANDBOX_COMMON_IMAGE = "moltbot-sandbox-common:bookworm-slim"; export const DEFAULT_SANDBOX_BROWSER_PREFIX = "moltbot-sbx-browser-"; +export const DEFAULT_SANDBOX_BROWSER_CDP_HOST = "127.0.0.1"; export const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; export const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 21e8c67b5..21980b959 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import { spawn } from "node:child_process"; import { defaultRuntime } from "../../runtime.js"; @@ -8,6 +9,41 @@ import { computeSandboxConfigHash } from "./config-hash.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; +/** + * For Docker-in-Docker scenarios, remap container paths to host paths. + * When the gateway runs in a container and creates sandbox containers, + * the volume mount paths must be host paths, not container paths. + * + * Uses environment variables set by docker-compose: + * - CLAWDBOT_SANDBOX_HOST_CONFIG_DIR: host path for ~/.clawdbot + * - CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR: host path for ~/clawd + */ +export function remapPathForDinD(containerPath: string): string { + const hostConfigDir = process.env.CLAWDBOT_SANDBOX_HOST_CONFIG_DIR; + const hostWorkspaceDir = process.env.CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR; + + // Both must be set for DinD mode, or neither (partial config is invalid) + if (!hostConfigDir || !hostWorkspaceDir) { + return containerPath; + } + + const home = os.homedir(); + const containerConfigDir = `${home}/.clawdbot`; + const containerWorkspaceDir = `${home}/clawd`; + + // Remap config directory paths + if (hostConfigDir && containerPath.startsWith(containerConfigDir)) { + return containerPath.replace(containerConfigDir, hostConfigDir); + } + + // Remap workspace directory paths + if (hostWorkspaceDir && containerPath.startsWith(containerWorkspaceDir)) { + return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir); + } + + return containerPath; +} + const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000; export function execDocker(args: string[], opts?: { allowFailure?: boolean }) { @@ -189,13 +225,13 @@ async function createSandboxContainer(params: { args.push("--workdir", cfg.workdir); const mainMountSuffix = params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; - args.push("-v", `${workspaceDir}:${cfg.workdir}${mainMountSuffix}`); + // Remap paths for Docker-in-Docker scenarios + const hostWorkspaceDir = remapPathForDinD(workspaceDir); + args.push("-v", `${hostWorkspaceDir}:${cfg.workdir}${mainMountSuffix}`); if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) { const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""; - args.push( - "-v", - `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, - ); + const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir); + args.push("-v", `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`); } args.push(cfg.image, "sleep", "infinity"); @@ -271,7 +307,10 @@ export async function ensureSandboxContainer(params: { running && (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); if (isHot) { - const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey }); + const hint = formatSandboxRecreateHint({ + scope: params.cfg.scope, + sessionKey: scopeKey, + }); defaultRuntime.log( `Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`, ); diff --git a/src/agents/sandbox/types.ts b/src/agents/sandbox/types.ts index f27dfd715..f13922111 100644 --- a/src/agents/sandbox/types.ts +++ b/src/agents/sandbox/types.ts @@ -31,6 +31,7 @@ export type SandboxBrowserConfig = { enabled: boolean; image: string; containerPrefix: string; + cdpHost: string; cdpPort: number; vncPort: number; noVncPort: number; diff --git a/src/config/types.sandbox.ts b/src/config/types.sandbox.ts index 4f5f83810..a896c5171 100644 --- a/src/config/types.sandbox.ts +++ b/src/config/types.sandbox.ts @@ -48,6 +48,12 @@ export type SandboxBrowserSettings = { enabled?: boolean; image?: string; containerPrefix?: string; + /** + * Host to connect to for CDP (Chrome DevTools Protocol). + * Default: "127.0.0.1". + * Set to "host.docker.internal" when running gateway inside Docker. + */ + cdpHost?: string; cdpPort?: number; vncPort?: number; noVncPort?: number; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7e95c3538..18b8884e0 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -124,6 +124,7 @@ export const SandboxBrowserSchema = z enabled: z.boolean().optional(), image: z.string().optional(), containerPrefix: z.string().optional(), + cdpHost: z.string().optional(), cdpPort: z.number().int().positive().optional(), vncPort: z.number().int().positive().optional(), noVncPort: z.number().int().positive().optional(),