This commit is contained in:
Muhsinun Chowdhury 2026-01-29 18:55:06 -05:00 committed by GitHub
commit 569e07da4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 157 additions and 15 deletions

View File

@ -4,6 +4,18 @@ FROM node:22-bookworm
RUN curl -fsSL https://bun.sh/install | bash RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}" 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 RUN corepack enable
WORKDIR /app WORKDIR /app

View File

@ -1,6 +1,8 @@
services: services:
moltbot-gateway: moltbot-gateway:
image: ${CLAWDBOT_IMAGE:-moltbot:local} image: ${CLAWDBOT_IMAGE:-moltbot:local}
# Required for Docker socket access when creating sandbox containers (DinD)
user: root
environment: environment:
HOME: /home/node HOME: /home/node
TERM: xterm-256color TERM: xterm-256color
@ -8,9 +10,13 @@ services:
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY} CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY} CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE} 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: volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
- /var/run/docker.sock:/var/run/docker.sock
ports: ports:
- "${CLAWDBOT_GATEWAY_PORT:-18789}:18789" - "${CLAWDBOT_GATEWAY_PORT:-18789}:18789"
- "${CLAWDBOT_BRIDGE_PORT:-18790}:18790" - "${CLAWDBOT_BRIDGE_PORT:-18790}:18790"

View File

@ -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: Custom browser image:
```json5 ```json5

View File

