From 174bac87cbeb9d60a7218d93b479c6e519c1bd2d Mon Sep 17 00:00:00 2001 From: Muhsinun Chowdhury <11369216+MuhsinunC@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:42:15 -0500 Subject: [PATCH 1/7] feat(sandbox): add cdpHost config for Docker gateway deployments When the gateway runs inside a Docker container, it needs to connect to sandbox browser containers via the host network. Chrome's CDP HTTP endpoints reject non-IP Host headers, so this change: 1. Adds `cdpHost` config option (default: "127.0.0.1") 2. Adds DNS resolution helper to convert hostnames to IPs 3. Updates sandbox browser entrypoint with --remote-allow-origins=* For Docker deployments, set `cdpHost: "host.docker.internal"` and the gateway will resolve it to the host's IP for Chrome compatibility. Co-Authored-By: Claude Opus 4.5 --- scripts/sandbox-browser-entrypoint.sh | 1 + src/agents/sandbox/browser.ts | 51 ++++++++++++++++++++++---- src/agents/sandbox/config.ts | 2 + src/agents/sandbox/constants.ts | 1 + src/agents/sandbox/types.ts | 1 + src/config/types.sandbox.ts | 6 +++ src/config/zod-schema.agent-runtime.ts | 1 + 7 files changed, 56 insertions(+), 7 deletions(-) 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..92ecf1666 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 { @@ -17,9 +18,38 @@ 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 +70,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, @@ -153,6 +185,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 +195,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 +210,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 +240,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/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(), From fc0fca5108701d2af31c96ae6cf9546bb5a209d6 Mon Sep 17 00:00:00 2001 From: Muhsinun Chowdhury <11369216+MuhsinunC@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:47:22 -0500 Subject: [PATCH 2/7] chore(docker): add Docker socket mount for sandbox containers When running gateway in Docker, it needs access to the Docker socket to create and manage sandbox browser containers on the host. Changes: - Add user: root for Docker socket permissions - Mount /var/run/docker.sock into gateway container Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 8ce610d6a..9324c521d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: moltbot-gateway: image: ${CLAWDBOT_IMAGE:-moltbot:local} + user: root environment: HOME: /home/node TERM: xterm-256color @@ -11,6 +12,7 @@ services: 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" From 51decc6535ad3cb0bbc388bec97984da34d89fc0 Mon Sep 17 00:00:00 2001 From: Muhsinun Chowdhury <11369216+MuhsinunC@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:38:02 -0500 Subject: [PATCH 3/7] feat(sandbox): add Docker-in-Docker path remapping for browser sandboxes When the gateway runs inside a Docker container and creates sandbox containers, volume mount paths need to be host paths, not container paths. Changes: - Add remapPathForDinD() function to remap container paths to host paths - Add CLAWDBOT_SANDBOX_HOST_CONFIG_DIR and CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR environment variables to docker-compose.yml - Use path remapping in both sandbox container and browser sandbox creation - Add Docker CLI to gateway Dockerfile for Docker-in-Docker support The path remapping is a no-op when the environment variables are not set, so bare metal installations are unaffected. Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 12 ++++++++++ docker-compose.yml | 3 +++ src/agents/sandbox/browser.ts | 8 +++++-- src/agents/sandbox/docker.ts | 43 +++++++++++++++++++++++++++++++++-- 4 files changed, 62 insertions(+), 4 deletions(-) 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 9324c521d..1f5dd26e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ 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 diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 92ecf1666..4a6648c7f 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -12,6 +12,7 @@ import { dockerContainerState, execDocker, readDockerPort, + remapPathForDinD, } from "./docker.js"; import { updateBrowserRegistry } from "./registry.js"; import { slugifySessionKey } from "./shared.js"; @@ -134,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}`); diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 21e8c67b5..6a0a5d524 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; + + // If no host path mappings are set, we're not in Docker-in-Docker mode + 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,12 +225,15 @@ 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" : ""; + const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir); args.push( "-v", - `${params.agentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, + `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, ); } args.push(cfg.image, "sleep", "infinity"); From ca3e65eb35ee484a17cbf4d8a01f7afe91bc3a74 Mon Sep 17 00:00:00 2001 From: Muhsinun Chowdhury <11369216+MuhsinunC@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:41:49 -0500 Subject: [PATCH 4/7] docs(docker): add Docker-in-Docker browser sandbox configuration Document the cdpHost config option and Docker-in-Docker setup for running sandbox browsers when the gateway is containerized. Co-Authored-By: Claude Opus 4.5 --- docs/install/docker.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) 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 From 0a2a7fbcd11b2e996e3adfde9d569ba01b0fed98 Mon Sep 17 00:00:00 2001 From: Muhsinun Chowdhury <11369216+MuhsinunC@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:47:50 -0500 Subject: [PATCH 5/7] fix(sandbox): improve DinD detection and add security comment - Change DinD detection from AND to OR: require both env vars or neither (partial config would cause confusing mount failures) - Add comment explaining why root user is required for Docker socket access Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 1 + src/agents/sandbox/docker.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1f5dd26e4..7cfdcc209 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: moltbot-gateway: image: ${CLAWDBOT_IMAGE:-moltbot:local} + # Required for Docker socket access when creating sandbox containers (DinD) user: root environment: HOME: /home/node diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 6a0a5d524..596b6ccc1 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -22,8 +22,8 @@ export function remapPathForDinD(containerPath: string): string { const hostConfigDir = process.env.CLAWDBOT_SANDBOX_HOST_CONFIG_DIR; const hostWorkspaceDir = process.env.CLAWDBOT_SANDBOX_HOST_WORKSPACE_DIR; - // If no host path mappings are set, we're not in Docker-in-Docker mode - if (!hostConfigDir && !hostWorkspaceDir) { + // Both must be set for DinD mode, or neither (partial config is invalid) + if (!hostConfigDir || !hostWorkspaceDir) { return containerPath; } From dc41f74e7260ac856b02b2336d5d86e4d9f0ddad Mon Sep 17 00:00:00 2001 From: Muhsinun Chowdhury <11369216+MuhsinunC@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:49:13 -0500 Subject: [PATCH 6/7] style: apply oxfmt formatting Co-Authored-By: Claude Opus 4.5 --- src/agents/sandbox/browser.ts | 242 ++++++++++++-------- src/agents/sandbox/docker.ts | 411 +++++++++++++++++++--------------- 2 files changed, 369 insertions(+), 284 deletions(-) diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 4a6648c7f..827561d54 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -1,23 +1,32 @@ -import dns from "node:dns/promises"; -import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; -import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; +import dns from "node:dns/promises" +import { + startBrowserBridgeServer, + stopBrowserBridgeServer, +} from "../../browser/bridge-server.js" +import { + type ResolvedBrowserConfig, + resolveProfile, +} from "../../browser/config.js" import { DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_CLAWD_BROWSER_COLOR, -} from "../../browser/constants.js"; -import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +} from "../../browser/constants.js" +import { BROWSER_BRIDGES } from "./browser-bridges.js" +import { + DEFAULT_SANDBOX_BROWSER_IMAGE, + SANDBOX_AGENT_WORKSPACE_MOUNT, +} from "./constants.js" import { buildSandboxCreateArgs, 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"; +} 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" /** * Resolve a hostname to an IPv4 address for CDP connections. @@ -27,57 +36,59 @@ import type { SandboxBrowserContext, SandboxConfig } from "./types.js"; 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; + return host } // localhost is special-cased by Chrome if (host === "localhost") { - return host; + return host } try { - const result = await dns.lookup(host, { family: 4 }); - return result.address; + 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; + return host } } async function waitForSandboxCdp(params: { - cdpHost: string; - cdpPort: number; - timeoutMs: number; + cdpHost: string + cdpPort: number + timeoutMs: number }): Promise { - const deadline = Date.now() + Math.max(0, params.timeoutMs); + const deadline = Date.now() + Math.max(0, params.timeoutMs) // Resolve hostname to IP for Chrome CDP compatibility - const resolvedHost = await resolveHostToIp(params.cdpHost); - const url = `http://${resolvedHost}:${params.cdpPort}/json/version`; + const resolvedHost = await resolveHostToIp(params.cdpHost) + const url = `http://${resolvedHost}:${params.cdpPort}/json/version` while (Date.now() < deadline) { try { - const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 1000); + const ctrl = new AbortController() + const t = setTimeout(() => ctrl.abort(), 1000) try { - const res = await fetch(url, { signal: ctrl.signal }); - if (res.ok) return true; + const res = await fetch(url, { signal: ctrl.signal }) + if (res.ok) return true } finally { - clearTimeout(t); + clearTimeout(t) } } catch { // ignore } - await new Promise((r) => setTimeout(r, 150)); + await new Promise((r) => setTimeout(r, 150)) } - return false; + return false } function buildSandboxBrowserResolvedConfig(params: { - controlPort: number; - cdpHost: string; - cdpPort: number; - headless: boolean; - evaluateEnabled: boolean; + controlPort: number + cdpHost: string + cdpPort: number + headless: boolean + evaluateEnabled: boolean }): ResolvedBrowserConfig { const isLoopback = - params.cdpHost === "127.0.0.1" || params.cdpHost === "localhost" || params.cdpHost === "::1"; + params.cdpHost === "127.0.0.1" || + params.cdpHost === "localhost" || + params.cdpHost === "::1" return { enabled: true, evaluateEnabled: params.evaluateEnabled, @@ -96,120 +107,150 @@ function buildSandboxBrowserResolvedConfig(params: { profiles: { clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR }, }, - }; + } } async function ensureSandboxBrowserImage(image: string) { const result = await execDocker(["image", "inspect", image], { allowFailure: true, - }); - if (result.code === 0) return; + }) + if (result.code === 0) return throw new Error( `Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`, - ); + ) } export async function ensureSandboxBrowser(params: { - scopeKey: string; - workspaceDir: string; - agentWorkspaceDir: string; - cfg: SandboxConfig; - evaluateEnabled?: boolean; + scopeKey: string + workspaceDir: string + agentWorkspaceDir: string + cfg: SandboxConfig + evaluateEnabled?: boolean }): Promise { - if (!params.cfg.browser.enabled) return null; - if (!isToolAllowed(params.cfg.tools, "browser")) return null; + if (!params.cfg.browser.enabled) return null + if (!isToolAllowed(params.cfg.tools, "browser")) return null - const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); - const name = `${params.cfg.browser.containerPrefix}${slug}`; - const containerName = name.slice(0, 63); - const state = await dockerContainerState(containerName); + const slug = + params.cfg.scope === "shared" + ? "shared" + : slugifySessionKey(params.scopeKey) + const name = `${params.cfg.browser.containerPrefix}${slug}` + const containerName = name.slice(0, 63) + const state = await dockerContainerState(containerName) if (!state.exists) { - await ensureSandboxBrowserImage(params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE); + await ensureSandboxBrowserImage( + params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE, + ) const args = buildSandboxCreateArgs({ name: containerName, cfg: params.cfg.docker, scopeKey: params.scopeKey, labels: { "moltbot.sandboxBrowser": "1" }, - }); + }) const mainMountSuffix = - params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir + params.cfg.workspaceAccess === "ro" && + params.workspaceDir === params.agentWorkspaceDir ? ":ro" - : ""; + : "" // 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); + 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", `${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}`) if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) { - args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); + args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`) } - args.push("-e", `CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); - args.push("-e", `CLAWDBOT_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`); - args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); - args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`); - args.push("-e", `CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`); - args.push(params.cfg.browser.image); - await execDocker(args); - await execDocker(["start", containerName]); + args.push( + "-e", + `CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`, + ) + args.push( + "-e", + `CLAWDBOT_BROWSER_ENABLE_NOVNC=${ + params.cfg.browser.enableNoVnc ? "1" : "0" + }`, + ) + args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`) + args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`) + args.push( + "-e", + `CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`, + ) + args.push(params.cfg.browser.image) + await execDocker(args) + await execDocker(["start", containerName]) } else if (!state.running) { - await execDocker(["start", containerName]); + await execDocker(["start", containerName]) } - const mappedCdp = await readDockerPort(containerName, params.cfg.browser.cdpPort); + const mappedCdp = await readDockerPort( + containerName, + params.cfg.browser.cdpPort, + ) if (!mappedCdp) { - throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`); + throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`) } const mappedNoVnc = params.cfg.browser.enableNoVnc && !params.cfg.browser.headless ? await readDockerPort(containerName, params.cfg.browser.noVncPort) - : null; + : null - const existing = BROWSER_BRIDGES.get(params.scopeKey); - const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, "clawd") : null; + const existing = BROWSER_BRIDGES.get(params.scopeKey) + const existingProfile = existing + ? resolveProfile(existing.bridge.state.resolved, "clawd") + : null const shouldReuse = - existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; + existing && + existing.containerName === containerName && + existingProfile?.cdpPort === mappedCdp if (existing && !shouldReuse) { - await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); - BROWSER_BRIDGES.delete(params.scopeKey); + await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined) + BROWSER_BRIDGES.delete(params.scopeKey) } const bridge = (() => { - if (shouldReuse && existing) return existing.bridge; - return null; - })(); + if (shouldReuse && existing) return existing.bridge + return null + })() 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 resolvedCdpHost = await resolveHostToIp(params.cfg.browser.cdpHost) const onEnsureAttachTarget = params.cfg.browser.autoStart ? async () => { - const state = await dockerContainerState(containerName); + const state = await dockerContainerState(containerName) if (state.exists && !state.running) { - await execDocker(["start", containerName]); + 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 ${resolvedCdpHost}:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`, - ); + ) } } - : undefined; + : undefined return await startBrowserBridgeServer({ resolved: buildSandboxBrowserResolvedConfig({ @@ -217,21 +258,22 @@ export async function ensureSandboxBrowser(params: { cdpHost: resolvedCdpHost, cdpPort: mappedCdp, headless: params.cfg.browser.headless, - evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, + evaluateEnabled: + params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, }), onEnsureAttachTarget, - }); - }; + }) + } - const resolvedBridge = await ensureBridge(); + const resolvedBridge = await ensureBridge() if (!shouldReuse) { BROWSER_BRIDGES.set(params.scopeKey, { bridge: resolvedBridge, containerName, - }); + }) } - const now = Date.now(); + const now = Date.now() await updateBrowserRegistry({ containerName, sessionKey: params.scopeKey, @@ -240,16 +282,18 @@ export async function ensureSandboxBrowser(params: { image: params.cfg.browser.image, cdpPort: mappedCdp, noVncPort: mappedNoVnc ?? undefined, - }); + }) const noVncUrl = - mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless + mappedNoVnc && + params.cfg.browser.enableNoVnc && + !params.cfg.browser.headless ? `http://${params.cfg.browser.cdpHost}:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` - : undefined; + : undefined return { bridgeUrl: resolvedBridge.baseUrl, noVncUrl, containerName, - }; + } } diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 596b6ccc1..84738fede 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -1,13 +1,24 @@ -import os from "node:os"; -import { spawn } from "node:child_process"; +import os from "node:os" +import { spawn } from "node:child_process" -import { defaultRuntime } from "../../runtime.js"; -import { formatCliCommand } from "../../cli/command-format.js"; -import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; -import { readRegistry, updateRegistry } from "./registry.js"; -import { computeSandboxConfigHash } from "./config-hash.js"; -import { resolveSandboxAgentId, resolveSandboxScopeKey, slugifySessionKey } from "./shared.js"; -import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from "./types.js"; +import { defaultRuntime } from "../../runtime.js" +import { formatCliCommand } from "../../cli/command-format.js" +import { + DEFAULT_SANDBOX_IMAGE, + SANDBOX_AGENT_WORKSPACE_MOUNT, +} from "./constants.js" +import { readRegistry, updateRegistry } from "./registry.js" +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. @@ -19,305 +30,334 @@ import type { SandboxConfig, SandboxDockerConfig, SandboxWorkspaceAccess } from * - 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; + 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; + return containerPath } - const home = os.homedir(); - const containerConfigDir = `${home}/.clawdbot`; - const containerWorkspaceDir = `${home}/clawd`; + 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); + return containerPath.replace(containerConfigDir, hostConfigDir) } // Remap workspace directory paths if (hostWorkspaceDir && containerPath.startsWith(containerWorkspaceDir)) { - return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir); + return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir) } - return containerPath; + 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 }) { - return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { - const child = spawn("docker", args, { - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (chunk) => { - stdout += chunk.toString(); - }); - child.stderr?.on("data", (chunk) => { - stderr += chunk.toString(); - }); - child.on("close", (code) => { - const exitCode = code ?? 0; - if (exitCode !== 0 && !opts?.allowFailure) { - reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)); - return; - } - resolve({ stdout, stderr, code: exitCode }); - }); - }); + return new Promise<{ stdout: string stderr: string code: number }>( + (resolve, reject) => { + const child = spawn("docker", args, { + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString() + }) + child.on("close", (code) => { + const exitCode = code ?? 0 + if (exitCode !== 0 && !opts?.allowFailure) { + reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)) + return + } + resolve({ stdout, stderr, code: exitCode }) + }) + }, + ) } export async function readDockerPort(containerName: string, port: number) { const result = await execDocker(["port", containerName, `${port}/tcp`], { allowFailure: true, - }); - if (result.code !== 0) return null; - const line = result.stdout.trim().split(/\r?\n/)[0] ?? ""; - const match = line.match(/:(\d+)\s*$/); - if (!match) return null; - const mapped = Number.parseInt(match[1] ?? "", 10); - return Number.isFinite(mapped) ? mapped : null; + }) + if (result.code !== 0) return null + const line = result.stdout.trim().split(/\r?\n/)[0] ?? "" + const match = line.match(/:(\d+)\s*$/) + if (!match) return null + const mapped = Number.parseInt(match[1] ?? "", 10) + return Number.isFinite(mapped) ? mapped : null } async function dockerImageExists(image: string) { const result = await execDocker(["image", "inspect", image], { allowFailure: true, - }); - if (result.code === 0) return true; - const stderr = result.stderr.trim(); + }) + if (result.code === 0) return true + const stderr = result.stderr.trim() if (stderr.includes("No such image")) { - return false; + return false } - throw new Error(`Failed to inspect sandbox image: ${stderr}`); + throw new Error(`Failed to inspect sandbox image: ${stderr}`) } export async function ensureDockerImage(image: string) { - const exists = await dockerImageExists(image); - if (exists) return; + const exists = await dockerImageExists(image) + if (exists) return if (image === DEFAULT_SANDBOX_IMAGE) { - await execDocker(["pull", "debian:bookworm-slim"]); - await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]); - return; + await execDocker(["pull", "debian:bookworm-slim"]) + await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]) + return } - throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`); + throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`) } export async function dockerContainerState(name: string) { - const result = await execDocker(["inspect", "-f", "{{.State.Running}}", name], { - allowFailure: true, - }); - if (result.code !== 0) return { exists: false, running: false }; - return { exists: true, running: result.stdout.trim() === "true" }; + const result = await execDocker( + ["inspect", "-f", "{{.State.Running}}", name], + { + allowFailure: true, + }, + ) + if (result.code !== 0) return { exists: false, running: false } + return { exists: true, running: result.stdout.trim() === "true" } } function normalizeDockerLimit(value?: string | number) { - if (value === undefined || value === null) return undefined; + if (value === undefined || value === null) return undefined if (typeof value === "number") { - return Number.isFinite(value) ? String(value) : undefined; + return Number.isFinite(value) ? String(value) : undefined } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; + const trimmed = value.trim() + return trimmed ? trimmed : undefined } function formatUlimitValue( name: string, - value: string | number | { soft?: number; hard?: number }, + value: string | number | { soft?: number hard?: number }, ) { - if (!name.trim()) return null; + if (!name.trim()) return null if (typeof value === "number" || typeof value === "string") { - const raw = String(value).trim(); - return raw ? `${name}=${raw}` : null; + const raw = String(value).trim() + return raw ? `${name}=${raw}` : null } - const soft = typeof value.soft === "number" ? Math.max(0, value.soft) : undefined; - const hard = typeof value.hard === "number" ? Math.max(0, value.hard) : undefined; - if (soft === undefined && hard === undefined) return null; - if (soft === undefined) return `${name}=${hard}`; - if (hard === undefined) return `${name}=${soft}`; - return `${name}=${soft}:${hard}`; + const soft = + typeof value.soft === "number" ? Math.max(0, value.soft) : undefined + const hard = + typeof value.hard === "number" ? Math.max(0, value.hard) : undefined + if (soft === undefined && hard === undefined) return null + if (soft === undefined) return `${name}=${hard}` + if (hard === undefined) return `${name}=${soft}` + return `${name}=${soft}:${hard}` } export function buildSandboxCreateArgs(params: { - name: string; - cfg: SandboxDockerConfig; - scopeKey: string; - createdAtMs?: number; - labels?: Record; - configHash?: string; + name: string + cfg: SandboxDockerConfig + scopeKey: string + createdAtMs?: number + labels?: Record + configHash?: string }) { - const createdAtMs = params.createdAtMs ?? Date.now(); - const args = ["create", "--name", params.name]; - args.push("--label", "moltbot.sandbox=1"); - args.push("--label", `moltbot.sessionKey=${params.scopeKey}`); - args.push("--label", `moltbot.createdAtMs=${createdAtMs}`); + const createdAtMs = params.createdAtMs ?? Date.now() + const args = ["create", "--name", params.name] + args.push("--label", "moltbot.sandbox=1") + args.push("--label", `moltbot.sessionKey=${params.scopeKey}`) + args.push("--label", `moltbot.createdAtMs=${createdAtMs}`) if (params.configHash) { - args.push("--label", `moltbot.configHash=${params.configHash}`); + args.push("--label", `moltbot.configHash=${params.configHash}`) } for (const [key, value] of Object.entries(params.labels ?? {})) { - if (key && value) args.push("--label", `${key}=${value}`); + if (key && value) args.push("--label", `${key}=${value}`) } - if (params.cfg.readOnlyRoot) args.push("--read-only"); + if (params.cfg.readOnlyRoot) args.push("--read-only") for (const entry of params.cfg.tmpfs) { - args.push("--tmpfs", entry); + args.push("--tmpfs", entry) } - if (params.cfg.network) args.push("--network", params.cfg.network); - if (params.cfg.user) args.push("--user", params.cfg.user); + if (params.cfg.network) args.push("--network", params.cfg.network) + if (params.cfg.user) args.push("--user", params.cfg.user) for (const cap of params.cfg.capDrop) { - args.push("--cap-drop", cap); + args.push("--cap-drop", cap) } - args.push("--security-opt", "no-new-privileges"); + args.push("--security-opt", "no-new-privileges") if (params.cfg.seccompProfile) { - args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`); + args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`) } if (params.cfg.apparmorProfile) { - args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`); + args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`) } for (const entry of params.cfg.dns ?? []) { - if (entry.trim()) args.push("--dns", entry); + if (entry.trim()) args.push("--dns", entry) } for (const entry of params.cfg.extraHosts ?? []) { - if (entry.trim()) args.push("--add-host", entry); + if (entry.trim()) args.push("--add-host", entry) } if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) { - args.push("--pids-limit", String(params.cfg.pidsLimit)); + args.push("--pids-limit", String(params.cfg.pidsLimit)) } - const memory = normalizeDockerLimit(params.cfg.memory); - if (memory) args.push("--memory", memory); - const memorySwap = normalizeDockerLimit(params.cfg.memorySwap); - if (memorySwap) args.push("--memory-swap", memorySwap); + const memory = normalizeDockerLimit(params.cfg.memory) + if (memory) args.push("--memory", memory) + const memorySwap = normalizeDockerLimit(params.cfg.memorySwap) + if (memorySwap) args.push("--memory-swap", memorySwap) if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) { - args.push("--cpus", String(params.cfg.cpus)); + args.push("--cpus", String(params.cfg.cpus)) } - for (const [name, value] of Object.entries(params.cfg.ulimits ?? {}) as Array< - [string, string | number | { soft?: number; hard?: number }] - >) { - const formatted = formatUlimitValue(name, value); - if (formatted) args.push("--ulimit", formatted); + for (const [name, value] of Object.entries( + params.cfg.ulimits ?? {}, + ) as Array<[string, string | number | { soft?: number hard?: number }]>) { + const formatted = formatUlimitValue(name, value) + if (formatted) args.push("--ulimit", formatted) } if (params.cfg.binds?.length) { for (const bind of params.cfg.binds) { - args.push("-v", bind); + args.push("-v", bind) } } - return args; + return args } async function createSandboxContainer(params: { - name: string; - cfg: SandboxDockerConfig; - workspaceDir: string; - workspaceAccess: SandboxWorkspaceAccess; - agentWorkspaceDir: string; - scopeKey: string; - configHash?: string; + name: string + cfg: SandboxDockerConfig + workspaceDir: string + workspaceAccess: SandboxWorkspaceAccess + agentWorkspaceDir: string + scopeKey: string + configHash?: string }) { - const { name, cfg, workspaceDir, scopeKey } = params; - await ensureDockerImage(cfg.image); + const { name, cfg, workspaceDir, scopeKey } = params + await ensureDockerImage(cfg.image) const args = buildSandboxCreateArgs({ name, cfg, scopeKey, configHash: params.configHash, - }); - args.push("--workdir", cfg.workdir); + }) + args.push("--workdir", cfg.workdir) const mainMountSuffix = - params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; + params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir + ? ":ro" + : "" // 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" : ""; - const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir); + const hostWorkspaceDir = remapPathForDinD(workspaceDir) + args.push("-v", `${hostWorkspaceDir}:${cfg.workdir}${mainMountSuffix}`) + if ( + params.workspaceAccess !== "none" && + workspaceDir !== params.agentWorkspaceDir + ) { + const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : "" + const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir) args.push( "-v", `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, - ); + ) } - args.push(cfg.image, "sleep", "infinity"); + args.push(cfg.image, "sleep", "infinity") - await execDocker(args); - await execDocker(["start", name]); + await execDocker(args) + await execDocker(["start", name]) if (cfg.setupCommand?.trim()) { - await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]); + await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]) } } -async function readContainerConfigHash(containerName: string): Promise { +async function readContainerConfigHash( + containerName: string, +): Promise { const result = await execDocker( - ["inspect", "-f", '{{ index .Config.Labels "moltbot.configHash" }}', containerName], + [ + "inspect", + "-f", + '{{ index .Config.Labels "moltbot.configHash" }}', + containerName, + ], { allowFailure: true }, - ); - if (result.code !== 0) return null; - const raw = result.stdout.trim(); - if (!raw || raw === "") return null; - return raw; + ) + if (result.code !== 0) return null + const raw = result.stdout.trim() + if (!raw || raw === "") return null + return raw } -function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) { +function formatSandboxRecreateHint(params: { + scope: SandboxConfig["scope"] + sessionKey: string +}) { if (params.scope === "session") { - return formatCliCommand(`moltbot sandbox recreate --session ${params.sessionKey}`); + return formatCliCommand( + `moltbot sandbox recreate --session ${params.sessionKey}`, + ) } if (params.scope === "agent") { - const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main"; - return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`); + const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main" + return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`) } - return formatCliCommand("moltbot sandbox recreate --all"); + return formatCliCommand("moltbot sandbox recreate --all") } export async function ensureSandboxContainer(params: { - sessionKey: string; - workspaceDir: string; - agentWorkspaceDir: string; - cfg: SandboxConfig; + sessionKey: string + workspaceDir: string + agentWorkspaceDir: string + cfg: SandboxConfig }) { - const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); - const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); - const name = `${params.cfg.docker.containerPrefix}${slug}`; - const containerName = name.slice(0, 63); + const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey) + const slug = + params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey) + const name = `${params.cfg.docker.containerPrefix}${slug}` + const containerName = name.slice(0, 63) const expectedHash = computeSandboxConfigHash({ docker: params.cfg.docker, workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, - }); - const now = Date.now(); - const state = await dockerContainerState(containerName); - let hasContainer = state.exists; - let running = state.running; - let currentHash: string | null = null; - let hashMismatch = false; - let registryEntry: - | { - lastUsedAtMs: number; - configHash?: string; - } - | undefined; + }) + const now = Date.now() + const state = await dockerContainerState(containerName) + let hasContainer = state.exists + let running = state.running + let currentHash: string | null = null + let hashMismatch = false + let registryEntry: { + lastUsedAtMs: number + configHash?: string + } | undefined if (hasContainer) { - const registry = await readRegistry(); - registryEntry = registry.entries.find((entry) => entry.containerName === containerName); - currentHash = await readContainerConfigHash(containerName); + const registry = await readRegistry() + registryEntry = registry.entries.find( + (entry) => entry.containerName === containerName, + ) + currentHash = await readContainerConfigHash(containerName) if (!currentHash) { - currentHash = registryEntry?.configHash ?? null; + currentHash = registryEntry?.configHash ?? null } - hashMismatch = !currentHash || currentHash !== expectedHash; + hashMismatch = !currentHash || currentHash !== expectedHash if (hashMismatch) { - const lastUsedAtMs = registryEntry?.lastUsedAtMs; + const lastUsedAtMs = registryEntry?.lastUsedAtMs const isHot = running && - (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); + (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}`, - ); + ) } else { - await execDocker(["rm", "-f", containerName], { allowFailure: true }); - hasContainer = false; - running = false; + await execDocker(["rm", "-f", containerName], { allowFailure: true }) + hasContainer = false + running = false } } } @@ -330,9 +370,9 @@ export async function ensureSandboxContainer(params: { agentWorkspaceDir: params.agentWorkspaceDir, scopeKey, configHash: expectedHash, - }); + }) } else if (!running) { - await execDocker(["start", containerName]); + await execDocker(["start", containerName]) } await updateRegistry({ containerName, @@ -340,7 +380,8 @@ export async function ensureSandboxContainer(params: { createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, - configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, - }); - return containerName; + configHash: + hashMismatch && running ? (currentHash ?? undefined) : expectedHash, + }) + return containerName } From fa8f88ed869678e8864489491aab43be76d88ed9 Mon Sep 17 00:00:00 2001 From: Muhsinun Chowdhury <11369216+MuhsinunC@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:54:57 -0500 Subject: [PATCH 7/7] style(sandbox): fix formatting in docker and browser modules Run oxfmt to fix formatting issues caught by CI. Co-Authored-By: Claude Opus 4.5 --- src/agents/sandbox/browser.ts | 242 ++++++++------------ src/agents/sandbox/docker.ts | 411 +++++++++++++++------------------- 2 files changed, 284 insertions(+), 369 deletions(-) diff --git a/src/agents/sandbox/browser.ts b/src/agents/sandbox/browser.ts index 827561d54..4a6648c7f 100644 --- a/src/agents/sandbox/browser.ts +++ b/src/agents/sandbox/browser.ts @@ -1,32 +1,23 @@ -import dns from "node:dns/promises" -import { - startBrowserBridgeServer, - stopBrowserBridgeServer, -} from "../../browser/bridge-server.js" -import { - type ResolvedBrowserConfig, - resolveProfile, -} from "../../browser/config.js" +import dns from "node:dns/promises"; +import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js"; +import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js"; import { DEFAULT_BROWSER_EVALUATE_ENABLED, DEFAULT_CLAWD_BROWSER_COLOR, -} from "../../browser/constants.js" -import { BROWSER_BRIDGES } from "./browser-bridges.js" -import { - DEFAULT_SANDBOX_BROWSER_IMAGE, - SANDBOX_AGENT_WORKSPACE_MOUNT, -} from "./constants.js" +} from "../../browser/constants.js"; +import { BROWSER_BRIDGES } from "./browser-bridges.js"; +import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; import { buildSandboxCreateArgs, 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" +} 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"; /** * Resolve a hostname to an IPv4 address for CDP connections. @@ -36,59 +27,57 @@ import type { SandboxBrowserContext, SandboxConfig } from "./types.js" 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 + return host; } // localhost is special-cased by Chrome if (host === "localhost") { - return host + return host; } try { - const result = await dns.lookup(host, { family: 4 }) - return result.address + 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 + return host; } } async function waitForSandboxCdp(params: { - cdpHost: string - cdpPort: number - timeoutMs: number + cdpHost: string; + cdpPort: number; + timeoutMs: number; }): Promise { - const deadline = Date.now() + Math.max(0, params.timeoutMs) + const deadline = Date.now() + Math.max(0, params.timeoutMs); // Resolve hostname to IP for Chrome CDP compatibility - const resolvedHost = await resolveHostToIp(params.cdpHost) - const url = `http://${resolvedHost}:${params.cdpPort}/json/version` + const resolvedHost = await resolveHostToIp(params.cdpHost); + const url = `http://${resolvedHost}:${params.cdpPort}/json/version`; while (Date.now() < deadline) { try { - const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 1000) + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 1000); try { - const res = await fetch(url, { signal: ctrl.signal }) - if (res.ok) return true + const res = await fetch(url, { signal: ctrl.signal }); + if (res.ok) return true; } finally { - clearTimeout(t) + clearTimeout(t); } } catch { // ignore } - await new Promise((r) => setTimeout(r, 150)) + await new Promise((r) => setTimeout(r, 150)); } - return false + return false; } function buildSandboxBrowserResolvedConfig(params: { - controlPort: number - cdpHost: string - cdpPort: number - headless: boolean - evaluateEnabled: boolean + controlPort: number; + cdpHost: string; + cdpPort: number; + headless: boolean; + evaluateEnabled: boolean; }): ResolvedBrowserConfig { const isLoopback = - params.cdpHost === "127.0.0.1" || - params.cdpHost === "localhost" || - params.cdpHost === "::1" + params.cdpHost === "127.0.0.1" || params.cdpHost === "localhost" || params.cdpHost === "::1"; return { enabled: true, evaluateEnabled: params.evaluateEnabled, @@ -107,150 +96,120 @@ function buildSandboxBrowserResolvedConfig(params: { profiles: { clawd: { cdpPort: params.cdpPort, color: DEFAULT_CLAWD_BROWSER_COLOR }, }, - } + }; } async function ensureSandboxBrowserImage(image: string) { const result = await execDocker(["image", "inspect", image], { allowFailure: true, - }) - if (result.code === 0) return + }); + if (result.code === 0) return; throw new Error( `Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`, - ) + ); } export async function ensureSandboxBrowser(params: { - scopeKey: string - workspaceDir: string - agentWorkspaceDir: string - cfg: SandboxConfig - evaluateEnabled?: boolean + scopeKey: string; + workspaceDir: string; + agentWorkspaceDir: string; + cfg: SandboxConfig; + evaluateEnabled?: boolean; }): Promise { - if (!params.cfg.browser.enabled) return null - if (!isToolAllowed(params.cfg.tools, "browser")) return null + if (!params.cfg.browser.enabled) return null; + if (!isToolAllowed(params.cfg.tools, "browser")) return null; - const slug = - params.cfg.scope === "shared" - ? "shared" - : slugifySessionKey(params.scopeKey) - const name = `${params.cfg.browser.containerPrefix}${slug}` - const containerName = name.slice(0, 63) - const state = await dockerContainerState(containerName) + const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(params.scopeKey); + const name = `${params.cfg.browser.containerPrefix}${slug}`; + const containerName = name.slice(0, 63); + const state = await dockerContainerState(containerName); if (!state.exists) { - await ensureSandboxBrowserImage( - params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE, - ) + await ensureSandboxBrowserImage(params.cfg.browser.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE); const args = buildSandboxCreateArgs({ name: containerName, cfg: params.cfg.docker, scopeKey: params.scopeKey, labels: { "moltbot.sandboxBrowser": "1" }, - }) + }); const mainMountSuffix = - params.cfg.workspaceAccess === "ro" && - params.workspaceDir === params.agentWorkspaceDir + params.cfg.workspaceAccess === "ro" && params.workspaceDir === params.agentWorkspaceDir ? ":ro" - : "" + : ""; // 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) + 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", `${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}`); if (params.cfg.browser.enableNoVnc && !params.cfg.browser.headless) { - args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`) + args.push("-p", `127.0.0.1::${params.cfg.browser.noVncPort}`); } - args.push( - "-e", - `CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`, - ) - args.push( - "-e", - `CLAWDBOT_BROWSER_ENABLE_NOVNC=${ - params.cfg.browser.enableNoVnc ? "1" : "0" - }`, - ) - args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`) - args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`) - args.push( - "-e", - `CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`, - ) - args.push(params.cfg.browser.image) - await execDocker(args) - await execDocker(["start", containerName]) + args.push("-e", `CLAWDBOT_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`); + args.push("-e", `CLAWDBOT_BROWSER_ENABLE_NOVNC=${params.cfg.browser.enableNoVnc ? "1" : "0"}`); + args.push("-e", `CLAWDBOT_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`); + args.push("-e", `CLAWDBOT_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`); + args.push("-e", `CLAWDBOT_BROWSER_NOVNC_PORT=${params.cfg.browser.noVncPort}`); + args.push(params.cfg.browser.image); + await execDocker(args); + await execDocker(["start", containerName]); } else if (!state.running) { - await execDocker(["start", containerName]) + await execDocker(["start", containerName]); } - const mappedCdp = await readDockerPort( - containerName, - params.cfg.browser.cdpPort, - ) + const mappedCdp = await readDockerPort(containerName, params.cfg.browser.cdpPort); if (!mappedCdp) { - throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`) + throw new Error(`Failed to resolve CDP port mapping for ${containerName}.`); } const mappedNoVnc = params.cfg.browser.enableNoVnc && !params.cfg.browser.headless ? await readDockerPort(containerName, params.cfg.browser.noVncPort) - : null + : null; - const existing = BROWSER_BRIDGES.get(params.scopeKey) - const existingProfile = existing - ? resolveProfile(existing.bridge.state.resolved, "clawd") - : null + const existing = BROWSER_BRIDGES.get(params.scopeKey); + const existingProfile = existing ? resolveProfile(existing.bridge.state.resolved, "clawd") : null; const shouldReuse = - existing && - existing.containerName === containerName && - existingProfile?.cdpPort === mappedCdp + existing && existing.containerName === containerName && existingProfile?.cdpPort === mappedCdp; if (existing && !shouldReuse) { - await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined) - BROWSER_BRIDGES.delete(params.scopeKey) + await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); + BROWSER_BRIDGES.delete(params.scopeKey); } const bridge = (() => { - if (shouldReuse && existing) return existing.bridge - return null - })() + if (shouldReuse && existing) return existing.bridge; + return null; + })(); 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 resolvedCdpHost = await resolveHostToIp(params.cfg.browser.cdpHost); const onEnsureAttachTarget = params.cfg.browser.autoStart ? async () => { - const state = await dockerContainerState(containerName) + const state = await dockerContainerState(containerName); if (state.exists && !state.running) { - await execDocker(["start", containerName]) + 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 ${resolvedCdpHost}:${mappedCdp} within ${params.cfg.browser.autoStartTimeoutMs}ms.`, - ) + ); } } - : undefined + : undefined; return await startBrowserBridgeServer({ resolved: buildSandboxBrowserResolvedConfig({ @@ -258,22 +217,21 @@ export async function ensureSandboxBrowser(params: { cdpHost: resolvedCdpHost, cdpPort: mappedCdp, headless: params.cfg.browser.headless, - evaluateEnabled: - params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, + evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED, }), onEnsureAttachTarget, - }) - } + }); + }; - const resolvedBridge = await ensureBridge() + const resolvedBridge = await ensureBridge(); if (!shouldReuse) { BROWSER_BRIDGES.set(params.scopeKey, { bridge: resolvedBridge, containerName, - }) + }); } - const now = Date.now() + const now = Date.now(); await updateBrowserRegistry({ containerName, sessionKey: params.scopeKey, @@ -282,18 +240,16 @@ export async function ensureSandboxBrowser(params: { image: params.cfg.browser.image, cdpPort: mappedCdp, noVncPort: mappedNoVnc ?? undefined, - }) + }); const noVncUrl = - mappedNoVnc && - params.cfg.browser.enableNoVnc && - !params.cfg.browser.headless + mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless ? `http://${params.cfg.browser.cdpHost}:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` - : undefined + : undefined; return { bridgeUrl: resolvedBridge.baseUrl, noVncUrl, containerName, - } + }; } diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 84738fede..21980b959 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -1,24 +1,13 @@ -import os from "node:os" -import { spawn } from "node:child_process" +import os from "node:os"; +import { spawn } from "node:child_process"; -import { defaultRuntime } from "../../runtime.js" -import { formatCliCommand } from "../../cli/command-format.js" -import { - DEFAULT_SANDBOX_IMAGE, - SANDBOX_AGENT_WORKSPACE_MOUNT, -} from "./constants.js" -import { readRegistry, updateRegistry } from "./registry.js" -import { computeSandboxConfigHash } from "./config-hash.js" -import { - resolveSandboxAgentId, - resolveSandboxScopeKey, - slugifySessionKey, -} from "./shared.js" -import type { - SandboxConfig, - SandboxDockerConfig, - SandboxWorkspaceAccess, -} from "./types.js" +import { defaultRuntime } from "../../runtime.js"; +import { formatCliCommand } from "../../cli/command-format.js"; +import { DEFAULT_SANDBOX_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js"; +import { readRegistry, updateRegistry } from "./registry.js"; +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. @@ -30,334 +19,305 @@ import type { * - 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 + 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 + return containerPath; } - const home = os.homedir() - const containerConfigDir = `${home}/.clawdbot` - const containerWorkspaceDir = `${home}/clawd` + 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) + return containerPath.replace(containerConfigDir, hostConfigDir); } // Remap workspace directory paths if (hostWorkspaceDir && containerPath.startsWith(containerWorkspaceDir)) { - return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir) + return containerPath.replace(containerWorkspaceDir, hostWorkspaceDir); } - return containerPath + 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 }) { - return new Promise<{ stdout: string stderr: string code: number }>( - (resolve, reject) => { - const child = spawn("docker", args, { - stdio: ["ignore", "pipe", "pipe"], - }) - let stdout = "" - let stderr = "" - child.stdout?.on("data", (chunk) => { - stdout += chunk.toString() - }) - child.stderr?.on("data", (chunk) => { - stderr += chunk.toString() - }) - child.on("close", (code) => { - const exitCode = code ?? 0 - if (exitCode !== 0 && !opts?.allowFailure) { - reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)) - return - } - resolve({ stdout, stderr, code: exitCode }) - }) - }, - ) + return new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => { + const child = spawn("docker", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("close", (code) => { + const exitCode = code ?? 0; + if (exitCode !== 0 && !opts?.allowFailure) { + reject(new Error(stderr.trim() || `docker ${args.join(" ")} failed`)); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + }); } export async function readDockerPort(containerName: string, port: number) { const result = await execDocker(["port", containerName, `${port}/tcp`], { allowFailure: true, - }) - if (result.code !== 0) return null - const line = result.stdout.trim().split(/\r?\n/)[0] ?? "" - const match = line.match(/:(\d+)\s*$/) - if (!match) return null - const mapped = Number.parseInt(match[1] ?? "", 10) - return Number.isFinite(mapped) ? mapped : null + }); + if (result.code !== 0) return null; + const line = result.stdout.trim().split(/\r?\n/)[0] ?? ""; + const match = line.match(/:(\d+)\s*$/); + if (!match) return null; + const mapped = Number.parseInt(match[1] ?? "", 10); + return Number.isFinite(mapped) ? mapped : null; } async function dockerImageExists(image: string) { const result = await execDocker(["image", "inspect", image], { allowFailure: true, - }) - if (result.code === 0) return true - const stderr = result.stderr.trim() + }); + if (result.code === 0) return true; + const stderr = result.stderr.trim(); if (stderr.includes("No such image")) { - return false + return false; } - throw new Error(`Failed to inspect sandbox image: ${stderr}`) + throw new Error(`Failed to inspect sandbox image: ${stderr}`); } export async function ensureDockerImage(image: string) { - const exists = await dockerImageExists(image) - if (exists) return + const exists = await dockerImageExists(image); + if (exists) return; if (image === DEFAULT_SANDBOX_IMAGE) { - await execDocker(["pull", "debian:bookworm-slim"]) - await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]) - return + await execDocker(["pull", "debian:bookworm-slim"]); + await execDocker(["tag", "debian:bookworm-slim", DEFAULT_SANDBOX_IMAGE]); + return; } - throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`) + throw new Error(`Sandbox image not found: ${image}. Build or pull it first.`); } export async function dockerContainerState(name: string) { - const result = await execDocker( - ["inspect", "-f", "{{.State.Running}}", name], - { - allowFailure: true, - }, - ) - if (result.code !== 0) return { exists: false, running: false } - return { exists: true, running: result.stdout.trim() === "true" } + const result = await execDocker(["inspect", "-f", "{{.State.Running}}", name], { + allowFailure: true, + }); + if (result.code !== 0) return { exists: false, running: false }; + return { exists: true, running: result.stdout.trim() === "true" }; } function normalizeDockerLimit(value?: string | number) { - if (value === undefined || value === null) return undefined + if (value === undefined || value === null) return undefined; if (typeof value === "number") { - return Number.isFinite(value) ? String(value) : undefined + return Number.isFinite(value) ? String(value) : undefined; } - const trimmed = value.trim() - return trimmed ? trimmed : undefined + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; } function formatUlimitValue( name: string, - value: string | number | { soft?: number hard?: number }, + value: string | number | { soft?: number; hard?: number }, ) { - if (!name.trim()) return null + if (!name.trim()) return null; if (typeof value === "number" || typeof value === "string") { - const raw = String(value).trim() - return raw ? `${name}=${raw}` : null + const raw = String(value).trim(); + return raw ? `${name}=${raw}` : null; } - const soft = - typeof value.soft === "number" ? Math.max(0, value.soft) : undefined - const hard = - typeof value.hard === "number" ? Math.max(0, value.hard) : undefined - if (soft === undefined && hard === undefined) return null - if (soft === undefined) return `${name}=${hard}` - if (hard === undefined) return `${name}=${soft}` - return `${name}=${soft}:${hard}` + const soft = typeof value.soft === "number" ? Math.max(0, value.soft) : undefined; + const hard = typeof value.hard === "number" ? Math.max(0, value.hard) : undefined; + if (soft === undefined && hard === undefined) return null; + if (soft === undefined) return `${name}=${hard}`; + if (hard === undefined) return `${name}=${soft}`; + return `${name}=${soft}:${hard}`; } export function buildSandboxCreateArgs(params: { - name: string - cfg: SandboxDockerConfig - scopeKey: string - createdAtMs?: number - labels?: Record - configHash?: string + name: string; + cfg: SandboxDockerConfig; + scopeKey: string; + createdAtMs?: number; + labels?: Record; + configHash?: string; }) { - const createdAtMs = params.createdAtMs ?? Date.now() - const args = ["create", "--name", params.name] - args.push("--label", "moltbot.sandbox=1") - args.push("--label", `moltbot.sessionKey=${params.scopeKey}`) - args.push("--label", `moltbot.createdAtMs=${createdAtMs}`) + const createdAtMs = params.createdAtMs ?? Date.now(); + const args = ["create", "--name", params.name]; + args.push("--label", "moltbot.sandbox=1"); + args.push("--label", `moltbot.sessionKey=${params.scopeKey}`); + args.push("--label", `moltbot.createdAtMs=${createdAtMs}`); if (params.configHash) { - args.push("--label", `moltbot.configHash=${params.configHash}`) + args.push("--label", `moltbot.configHash=${params.configHash}`); } for (const [key, value] of Object.entries(params.labels ?? {})) { - if (key && value) args.push("--label", `${key}=${value}`) + if (key && value) args.push("--label", `${key}=${value}`); } - if (params.cfg.readOnlyRoot) args.push("--read-only") + if (params.cfg.readOnlyRoot) args.push("--read-only"); for (const entry of params.cfg.tmpfs) { - args.push("--tmpfs", entry) + args.push("--tmpfs", entry); } - if (params.cfg.network) args.push("--network", params.cfg.network) - if (params.cfg.user) args.push("--user", params.cfg.user) + if (params.cfg.network) args.push("--network", params.cfg.network); + if (params.cfg.user) args.push("--user", params.cfg.user); for (const cap of params.cfg.capDrop) { - args.push("--cap-drop", cap) + args.push("--cap-drop", cap); } - args.push("--security-opt", "no-new-privileges") + args.push("--security-opt", "no-new-privileges"); if (params.cfg.seccompProfile) { - args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`) + args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`); } if (params.cfg.apparmorProfile) { - args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`) + args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`); } for (const entry of params.cfg.dns ?? []) { - if (entry.trim()) args.push("--dns", entry) + if (entry.trim()) args.push("--dns", entry); } for (const entry of params.cfg.extraHosts ?? []) { - if (entry.trim()) args.push("--add-host", entry) + if (entry.trim()) args.push("--add-host", entry); } if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) { - args.push("--pids-limit", String(params.cfg.pidsLimit)) + args.push("--pids-limit", String(params.cfg.pidsLimit)); } - const memory = normalizeDockerLimit(params.cfg.memory) - if (memory) args.push("--memory", memory) - const memorySwap = normalizeDockerLimit(params.cfg.memorySwap) - if (memorySwap) args.push("--memory-swap", memorySwap) + const memory = normalizeDockerLimit(params.cfg.memory); + if (memory) args.push("--memory", memory); + const memorySwap = normalizeDockerLimit(params.cfg.memorySwap); + if (memorySwap) args.push("--memory-swap", memorySwap); if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) { - args.push("--cpus", String(params.cfg.cpus)) + args.push("--cpus", String(params.cfg.cpus)); } - for (const [name, value] of Object.entries( - params.cfg.ulimits ?? {}, - ) as Array<[string, string | number | { soft?: number hard?: number }]>) { - const formatted = formatUlimitValue(name, value) - if (formatted) args.push("--ulimit", formatted) + for (const [name, value] of Object.entries(params.cfg.ulimits ?? {}) as Array< + [string, string | number | { soft?: number; hard?: number }] + >) { + const formatted = formatUlimitValue(name, value); + if (formatted) args.push("--ulimit", formatted); } if (params.cfg.binds?.length) { for (const bind of params.cfg.binds) { - args.push("-v", bind) + args.push("-v", bind); } } - return args + return args; } async function createSandboxContainer(params: { - name: string - cfg: SandboxDockerConfig - workspaceDir: string - workspaceAccess: SandboxWorkspaceAccess - agentWorkspaceDir: string - scopeKey: string - configHash?: string + name: string; + cfg: SandboxDockerConfig; + workspaceDir: string; + workspaceAccess: SandboxWorkspaceAccess; + agentWorkspaceDir: string; + scopeKey: string; + configHash?: string; }) { - const { name, cfg, workspaceDir, scopeKey } = params - await ensureDockerImage(cfg.image) + const { name, cfg, workspaceDir, scopeKey } = params; + await ensureDockerImage(cfg.image); const args = buildSandboxCreateArgs({ name, cfg, scopeKey, configHash: params.configHash, - }) - args.push("--workdir", cfg.workdir) + }); + args.push("--workdir", cfg.workdir); const mainMountSuffix = - params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir - ? ":ro" - : "" + params.workspaceAccess === "ro" && workspaceDir === params.agentWorkspaceDir ? ":ro" : ""; // 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" : "" - const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir) - args.push( - "-v", - `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`, - ) + const hostWorkspaceDir = remapPathForDinD(workspaceDir); + args.push("-v", `${hostWorkspaceDir}:${cfg.workdir}${mainMountSuffix}`); + if (params.workspaceAccess !== "none" && workspaceDir !== params.agentWorkspaceDir) { + const agentMountSuffix = params.workspaceAccess === "ro" ? ":ro" : ""; + const hostAgentWorkspaceDir = remapPathForDinD(params.agentWorkspaceDir); + args.push("-v", `${hostAgentWorkspaceDir}:${SANDBOX_AGENT_WORKSPACE_MOUNT}${agentMountSuffix}`); } - args.push(cfg.image, "sleep", "infinity") + args.push(cfg.image, "sleep", "infinity"); - await execDocker(args) - await execDocker(["start", name]) + await execDocker(args); + await execDocker(["start", name]); if (cfg.setupCommand?.trim()) { - await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]) + await execDocker(["exec", "-i", name, "sh", "-lc", cfg.setupCommand]); } } -async function readContainerConfigHash( - containerName: string, -): Promise { +async function readContainerConfigHash(containerName: string): Promise { const result = await execDocker( - [ - "inspect", - "-f", - '{{ index .Config.Labels "moltbot.configHash" }}', - containerName, - ], + ["inspect", "-f", '{{ index .Config.Labels "moltbot.configHash" }}', containerName], { allowFailure: true }, - ) - if (result.code !== 0) return null - const raw = result.stdout.trim() - if (!raw || raw === "") return null - return raw + ); + if (result.code !== 0) return null; + const raw = result.stdout.trim(); + if (!raw || raw === "") return null; + return raw; } -function formatSandboxRecreateHint(params: { - scope: SandboxConfig["scope"] - sessionKey: string -}) { +function formatSandboxRecreateHint(params: { scope: SandboxConfig["scope"]; sessionKey: string }) { if (params.scope === "session") { - return formatCliCommand( - `moltbot sandbox recreate --session ${params.sessionKey}`, - ) + return formatCliCommand(`moltbot sandbox recreate --session ${params.sessionKey}`); } if (params.scope === "agent") { - const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main" - return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`) + const agentId = resolveSandboxAgentId(params.sessionKey) ?? "main"; + return formatCliCommand(`moltbot sandbox recreate --agent ${agentId}`); } - return formatCliCommand("moltbot sandbox recreate --all") + return formatCliCommand("moltbot sandbox recreate --all"); } export async function ensureSandboxContainer(params: { - sessionKey: string - workspaceDir: string - agentWorkspaceDir: string - cfg: SandboxConfig + sessionKey: string; + workspaceDir: string; + agentWorkspaceDir: string; + cfg: SandboxConfig; }) { - const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey) - const slug = - params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey) - const name = `${params.cfg.docker.containerPrefix}${slug}` - const containerName = name.slice(0, 63) + const scopeKey = resolveSandboxScopeKey(params.cfg.scope, params.sessionKey); + const slug = params.cfg.scope === "shared" ? "shared" : slugifySessionKey(scopeKey); + const name = `${params.cfg.docker.containerPrefix}${slug}`; + const containerName = name.slice(0, 63); const expectedHash = computeSandboxConfigHash({ docker: params.cfg.docker, workspaceAccess: params.cfg.workspaceAccess, workspaceDir: params.workspaceDir, agentWorkspaceDir: params.agentWorkspaceDir, - }) - const now = Date.now() - const state = await dockerContainerState(containerName) - let hasContainer = state.exists - let running = state.running - let currentHash: string | null = null - let hashMismatch = false - let registryEntry: { - lastUsedAtMs: number - configHash?: string - } | undefined + }); + const now = Date.now(); + const state = await dockerContainerState(containerName); + let hasContainer = state.exists; + let running = state.running; + let currentHash: string | null = null; + let hashMismatch = false; + let registryEntry: + | { + lastUsedAtMs: number; + configHash?: string; + } + | undefined; if (hasContainer) { - const registry = await readRegistry() - registryEntry = registry.entries.find( - (entry) => entry.containerName === containerName, - ) - currentHash = await readContainerConfigHash(containerName) + const registry = await readRegistry(); + registryEntry = registry.entries.find((entry) => entry.containerName === containerName); + currentHash = await readContainerConfigHash(containerName); if (!currentHash) { - currentHash = registryEntry?.configHash ?? null + currentHash = registryEntry?.configHash ?? null; } - hashMismatch = !currentHash || currentHash !== expectedHash + hashMismatch = !currentHash || currentHash !== expectedHash; if (hashMismatch) { - const lastUsedAtMs = registryEntry?.lastUsedAtMs + const lastUsedAtMs = registryEntry?.lastUsedAtMs; const isHot = running && - (typeof lastUsedAtMs !== "number" || - now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS) + (typeof lastUsedAtMs !== "number" || now - lastUsedAtMs < HOT_CONTAINER_WINDOW_MS); if (isHot) { const hint = formatSandboxRecreateHint({ scope: params.cfg.scope, sessionKey: scopeKey, - }) + }); defaultRuntime.log( `Sandbox config changed for ${containerName} (recently used). Recreate to apply: ${hint}`, - ) + ); } else { - await execDocker(["rm", "-f", containerName], { allowFailure: true }) - hasContainer = false - running = false + await execDocker(["rm", "-f", containerName], { allowFailure: true }); + hasContainer = false; + running = false; } } } @@ -370,9 +330,9 @@ export async function ensureSandboxContainer(params: { agentWorkspaceDir: params.agentWorkspaceDir, scopeKey, configHash: expectedHash, - }) + }); } else if (!running) { - await execDocker(["start", containerName]) + await execDocker(["start", containerName]); } await updateRegistry({ containerName, @@ -380,8 +340,7 @@ export async function ensureSandboxContainer(params: { createdAtMs: now, lastUsedAtMs: now, image: params.cfg.docker.image, - configHash: - hashMismatch && running ? (currentHash ?? undefined) : expectedHash, - }) - return containerName + configHash: hashMismatch && running ? (currentHash ?? undefined) : expectedHash, + }); + return containerName; }