feat: add sandbox browser support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
27
Dockerfile.sandbox-browser
Normal file
27
Dockerfile.sandbox-browser
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
45
scripts/sandbox-browser-entrypoint.sh
Executable file
45
scripts/sandbox-browser-entrypoint.sh
Executable 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
|
||||
7
scripts/sandbox-browser-setup.sh
Executable file
7
scripts/sandbox-browser-setup.sh
Executable 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}"
|
||||
@@ -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<string, unknown>;
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 }) {
|
||||
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<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) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 ? "" : "",
|
||||
|
||||
67
src/browser/bridge-server.ts
Normal file
67
src/browser/bridge-server.ts
Normal 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());
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user