diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e08f554e..c1ab7a77d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. - Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) — thanks @thewilloftheshadow. - Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. +- Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions. ### Fixes - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. @@ -55,7 +56,7 @@ - Gateway: document config hot reload + reload matrix. - Onboarding/Config: add protocol notes for wizard + schema RPC. - Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. -- Sandbox: document per-session agent sandbox setup, config, and Docker build. +- Sandbox: document per-session agent sandbox setup, browser image, and Docker build. ## 2.0.0-beta5 — 2026-01-03 diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser new file mode 100644 index 000000000..849e92a16 --- /dev/null +++ b/Dockerfile.sandbox-browser @@ -0,0 +1,27 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + chromium \ + curl \ + fonts-liberation \ + fonts-noto-color-emoji \ + git \ + jq \ + novnc \ + python3 \ + websockify \ + x11vnc \ + xvfb \ + && rm -rf /var/lib/apt/lists/* + +COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdis-sandbox-browser +RUN chmod +x /usr/local/bin/clawdis-sandbox-browser + +EXPOSE 9222 5900 6080 + +CMD ["clawdis-sandbox-browser"] diff --git a/docs/configuration.md b/docs/configuration.md index 8f1ba315c..64c2d9371 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -454,6 +454,7 @@ Defaults (if enabled): - workspace per session under `~/.clawdis/sandboxes` - auto-prune: idle > 24h OR age > 7d - tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins) +- optional sandboxed browser (Chromium + CDP, noVNC observer) ```json5 { @@ -474,6 +475,16 @@ Defaults (if enabled): env: { LANG: "C.UTF-8" }, setupCommand: "apt-get update && apt-get install -y git curl jq" }, + browser: { + enabled: false, + image: "clawdis-sandbox-browser:bookworm-slim", + containerPrefix: "clawdis-sbx-browser-", + cdpPort: 9222, + vncPort: 5900, + noVncPort: 6080, + headless: false, + enableNoVnc: true + }, tools: { allow: ["bash", "process", "read", "write", "edit"], deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] @@ -487,6 +498,22 @@ Defaults (if enabled): } ``` +Build the default sandbox image once with: +```bash +scripts/sandbox-setup.sh +``` + +Build the optional browser image with: +```bash +scripts/sandbox-browser-setup.sh +``` + +When `agent.sandbox.browser.enabled=true`, the browser tool uses a sandboxed +Chromium instance (CDP). If noVNC is enabled (default when headless=false), +the noVNC URL is injected into the system prompt so the agent can reference it. +This does not require `browser.enabled` in the main config; the sandbox control +URL is injected per session. + ### `models` (custom providers + base URLs) Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers diff --git a/docs/docker.md b/docs/docker.md index 2b48f70ec..9c7aff8b5 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -124,6 +124,53 @@ scripts/sandbox-setup.sh This builds `clawdis-sandbox:bookworm-slim` using `Dockerfile.sandbox`. +### Sandbox browser image + +To run the browser tool inside the sandbox, build the browser image: + +```bash +scripts/sandbox-browser-setup.sh +``` + +This builds `clawdis-sandbox-browser:bookworm-slim` using +`Dockerfile.sandbox-browser`. The container runs Chromium with CDP enabled and +an optional noVNC observer (headful via Xvfb). + +Notes: +- Headful (Xvfb) reduces bot blocking vs headless. +- Headless can still be used by setting `agent.sandbox.browser.headless=true`. +- No full desktop environment (GNOME) is needed; Xvfb provides the display. + +Use config: + +```json5 +{ + agent: { + sandbox: { + browser: { enabled: true } + } + } +} +``` + +Custom browser image: + +```json5 +{ + agent: { + sandbox: { browser: { image: "my-clawdis-browser" } } + } +} +``` + +When enabled, the agent receives: +- a sandbox browser control URL (for the `browser` tool) +- a noVNC URL (if enabled and headless=false) + +Remember: if you use an allowlist for tools, add `browser` (and remove it from +deny) or the tool remains blocked. +Prune rules (`agent.sandbox.prune`) apply to browser containers too. + ### Custom sandbox image Build your own image and point config to it: diff --git a/scripts/sandbox-browser-entrypoint.sh b/scripts/sandbox-browser-entrypoint.sh new file mode 100755 index 000000000..8e6f96841 --- /dev/null +++ b/scripts/sandbox-browser-entrypoint.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY=:1 + +CDP_PORT="${CLAWDIS_BROWSER_CDP_PORT:-9222}" +VNC_PORT="${CLAWDIS_BROWSER_VNC_PORT:-5900}" +NOVNC_PORT="${CLAWDIS_BROWSER_NOVNC_PORT:-6080}" +ENABLE_NOVNC="${CLAWDIS_BROWSER_ENABLE_NOVNC:-1}" +HEADLESS="${CLAWDIS_BROWSER_HEADLESS:-0}" + +mkdir -p /workspace/.chrome + +Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp & + +if [[ "${HEADLESS}" == "1" ]]; then + CHROME_ARGS=( + "--headless=new" + "--disable-gpu" + ) +else + CHROME_ARGS=() +fi + +CHROME_ARGS+=( + "--remote-debugging-address=0.0.0.0" + "--remote-debugging-port=${CDP_PORT}" + "--user-data-dir=/workspace/.chrome" + "--no-first-run" + "--no-default-browser-check" + "--disable-dev-shm-usage" + "--disable-background-networking" + "--disable-features=TranslateUI" + "--metrics-recording-only" + "--no-sandbox" +) + +chromium "${CHROME_ARGS[@]}" about:blank & + +if [[ "${ENABLE_NOVNC}" == "1" && "${HEADLESS}" != "1" ]]; then + x11vnc -display :1 -rfbport "${VNC_PORT}" -shared -forever -nopw -localhost & + websockify --web /usr/share/novnc/ "${NOVNC_PORT}" "localhost:${VNC_PORT}" & +fi + +wait -n diff --git a/scripts/sandbox-browser-setup.sh b/scripts/sandbox-browser-setup.sh new file mode 100755 index 000000000..732c5c43e --- /dev/null +++ b/scripts/sandbox-browser-setup.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME="clawdis-sandbox-browser:bookworm-slim" + +docker build -t "${IMAGE_NAME}" -f Dockerfile.sandbox-browser . +echo "Built ${IMAGE_NAME}" diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index 1b19f4f3f..ffcfa4d44 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -256,7 +256,7 @@ async function imageResultFromFile(params: { function resolveBrowserBaseUrl(controlUrl?: string) { const cfg = loadConfig(); const resolved = resolveBrowserConfig(cfg.browser); - if (!resolved.enabled) { + if (!resolved.enabled && !controlUrl?.trim()) { throw new Error( "Browser control is disabled. Set browser.enabled=true in ~/.clawdis/clawdis.json.", ); @@ -575,7 +575,7 @@ const BrowserToolSchema = Type.Union([ }), ]); -function createBrowserTool(): AnyAgentTool { +function createBrowserTool(opts?: { defaultControlUrl?: string }): AnyAgentTool { return { label: "Browser", name: "browser", @@ -586,7 +586,9 @@ function createBrowserTool(): AnyAgentTool { const params = args as Record; const action = readStringParam(params, "action", { required: true }); const controlUrl = readStringParam(params, "controlUrl"); - const baseUrl = resolveBrowserBaseUrl(controlUrl); + const baseUrl = resolveBrowserBaseUrl( + controlUrl ?? opts?.defaultControlUrl, + ); switch (action) { case "status": @@ -2304,9 +2306,11 @@ function createGatewayTool(): AnyAgentTool { }; } -export function createClawdisTools(): AnyAgentTool[] { +export function createClawdisTools(options?: { + browserControlUrl?: string; +}): AnyAgentTool[] { return [ - createBrowserTool(), + createBrowserTool({ defaultControlUrl: options?.browserControlUrl }), createCanvasTool(), createNodesTool(), createCronTool(), diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 9ba4923d3..dbf734ffc 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -436,6 +436,14 @@ export async function runEmbeddedPiAgent(params: { node: process.version, model: `${provider}/${modelId}`, }; + const sandboxInfo = sandbox?.enabled + ? { + enabled: true, + workspaceDir: sandbox.workspaceDir, + browserControlUrl: sandbox.browser?.controlUrl, + browserNoVncUrl: sandbox.browser?.noVncUrl, + } + : undefined; const reasoningTagHint = provider === "ollama"; const systemPrompt = buildSystemPrompt({ appendPrompt: buildAgentSystemPromptAppend({ @@ -445,6 +453,7 @@ export async function runEmbeddedPiAgent(params: { ownerNumbers: params.ownerNumbers, reasoningTagHint, runtimeInfo, + sandboxInfo, }), contextFiles, skills: promptSkills, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 26271f5d6..732309fe5 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -486,7 +486,9 @@ export function createClawdisCodingTools(options?: { bashTool as unknown as AnyAgentTool, processTool as unknown as AnyAgentTool, createWhatsAppLoginTool(), - ...createClawdisTools(), + ...createClawdisTools({ + browserControlUrl: sandbox?.browser?.controlUrl, + }), ]; const allowDiscord = shouldIncludeDiscordTool(options?.surface); const filtered = allowDiscord diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts index 8869318f9..d65f1da2e 100644 --- a/src/agents/sandbox.ts +++ b/src/agents/sandbox.ts @@ -6,6 +6,13 @@ import path from "node:path"; import type { ClawdisConfig } from "../config/config.js"; import { STATE_DIR_CLAWDIS } from "../config/config.js"; +import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js"; +import type { ResolvedBrowserConfig } from "../browser/config.js"; +import { + type BrowserBridge, + startBrowserBridgeServer, + stopBrowserBridgeServer, +} from "../browser/bridge-server.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; import { @@ -24,6 +31,17 @@ export type SandboxToolPolicy = { deny?: string[]; }; +export type SandboxBrowserConfig = { + enabled: boolean; + image: string; + containerPrefix: string; + cdpPort: number; + vncPort: number; + noVncPort: number; + headless: boolean; + enableNoVnc: boolean; +}; + export type SandboxDockerConfig = { image: string; containerPrefix: string; @@ -47,10 +65,17 @@ export type SandboxConfig = { perSession: boolean; workspaceRoot: string; docker: SandboxDockerConfig; + browser: SandboxBrowserConfig; tools: SandboxToolPolicy; prune: SandboxPruneConfig; }; +export type SandboxBrowserContext = { + controlUrl: string; + noVncUrl?: string; + containerName: string; +}; + export type SandboxContext = { enabled: boolean; sessionKey: string; @@ -59,6 +84,7 @@ export type SandboxContext = { containerWorkdir: string; docker: SandboxDockerConfig; tools: SandboxToolPolicy; + browser?: SandboxBrowserContext; }; const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join( @@ -80,9 +106,18 @@ const DEFAULT_TOOL_DENY = [ "discord", "gateway", ]; +const DEFAULT_SANDBOX_BROWSER_IMAGE = "clawdis-sandbox-browser:bookworm-slim"; +const DEFAULT_SANDBOX_BROWSER_PREFIX = "clawdis-sbx-browser-"; +const DEFAULT_SANDBOX_BROWSER_CDP_PORT = 9222; +const DEFAULT_SANDBOX_BROWSER_VNC_PORT = 5900; +const DEFAULT_SANDBOX_BROWSER_NOVNC_PORT = 6080; const SANDBOX_STATE_DIR = path.join(STATE_DIR_CLAWDIS, "sandbox"); const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); +const SANDBOX_BROWSER_REGISTRY_PATH = path.join( + SANDBOX_STATE_DIR, + "browsers.json", +); type SandboxRegistryEntry = { containerName: string; @@ -96,7 +131,41 @@ type SandboxRegistry = { entries: SandboxRegistryEntry[]; }; +type SandboxBrowserRegistryEntry = { + containerName: string; + sessionKey: string; + createdAtMs: number; + lastUsedAtMs: number; + image: string; + cdpPort: number; + noVncPort?: number; +}; + +type SandboxBrowserRegistry = { + entries: SandboxBrowserRegistryEntry[]; +}; + let lastPruneAtMs = 0; +const BROWSER_BRIDGES = new Map< + string, + { bridge: BrowserBridge; containerName: string } +>(); + +function normalizeToolList(values?: string[]) { + if (!values) return []; + return values + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => value.toLowerCase()); +} + +function isToolAllowed(policy: SandboxToolPolicy, name: string) { + const deny = new Set(normalizeToolList(policy.deny)); + if (deny.has(name.toLowerCase())) return false; + const allow = normalizeToolList(policy.allow); + if (allow.length === 0) return true; + return allow.includes(name.toLowerCase()); +} function defaultSandboxConfig(cfg?: ClawdisConfig): SandboxConfig { const agent = cfg?.agent?.sandbox; @@ -117,6 +186,18 @@ function defaultSandboxConfig(cfg?: ClawdisConfig): SandboxConfig { env: agent?.docker?.env ?? { LANG: "C.UTF-8" }, setupCommand: agent?.docker?.setupCommand, }, + browser: { + enabled: agent?.browser?.enabled ?? false, + image: agent?.browser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE, + containerPrefix: + agent?.browser?.containerPrefix ?? DEFAULT_SANDBOX_BROWSER_PREFIX, + cdpPort: agent?.browser?.cdpPort ?? DEFAULT_SANDBOX_BROWSER_CDP_PORT, + vncPort: agent?.browser?.vncPort ?? DEFAULT_SANDBOX_BROWSER_VNC_PORT, + noVncPort: + agent?.browser?.noVncPort ?? DEFAULT_SANDBOX_BROWSER_NOVNC_PORT, + headless: agent?.browser?.headless ?? false, + enableNoVnc: agent?.browser?.enableNoVnc ?? true, + }, tools: { allow: agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, deny: agent?.tools?.deny ?? DEFAULT_TOOL_DENY, @@ -204,6 +285,51 @@ async function removeRegistryEntry(containerName: string) { await writeRegistry({ entries: next }); } +async function readBrowserRegistry(): Promise { + try { + const raw = await fs.readFile(SANDBOX_BROWSER_REGISTRY_PATH, "utf-8"); + const parsed = JSON.parse(raw) as SandboxBrowserRegistry; + if (parsed && Array.isArray(parsed.entries)) return parsed; + } catch { + // ignore + } + return { entries: [] }; +} + +async function writeBrowserRegistry(registry: SandboxBrowserRegistry) { + await fs.mkdir(SANDBOX_STATE_DIR, { recursive: true }); + await fs.writeFile( + SANDBOX_BROWSER_REGISTRY_PATH, + `${JSON.stringify(registry, null, 2)}\n`, + "utf-8", + ); +} + +async function updateBrowserRegistry(entry: SandboxBrowserRegistryEntry) { + const registry = await readBrowserRegistry(); + const existing = registry.entries.find( + (item) => item.containerName === entry.containerName, + ); + const next = registry.entries.filter( + (item) => item.containerName !== entry.containerName, + ); + next.push({ + ...entry, + createdAtMs: existing?.createdAtMs ?? entry.createdAtMs, + image: existing?.image ?? entry.image, + }); + await writeBrowserRegistry({ entries: next }); +} + +async function removeBrowserRegistryEntry(containerName: string) { + const registry = await readBrowserRegistry(); + const next = registry.entries.filter( + (item) => item.containerName !== containerName, + ); + if (next.length === registry.entries.length) return; + await writeBrowserRegistry({ entries: next }); +} + function execDocker(args: string[], opts?: { allowFailure?: boolean }) { return new Promise<{ stdout: string; stderr: string; code: number }>( (resolve, reject) => { @@ -230,6 +356,19 @@ function execDocker(args: string[], opts?: { allowFailure?: boolean }) { ); } +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; +} + async function dockerImageExists(image: string) { const result = await execDocker(["image", "inspect", image], { allowFailure: true, @@ -354,6 +493,170 @@ async function ensureSandboxContainer(params: { return containerName; } +async function ensureSandboxBrowserImage(image: string) { + const exists = await dockerImageExists(image); + if (exists) return; + throw new Error( + `Sandbox browser image not found: ${image}. Build it with scripts/sandbox-browser-setup.sh.`, + ); +} + +function buildSandboxBrowserResolvedConfig(params: { + controlPort: number; + cdpPort: number; + headless: boolean; +}): ResolvedBrowserConfig { + const controlHost = "127.0.0.1"; + const controlUrl = `http://${controlHost}:${params.controlPort}`; + const cdpHost = "127.0.0.1"; + const cdpUrl = `http://${cdpHost}:${params.cdpPort}`; + return { + enabled: true, + controlUrl, + controlHost, + controlPort: params.controlPort, + cdpUrl, + cdpHost, + cdpPort: params.cdpPort, + cdpIsLoopback: true, + color: DEFAULT_CLAWD_BROWSER_COLOR, + executablePath: undefined, + headless: params.headless, + noSandbox: false, + attachOnly: true, + }; +} + +async function ensureSandboxBrowser(params: { + sessionKey: string; + workspaceDir: string; + cfg: SandboxConfig; +}): Promise { + if (!params.cfg.browser.enabled) return null; + if (!isToolAllowed(params.cfg.tools, "browser")) return null; + + const slug = params.cfg.perSession + ? slugifySessionKey(params.sessionKey) + : "shared"; + 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); + const args = ["create", "--name", containerName]; + args.push("--label", "clawdis.sandbox=1"); + args.push("--label", "clawdis.sandboxBrowser=1"); + args.push("--label", `clawdis.sessionKey=${params.sessionKey}`); + args.push("--label", `clawdis.createdAtMs=${Date.now()}`); + if (params.cfg.docker.readOnlyRoot) args.push("--read-only"); + for (const entry of params.cfg.docker.tmpfs) { + args.push("--tmpfs", entry); + } + if (params.cfg.docker.network) args.push("--network", params.cfg.docker.network); + if (params.cfg.docker.user) args.push("--user", params.cfg.docker.user); + for (const cap of params.cfg.docker.capDrop) { + args.push("--cap-drop", cap); + } + args.push("--security-opt", "no-new-privileges"); + args.push("-v", `${params.workspaceDir}:${params.cfg.docker.workdir}`); + 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( + "-e", + `CLAWDIS_BROWSER_HEADLESS=${params.cfg.browser.headless ? "1" : "0"}`, + ); + args.push( + "-e", + `CLAWDIS_BROWSER_ENABLE_NOVNC=${ + params.cfg.browser.enableNoVnc ? "1" : "0" + }`, + ); + args.push( + "-e", + `CLAWDIS_BROWSER_CDP_PORT=${params.cfg.browser.cdpPort}`, + ); + args.push( + "-e", + `CLAWDIS_BROWSER_VNC_PORT=${params.cfg.browser.vncPort}`, + ); + args.push( + "-e", + `CLAWDIS_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]); + } + + const mappedCdp = await readDockerPort( + containerName, + params.cfg.browser.cdpPort, + ); + if (!mappedCdp) { + 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; + + const existing = BROWSER_BRIDGES.get(params.sessionKey); + const shouldReuse = + existing && + existing.containerName === containerName && + existing.bridge.state.resolved.cdpPort === mappedCdp; + if (existing && !shouldReuse) { + await stopBrowserBridgeServer(existing.bridge.server).catch(() => undefined); + BROWSER_BRIDGES.delete(params.sessionKey); + } + const bridge = shouldReuse + ? existing!.bridge + : await startBrowserBridgeServer({ + resolved: buildSandboxBrowserResolvedConfig({ + controlPort: 0, + cdpPort: mappedCdp, + headless: params.cfg.browser.headless, + }), + }); + if (!shouldReuse) { + BROWSER_BRIDGES.set(params.sessionKey, { bridge, containerName }); + } + + const now = Date.now(); + await updateBrowserRegistry({ + containerName, + sessionKey: params.sessionKey, + createdAtMs: now, + lastUsedAtMs: now, + image: params.cfg.browser.image, + cdpPort: mappedCdp, + noVncPort: mappedNoVnc ?? undefined, + }); + + const noVncUrl = + mappedNoVnc && params.cfg.browser.enableNoVnc && !params.cfg.browser.headless + ? `http://127.0.0.1:${mappedNoVnc}/vnc.html?autoconnect=1&resize=remote` + : undefined; + + return { + controlUrl: bridge.baseUrl, + noVncUrl, + containerName, + }; +} + async function pruneSandboxContainers(cfg: SandboxConfig) { const now = Date.now(); const idleHours = cfg.prune.idleHours; @@ -380,12 +683,46 @@ async function pruneSandboxContainers(cfg: SandboxConfig) { } } +async function pruneSandboxBrowsers(cfg: SandboxConfig) { + const now = Date.now(); + const idleHours = cfg.prune.idleHours; + const maxAgeDays = cfg.prune.maxAgeDays; + if (idleHours === 0 && maxAgeDays === 0) return; + const registry = await readBrowserRegistry(); + for (const entry of registry.entries) { + const idleMs = now - entry.lastUsedAtMs; + const ageMs = now - entry.createdAtMs; + if ( + (idleHours > 0 && idleMs > idleHours * 60 * 60 * 1000) || + (maxAgeDays > 0 && ageMs > maxAgeDays * 24 * 60 * 60 * 1000) + ) { + try { + await execDocker(["rm", "-f", entry.containerName], { + allowFailure: true, + }); + } catch { + // ignore prune failures + } finally { + await removeBrowserRegistryEntry(entry.containerName); + const bridge = BROWSER_BRIDGES.get(entry.sessionKey); + if (bridge?.containerName === entry.containerName) { + await stopBrowserBridgeServer(bridge.bridge.server).catch( + () => undefined, + ); + BROWSER_BRIDGES.delete(entry.sessionKey); + } + } + } + } +} + async function maybePruneSandboxes(cfg: SandboxConfig) { const now = Date.now(); if (now - lastPruneAtMs < 5 * 60 * 1000) return; lastPruneAtMs = now; try { await pruneSandboxContainers(cfg); + await pruneSandboxBrowsers(cfg); } catch (error) { const message = error instanceof Error @@ -426,6 +763,12 @@ export async function resolveSandboxContext(params: { cfg, }); + const browser = await ensureSandboxBrowser({ + sessionKey: rawSessionKey, + workspaceDir, + cfg, + }); + return { enabled: true, sessionKey: rawSessionKey, @@ -434,5 +777,6 @@ export async function resolveSandboxContext(params: { containerWorkdir: cfg.docker.workdir, docker: cfg.docker, tools: cfg.tools, + browser: browser ?? undefined, }; } diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 0b7e00582..36448cb7c 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -13,6 +13,12 @@ export function buildAgentSystemPromptAppend(params: { node?: string; model?: string; }; + sandboxInfo?: { + enabled: boolean; + workspaceDir?: string; + browserControlUrl?: string; + browserNoVncUrl?: string; + }; }) { const thinkHint = params.defaultThinkLevel && params.defaultThinkLevel !== "off" @@ -72,6 +78,25 @@ export function buildAgentSystemPromptAppend(params: { `Your working directory is: ${params.workspaceDir}`, "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "", + params.sandboxInfo?.enabled ? "## Sandbox" : "", + params.sandboxInfo?.enabled + ? [ + "Tool execution is isolated in a Docker sandbox.", + "Some tools may be unavailable due to sandbox policy.", + params.sandboxInfo.workspaceDir + ? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}` + : "", + params.sandboxInfo.browserControlUrl + ? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}` + : "", + params.sandboxInfo.browserNoVncUrl + ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}` + : "", + ] + .filter(Boolean) + .join("\n") + : "", + params.sandboxInfo?.enabled ? "" : "", ownerLine ? "## User Identity" : "", ownerLine ?? "", ownerLine ? "" : "", diff --git a/src/browser/bridge-server.ts b/src/browser/bridge-server.ts new file mode 100644 index 000000000..8391d2c16 --- /dev/null +++ b/src/browser/bridge-server.ts @@ -0,0 +1,67 @@ +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import express from "express"; + +import type { ResolvedBrowserConfig } from "./config.js"; +import { registerBrowserRoutes } from "./routes/index.js"; +import { + type BrowserServerState, + createBrowserRouteContext, +} from "./server-context.js"; + +export type BrowserBridge = { + server: Server; + port: number; + baseUrl: string; + state: BrowserServerState; +}; + +export async function startBrowserBridgeServer(params: { + resolved: ResolvedBrowserConfig; + host?: string; + port?: number; +}): Promise { + const host = params.host ?? "127.0.0.1"; + const port = params.port ?? 0; + + const app = express(); + app.use(express.json({ limit: "1mb" })); + + const state: BrowserServerState = { + server: null as unknown as Server, + port, + cdpPort: params.resolved.cdpPort, + running: null, + resolved: params.resolved, + }; + + const ctx = createBrowserRouteContext({ + getState: () => state, + setRunning: (running) => { + state.running = running; + }, + }); + registerBrowserRoutes(app, ctx); + + const server = await new Promise((resolve, reject) => { + const s = app.listen(port, host, () => resolve(s)); + s.once("error", reject); + }); + + const address = server.address() as AddressInfo | null; + const resolvedPort = address?.port ?? port; + state.server = server; + state.port = resolvedPort; + state.resolved.controlHost = host; + state.resolved.controlPort = resolvedPort; + state.resolved.controlUrl = `http://${host}:${resolvedPort}`; + + const baseUrl = state.resolved.controlUrl; + return { server, port: resolvedPort, baseUrl, state }; +} + +export async function stopBrowserBridgeServer(server: Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} diff --git a/src/config/config.ts b/src/config/config.ts index 888e706d1..81f4cf10e 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -665,6 +665,17 @@ export type ClawdisConfig = { /** Optional setup command run once after container creation. */ setupCommand?: string; }; + /** Optional sandboxed browser settings. */ + browser?: { + enabled?: boolean; + image?: string; + containerPrefix?: string; + cdpPort?: number; + vncPort?: number; + noVncPort?: number; + headless?: boolean; + enableNoVnc?: boolean; + }; /** Tool allow/deny policy (deny wins). */ tools?: { allow?: string[]; @@ -1106,6 +1117,18 @@ export const ClawdisSchema = z.object({ setupCommand: z.string().optional(), }) .optional(), + browser: z + .object({ + enabled: z.boolean().optional(), + image: z.string().optional(), + containerPrefix: z.string().optional(), + cdpPort: z.number().int().positive().optional(), + vncPort: z.number().int().positive().optional(), + noVncPort: z.number().int().positive().optional(), + headless: z.boolean().optional(), + enableNoVnc: z.boolean().optional(), + }) + .optional(), tools: z .object({ allow: z.array(z.string()).optional(),