@ -34,6 +34,7 @@ fi
CHROME_ARGS+=( CHROME_ARGS+=(
"--remote-debugging-address=127.0.0.1" "--remote-debugging-address=127.0.0.1"
"--remote-debugging-port=${CHROME_CDP_PORT}" "--remote-debugging-port=${CHROME_CDP_PORT}"
"--remote-allow-origins=*"
"--user-data-dir=${HOME}/.chrome" "--user-data-dir=${HOME}/.chrome"
"--no-first-run" "--no-first-run"
"--no-default-browser-check" "--no-default-browser-check"

View File

@ -1,3 +1,4 @@
import dns from "node:dns/promises";
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
import { import {
@ -11,15 +12,45 @@ import {
dockerContainerState, dockerContainerState,
execDocker, execDocker,
readDockerPort, readDockerPort,
remapPathForDinD,
} from "./docker.js"; } from "./docker.js";
import { updateBrowserRegistry } from "./registry.js"; import { updateBrowserRegistry } from "./registry.js";
import { slugifySessionKey } from "./shared.js"; import { slugifySessionKey } from "./shared.js";
import { isToolAllowed } from "./tool-policy.js"; import { isToolAllowed } from "./tool-policy.js";
import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; import type { SandboxBrowserContext, SandboxConfig } from "./types.js";
async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number }): Promise<boolean> { /**
* 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<string> {
// 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<boolean> {
const deadline = Date.now() + Math.max(0, params.timeoutMs); 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) { while (Date.now() < deadline) {
try { try {
const ctrl = new AbortController(); const ctrl = new AbortController();
@ -40,18 +71,20 @@ async function waitForSandboxCdp(params: { cdpPort: number; timeoutMs: number })
function buildSandboxBrowserResolvedConfig(params: { function buildSandboxBrowserResolvedConfig(params: {
controlPort: number; controlPort: number;
cdpHost: string;
cdpPort: number; cdpPort: number;
headless: boolean; headless: boolean;
evaluateEnabled: boolean; evaluateEnabled: boolean;
}): ResolvedBrowserConfig { }): ResolvedBrowserConfig {
const cdpHost = "127.0.0.1"; const isLoopback =
params.cdpHost === "127.0.0.1" || params.cdpHost === "localhost" || params.cdpHost === "::1";
return { return {
enabled: true, enabled: true,
evaluateEnabled: params.evaluateEnabled, evaluateEnabled: params.evaluateEnabled,
controlPort: params.controlPort, controlPort: params.controlPort,
cdpProtocol: "http", cdpProtocol: "http",
cdpHost, cdpHost: params.cdpHost,
cdpIsLoopback: true, cdpIsLoopback: isLoopback,
remoteCdpTimeoutMs: 1500, remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000, remoteCdpHandshakeTimeoutMs: 3000,
color: DEFAULT_CLAWD_BROWSER_COLOR, color: DEFAULT_CLAWD_BROWSER_COLOR,
@ -102,12 +135,15 @@ export async function ensureSandboxBrowser(params: {
params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir
? ":ro" ? ":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) { if (params.cfg.workspaceAccess !== "none" && params.workspaceDir !== params.agentWorkspaceDir) {
const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : ""; const agentMountSuffix = params.cfg.workspaceAccess === "ro" ? ":ro" : "";
const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir);
args.push( args.push(
"-v", "-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}`); args.push("-p", `127.0.0.1::${params.cfg.browser.cdpPort}`);
@ -153,6 +189,9 @@ export async function ensureSandboxBrowser(params: {
const ensureBridge = async () => { const ensureBridge = async () => {
if (bridge) return bridge; 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 const onEnsureAttachTarget = params.cfg.browser.autoStart
? async () => { ? async () => {
const state = await dockerContainerState(containerName); const state = await dockerContainerState(containerName);
@ -160,12 +199,13 @@ export async function ensureSandboxBrowser(params: {
await execDocker(["start", containerName]); await execDocker(["start", containerName]);
} }
const ok = await waitForSandboxCdp({ const ok = await waitForSandboxCdp({
cdpHost: resolvedCdpHost,
cdpPort: mappedCdp, cdpPort: mappedCdp,
timeoutMs: params.cfg.browser.autoStartTimeoutMs, timeoutMs: params.cfg.browser.autoStartTimeoutMs,
}); });
if (!ok) { if (!ok) {
throw new Error( 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({ return await startBrowserBridgeServer({
resolved: buildSandboxBrowserResolvedConfig({ resolved: buildSandboxBrowserResolvedConfig({
controlPort: 0, controlPort: 0,
cdpHost: resolvedCdpHost,
cdpPort: mappedCdp, cdpPort: mappedCdp,
headless: params.cfg.browser.headless, headless: params.cfg.browser.headless,
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
@ -203,7 +244,7 @@ export async function ensureSandboxBrowser(params: {
const noVncUrl = const noVncUrl =
mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless 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; : undefined;
return { return {

View File

@ -2,6 +2,7 @@ import type { MoltbotConfig } from "../../config/config.js";
import { resolveAgentConfig } from "../agent-scope.js"; import { resolveAgentConfig } from "../agent-scope.js";
import { import {
DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS, DEFAULT_SANDBOX_BROWSER_AUTOSTART_TIMEOUT_MS,
DEFAULT_SANDBOX_BROWSER_CDP_HOST,
DEFAULT_SANDBOX_BROWSER_CDP_PORT, DEFAULT_SANDBOX_BROWSER_CDP_PORT,
DEFAULT_SANDBOX_BROWSER_IMAGE, DEFAULT_SANDBOX_BROWSER_IMAGE,
DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, DEFAULT_SANDBOX_BROWSER_NOVNC_PORT,
@ -93,6 +94,7 @@ export function resolveSandboxBrowserConfig(params: {
agentBrowser?.containerPrefix ?? agentBrowser?.containerPrefix ??
globalBrowser?.containerPrefix ?? globalBrowser?.containerPrefix ??
DEFAULT_SANDBOX_BROWSER_PREFIX, DEFAULT_SANDBOX_BROWSER_PREFIX,
cdpHost: agentBrowser?.cdpHost ?? globalBrowser?.cdpHost ?? DEFAULT_SANDBOX_BROWSER_CDP_HOST,
cdpPort: agentBrowser?.cdpPort ?? globalBrowser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT, cdpPort: agentBrowser?.cdpPort ?? globalBrowser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT,
vncPort: agentBrowser?.vncPort ?? globalBrowser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT, vncPort: agentBrowser?.vncPort ?? globalBrowser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT,
noVncPort: noVncPort:

View File

@ -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_COMMON_IMAGE = "moltbot-sandbox-common:bookworm-slim";
export const DEFAULT_SANDBOX_BROWSER_PREFIX = "moltbot-sbx-browser-"; 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_CDP_PORT = 9222;
export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; export const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900;
export const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; export const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080;

View File

@ -1,3 +1,4 @@
import os from "node:os";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
@ -8,6 +9,41 @@ import { computeSandboxConfigHash } from "./config-hash.js";
import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js";
import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.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; const HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000;
export function execDocker(args: string[], opts?: { allowFailure?: boolean }) { export function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
@ -189,13 +225,13 @@ async function createSandboxContainer(params: {
args.push("--workdir", cfg.workdir); args.push("--workdir", cfg.workdir);
const mainMountSuffix = const mainMountSuffix =
params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; 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) { if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) {
const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""; const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : "";
args.push( const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir);
"-v", args.push("-v", `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`);
`${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`,
);
} }
args.push(cfg.image, "sleep", "infinity"); args.push(cfg.image, "sleep", "infinity");
@ -271,7 +307,10 @@ export async function ensureSandboxContainer(params: {
running && running &&
(typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS);
if (isHot) { if (isHot) {
const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey }); const hint = formatSandboxRecreateHint({
scope: params.cfg.scope,
sessionKey: scopeKey,
});
defaultRuntime.log( defaultRuntime.log(
`Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`, `Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`,
); );

View File

@ -31,6 +31,7 @@ export type SandboxBrowserConfig = {
enabled: boolean; enabled: boolean;
image: string; image: string;
containerPrefix: string; containerPrefix: string;
cdpHost: string;
cdpPort: number; cdpPort: number;
vncPort: number; vncPort: number;
noVncPort: number; noVncPort: number;

View File

@ -48,6 +48,12 @@ export type SandboxBrowserSettings = {
enabled?: boolean; enabled?: boolean;
image?: string; image?: string;
containerPrefix?: 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; cdpPort?: number;
vncPort?: number; vncPort?: number;
noVncPort?: number; noVncPort?: number;

View File

@ -124,6 +124,7 @@ export const SandboxBrowserSchema = z
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
image: z.string().optional(), image: z.string().optional(),
containerPrefix: z.string().optional(), containerPrefix: z.string().optional(),
cdpHost: z.string().optional(),
cdpPort: z.number().int().positive().optional(), cdpPort: z.number().int().positive().optional(),
vncPort: z.number().int().positive().optional(), vncPort: z.number().int().positive().optional(),
noVncPort: z.number().int().positive().optional(), noVncPort: z.number().int().positive().optional(),