feat: add sandbox browser support

This commit is contained in:
Peter Steinberger
2026-01-03 22:11:43 +01:00
parent 107dc1aa42
commit d8a417f7ff
13 changed files with 635 additions and 7 deletions

View File

@@ -17,6 +17,7 @@
- Skills: add blogwatcher skill for RSS/Atom monitoring — thanks @Hyaxia. - 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. - 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 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 ### Fixes
- Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends. - Auto-reply: drop final payloads when block streaming to avoid duplicate Discord sends.
@@ -55,7 +56,7 @@
- Gateway: document config hot reload + reload matrix. - Gateway: document config hot reload + reload matrix.
- Onboarding/Config: add protocol notes for wizard + schema RPC. - Onboarding/Config: add protocol notes for wizard + schema RPC.
- Queue: clarify steer-backlog behavior with inline commands and update examples for streaming surfaces. - 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 ## 2.0.0-beta5 — 2026-01-03

View File

@@ -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"]

View File

@@ -454,6 +454,7 @@ Defaults (if enabled):
- workspace per session under `~/.clawdis/sandboxes` - workspace per session under `~/.clawdis/sandboxes`
- auto-prune: idle > 24h OR age > 7d - auto-prune: idle > 24h OR age > 7d
- tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins) - tools: allow only `bash`, `process`, `read`, `write`, `edit` (deny wins)
- optional sandboxed browser (Chromium + CDP, noVNC observer)
```json5 ```json5
{ {
@@ -474,6 +475,16 @@ Defaults (if enabled):
env: { LANG: "C.UTF-8" }, env: { LANG: "C.UTF-8" },
setupCommand: "apt-get update && apt-get install -y git curl jq" 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: { tools: {
allow: ["bash", "process", "read", "write", "edit"], allow: ["bash", "process", "read", "write", "edit"],
deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"] 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) ### `models` (custom providers + base URLs)
Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers Clawdis uses the **pi-coding-agent** model catalog. You can add custom providers

View File

@@ -124,6 +124,53 @@ scripts/sandbox-setup.sh
This builds `clawdis-sandbox:bookworm-slim` using `Dockerfile.sandbox`. 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 ### Custom sandbox image
Build your own image and point config to it: Build your own image and point config to it:

View File

@@ -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

View File

@@ -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}"

View File

@@ -256,7 +256,7 @@ async function imageResultFromFile(params: {
function resolveBrowserBaseUrl(controlUrl?: string) { function resolveBrowserBaseUrl(controlUrl?: string) {
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser); const resolved = resolveBrowserConfig(cfg.browser);
if (!resolved.enabled) { if (!resolved.enabled && !controlUrl?.trim()) {
throw new Error( throw new Error(
"Browser control is disabled. Set browser.enabled=true in ~/.clawdis/clawdis.json.", "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 { return {
label: "Browser", label: "Browser",
name: "browser", name: "browser",
@@ -586,7 +586,9 @@ function createBrowserTool(): AnyAgentTool {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true }); const action = readStringParam(params, "action", { required: true });
const controlUrl = readStringParam(params, "controlUrl"); const controlUrl = readStringParam(params, "controlUrl");
const baseUrl = resolveBrowserBaseUrl(controlUrl); const baseUrl = resolveBrowserBaseUrl(
controlUrl ?? opts?.defaultControlUrl,
);
switch (action) { switch (action) {
case "status": case "status":
@@ -2304,9 +2306,11 @@ function createGatewayTool(): AnyAgentTool {
}; };
} }
export function createClawdisTools(): AnyAgentTool[] { export function createClawdisTools(options?: {
browserControlUrl?: string;
}): AnyAgentTool[] {
return [ return [
createBrowserTool(), createBrowserTool({ defaultControlUrl: options?.browserControlUrl }),
createCanvasTool(), createCanvasTool(),
createNodesTool(), createNodesTool(),
createCronTool(), createCronTool(),

View File

@@ -436,6 +436,14 @@ export async function runEmbeddedPiAgent(params: {
node: process.version, node: process.version,
model: `${provider}/${modelId}`, 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 reasoningTagHint = provider === "ollama";
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
appendPrompt: buildAgentSystemPromptAppend({ appendPrompt: buildAgentSystemPromptAppend({
@@ -445,6 +453,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
reasoningTagHint, reasoningTagHint,
runtimeInfo, runtimeInfo,
sandboxInfo,
}), }),
contextFiles, contextFiles,
skills: promptSkills, skills: promptSkills,

View File

@@ -486,7 +486,9 @@ export function createClawdisCodingTools(options?: {
bashTool as unknown as AnyAgentTool, bashTool as unknown as AnyAgentTool,
processTool as unknown as AnyAgentTool, processTool as unknown as AnyAgentTool,
createWhatsAppLoginTool(), createWhatsAppLoginTool(),
...createClawdisTools(), ...createClawdisTools({
browserControlUrl: sandbox?.browser?.controlUrl,
}),
]; ];
const allowDiscord = shouldIncludeDiscordTool(options?.surface); const allowDiscord = shouldIncludeDiscordTool(options?.surface);
const filtered = allowDiscord const filtered = allowDiscord

View File

@@ -6,6 +6,13 @@ import path from "node:path";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { STATE_DIR_CLAWDIS } 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 { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { import {
@@ -24,6 +31,17 @@ export type SandboxToolPolicy = {
deny?: string[]; deny?: string[];
}; };
export type SandboxBrowserConfig = {
enabled: boolean;
image: string;
containerPrefix: string;
cdpPort: number;
vncPort: number;
noVncPort: number;
headless: boolean;
enableNoVnc: boolean;
};
export type SandboxDockerConfig = { export type SandboxDockerConfig = {
image: string; image: string;
containerPrefix: string; containerPrefix: string;
@@ -47,10 +65,17 @@ export type SandboxConfig = {
perSession: boolean; perSession: boolean;
workspaceRoot: string; workspaceRoot: string;
docker: SandboxDockerConfig; docker: SandboxDockerConfig;
browser: SandboxBrowserConfig;
tools: SandboxToolPolicy; tools: SandboxToolPolicy;
prune: SandboxPruneConfig; prune: SandboxPruneConfig;
}; };
export type SandboxBrowserContext = {
controlUrl: string;
noVncUrl?: string;
containerName: string;
};
export type SandboxContext = { export type SandboxContext = {
enabled: boolean; enabled: boolean;
sessionKey: string; sessionKey: string;
@@ -59,6 +84,7 @@ export type SandboxContext = {
containerWorkdir: string; containerWorkdir: string;
docker: SandboxDockerConfig; docker: SandboxDockerConfig;
tools: SandboxToolPolicy; tools: SandboxToolPolicy;
browser?: SandboxBrowserContext;
}; };
const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join( const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(
@@ -80,9 +106,18 @@ const DEFAULT_TOOL_DENY = [
"discord", "discord",
"gateway", "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_STATE_DIR = path.join(STATE_DIR_CLAWDIS, "sandbox");
const SANDBOX_REGISTRY_PATH = path.join(SANDBOX_STATE_DIR, "containers.json"); 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 = { type SandboxRegistryEntry = {
containerName: string; containerName: string;
@@ -96,7 +131,41 @@ type SandboxRegistry = {
entries: SandboxRegistryEntry[]; 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; 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 { function defaultSandboxConfig(cfg?: ClawdisConfig): SandboxConfig {
const agent = cfg?.agent?.sandbox; const agent = cfg?.agent?.sandbox;
@@ -117,6 +186,18 @@ function defaultSandboxConfig(cfg?: ClawdisConfig): SandboxConfig {
env: agent?.docker?.env ?? { LANG: "C.UTF-8" }, env: agent?.docker?.env ?? { LANG: "C.UTF-8" },
setupCommand: agent?.docker?.setupCommand, 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: { tools: {
allow: agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW, allow: agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
deny: agent?.tools?.deny ?? DEFAULT_TOOL_DENY, deny: agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
@@ -204,6 +285,51 @@ async function removeRegistryEntry(containerName: string) {
await writeRegistry({ entries: next }); await writeRegistry({ entries: next });
} }
async function readBrowserRegistry(): Promise<SandboxBrowserRegistry> {
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 }) { function execDocker(args: string[], opts?: { allowFailure?: boolean }) {
return new Promise<{ stdout: string; stderr: string; code: number }>( return new Promise<{ stdout: string; stderr: string; code: number }>(
(resolve, reject) => { (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) { async function dockerImageExists(image: string) {
const result = await execDocker(["image", "inspect", image], { const result = await execDocker(["image", "inspect", image], {
allowFailure: true, allowFailure: true,
@@ -354,6 +493,170 @@ async function ensureSandboxContainer(params: {
return containerName; 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<SandboxBrowserContext | null> {
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) { async function pruneSandboxContainers(cfg: SandboxConfig) {
const now = Date.now(); const now = Date.now();
const idleHours = cfg.prune.idleHours; 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) { async function maybePruneSandboxes(cfg: SandboxConfig) {
const now = Date.now(); const now = Date.now();
if (now - lastPruneAtMs < 5 * 60 * 1000) return; if (now - lastPruneAtMs < 5 * 60 * 1000) return;
lastPruneAtMs = now; lastPruneAtMs = now;
try { try {
await pruneSandboxContainers(cfg); await pruneSandboxContainers(cfg);
await pruneSandboxBrowsers(cfg);
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error error instanceof Error
@@ -426,6 +763,12 @@ export async function resolveSandboxContext(params: {
cfg, cfg,
}); });
const browser = await ensureSandboxBrowser({
sessionKey: rawSessionKey,
workspaceDir,
cfg,
});
return { return {
enabled: true, enabled: true,
sessionKey: rawSessionKey, sessionKey: rawSessionKey,
@@ -434,5 +777,6 @@ export async function resolveSandboxContext(params: {
containerWorkdir: cfg.docker.workdir, containerWorkdir: cfg.docker.workdir,
docker: cfg.docker, docker: cfg.docker,
tools: cfg.tools, tools: cfg.tools,
browser: browser ?? undefined,
}; };
} }

View File

@@ -13,6 +13,12 @@ export function buildAgentSystemPromptAppend(params: {
node?: string; node?: string;
model?: string; model?: string;
}; };
sandboxInfo?: {
enabled: boolean;
workspaceDir?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
};
}) { }) {
const thinkHint = const thinkHint =
params.defaultThinkLevel && params.defaultThinkLevel !== "off" params.defaultThinkLevel && params.defaultThinkLevel !== "off"
@@ -72,6 +78,25 @@ export function buildAgentSystemPromptAppend(params: {
`Your working directory is: ${params.workspaceDir}`, `Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "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 ? "## User Identity" : "",
ownerLine ?? "", ownerLine ?? "",
ownerLine ? "" : "", ownerLine ? "" : "",

View File

@@ -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<BrowserBridge> {
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<Server>((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<void> {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}

View File

@@ -665,6 +665,17 @@ export type ClawdisConfig = {
/** Optional setup command run once after container creation. */ /** Optional setup command run once after container creation. */
setupCommand?: string; 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). */ /** Tool allow/deny policy (deny wins). */
tools?: { tools?: {
allow?: string[]; allow?: string[];
@@ -1106,6 +1117,18 @@ export const ClawdisSchema = z.object({
setupCommand: z.string().optional(), setupCommand: z.string().optional(),
}) })
.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 tools: z
.object({ .object({
allow: z.array(z.string()).optional(), allow: z.array(z.string()).optional(),