Skip to content

Commit f09dbee

Browse files
dcramercodex
andcommitted
Add dedicated sandbox browser container runtime
Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent 08cbc12 commit f09dbee

9 files changed

Lines changed: 379 additions & 15 deletions

File tree

docker/Dockerfile.sandbox-browser

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
FROM python:3.12-slim-bookworm
2+
3+
ENV DEBIAN_FRONTEND=noninteractive
4+
5+
RUN apt-get update \
6+
&& apt-get install -y --no-install-recommends \
7+
bash \
8+
ca-certificates \
9+
chromium \
10+
curl \
11+
fonts-liberation \
12+
fonts-noto-color-emoji \
13+
socat \
14+
&& rm -rf /var/lib/apt/lists/*
15+
16+
RUN pip install --no-cache-dir playwright \
17+
&& playwright install chromium
18+
19+
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/ash-sandbox-browser
20+
RUN chmod +x /usr/local/bin/ash-sandbox-browser
21+
22+
RUN useradd --create-home --shell /bin/bash sandbox
23+
USER sandbox
24+
WORKDIR /home/sandbox
25+
26+
EXPOSE 9222
27+
28+
CMD ["ash-sandbox-browser"]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
export HOME=/tmp/ash-browser-home
5+
export XDG_CONFIG_HOME="${HOME}/.config"
6+
export XDG_CACHE_HOME="${HOME}/.cache"
7+
8+
CDP_PORT="${ASH_BROWSER_CDP_PORT:-${OPENCLAW_BROWSER_CDP_PORT:-9222}}"
9+
HEADLESS="${ASH_BROWSER_HEADLESS:-${OPENCLAW_BROWSER_HEADLESS:-1}}"
10+
11+
mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}"
12+
rm -rf "${HOME}/.chrome/WidevineCdm" >/dev/null 2>&1 || true
13+
rm -f "${HOME}/.chrome/SingletonLock" "${HOME}/.chrome/SingletonCookie" "${HOME}/.chrome/SingletonSocket" >/dev/null 2>&1 || true
14+
15+
if [[ "${HEADLESS}" == "1" ]]; then
16+
CHROME_ARGS=(
17+
"--headless=new"
18+
"--disable-gpu"
19+
)
20+
else
21+
CHROME_ARGS=()
22+
fi
23+
24+
CHROME_ARGS+=(
25+
"--remote-debugging-address=127.0.0.1"
26+
"--remote-debugging-port=${CDP_PORT}"
27+
"--user-data-dir=${HOME}/.chrome"
28+
"--no-first-run"
29+
"--no-default-browser-check"
30+
"--disable-dev-shm-usage"
31+
"--disable-background-networking"
32+
"--disable-component-update"
33+
"--disable-features=Translate,MediaRouter"
34+
"--disable-breakpad"
35+
"--disable-crash-reporter"
36+
"--metrics-recording-only"
37+
"--disable-session-crashed-bubble"
38+
"--hide-crash-restore-bubble"
39+
"--password-store=basic"
40+
"--no-sandbox"
41+
)
42+
43+
chromium "${CHROME_ARGS[@]}" about:blank &
44+
45+
wait -n

src/ash/browser/manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,9 @@ def create_browser_manager(
10041004
viewport_width=config.browser.default_viewport_width,
10051005
viewport_height=config.browser.default_viewport_height,
10061006
executor=sandbox_executor,
1007+
runtime_mode=config.browser.sandbox.runtime_mode,
1008+
container_image=config.browser.sandbox.container_image,
1009+
container_name_prefix=config.browser.sandbox.container_name_prefix,
10071010
runtime_restart_attempts=config.browser.sandbox.runtime_restart_attempts,
10081011
)
10091012
else:

src/ash/browser/providers/sandbox.py

Lines changed: 222 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Sandbox-backed browser provider.
22
33
This provider never runs browser automation in the host process.
4-
All actions execute inside the shared sandbox container via SandboxExecutor.
4+
All actions execute inside container runtime (legacy shared executor container
5+
or dedicated browser container).
56
"""
67

78
from __future__ import annotations
@@ -33,6 +34,8 @@ class _RemoteSandboxRuntime:
3334
port: int
3435
pid: int
3536
base_dir: str
37+
container_name: str | None = None
38+
host_port: int | None = None
3639

3740

3841
class SandboxBrowserProvider:
@@ -48,16 +51,29 @@ def __init__(
4851
viewport_width: int = 1280,
4952
viewport_height: int = 720,
5053
executor: SandboxExecutor | None = None,
54+
runtime_mode: str = "dedicated",
55+
container_image: str = "ash-sandbox-browser:latest",
56+
container_name_prefix: str = "ash-browser-",
5157
runtime_restart_attempts: int = 1,
5258
) -> None:
53-
_ = (headless, browser_channel, viewport_width, viewport_height)
59+
_ = (browser_channel, viewport_width, viewport_height)
60+
self._headless = headless
5461
self._executor = executor
62+
self._runtime_mode = (
63+
"dedicated" if runtime_mode.strip().lower() == "dedicated" else "legacy"
64+
)
65+
self._container_image = container_image.strip() or "ash-sandbox-browser:latest"
66+
self._container_name = (
67+
f"{(container_name_prefix.strip() or 'ash-browser-')}runtime"
68+
)
5569
self._sessions: set[str] = set()
5670
self._session_targets: dict[str, str] = {}
5771
self._runtime: _RemoteSandboxRuntime | None = None
5872
self._runtime_lock = asyncio.Lock()
5973
self._runtime_base_dir: str | None = None
60-
self.runs_in_sandbox_executor = executor is not None
74+
self.runs_in_sandbox_executor = (
75+
executor is not None or self._runtime_mode == "dedicated"
76+
)
6177
self._runtime_restart_attempts = max(0, int(runtime_restart_attempts))
6278

6379
async def start_session(
@@ -691,6 +707,68 @@ def _pick_port(self) -> int:
691707
return candidate
692708
return 20000 + secrets.randbelow(20000)
693709

710+
async def _execute_host_command(
711+
self, args: list[str], *, timeout_seconds: int
712+
) -> ExecutionResult:
713+
proc = await asyncio.create_subprocess_exec(
714+
*args,
715+
stdout=asyncio.subprocess.PIPE,
716+
stderr=asyncio.subprocess.PIPE,
717+
)
718+
try:
719+
stdout_b, stderr_b = await asyncio.wait_for(
720+
proc.communicate(),
721+
timeout=max(1, timeout_seconds),
722+
)
723+
except TimeoutError:
724+
proc.kill()
725+
_ = await proc.communicate()
726+
return ExecutionResult(
727+
exit_code=-1,
728+
stdout="",
729+
stderr="host_command_timed_out",
730+
timed_out=True,
731+
)
732+
return ExecutionResult(
733+
exit_code=int(proc.returncode or 0),
734+
stdout=(stdout_b or b"").decode("utf-8", errors="replace"),
735+
stderr=(stderr_b or b"").decode("utf-8", errors="replace"),
736+
)
737+
738+
async def _docker_inspect_running(self, container_name: str) -> bool | None:
739+
probe = await self._execute_host_command(
740+
["docker", "inspect", "-f", "{{.State.Running}}", container_name],
741+
timeout_seconds=10,
742+
)
743+
if not probe.success:
744+
return None
745+
value = (probe.stdout or "").strip().lower()
746+
if value == "true":
747+
return True
748+
if value == "false":
749+
return False
750+
return None
751+
752+
async def _docker_resolve_host_port(
753+
self, container_name: str, internal_port: int
754+
) -> int | None:
755+
result = await self._execute_host_command(
756+
["docker", "port", container_name, f"{internal_port}/tcp"],
757+
timeout_seconds=10,
758+
)
759+
if not result.success:
760+
return None
761+
lines = [
762+
line.strip() for line in (result.stdout or "").splitlines() if line.strip()
763+
]
764+
if not lines:
765+
return None
766+
tail = lines[-1]
767+
if ":" not in tail:
768+
return None
769+
maybe_port = tail.rsplit(":", 1)[-1].strip()
770+
return int(maybe_port) if maybe_port.isdigit() else None
771+
694772
async def _ensure_runtime(self) -> _RemoteSandboxRuntime:
695773
async with self._runtime_lock:
696774
if self._runtime and await self._is_runtime_healthy(self._runtime):
@@ -760,6 +838,88 @@ async def _recover_runtime(self, *, reason: str) -> _RemoteSandboxRuntime:
760838
raise ValueError(last_error)
761839

762840
async def _launch_runtime(self) -> _RemoteSandboxRuntime:
841+
if self._runtime_mode == "dedicated":
842+
logger.info(
843+
"browser_sandbox_runtime_starting",
844+
extra={"browser.provider": "sandbox"},
845+
)
846+
inspect_image = await self._execute_host_command(
847+
["docker", "image", "inspect", self._container_image],
848+
timeout_seconds=15,
849+
)
850+
if not inspect_image.success:
851+
raise ValueError(
852+
"sandbox_browser_launch_failed: missing_browser_image:"
853+
f"{self._container_image}"
854+
)
855+
856+
running = await self._docker_inspect_running(self._container_name)
857+
if running is None:
858+
create = await self._execute_host_command(
859+
[
860+
"docker",
861+
"create",
862+
"--name",
863+
self._container_name,
864+
"-p",
865+
"127.0.0.1::9222",
866+
"-e",
867+
f"OPENCLAW_BROWSER_HEADLESS={'1' if self._headless else '0'}",
868+
"-e",
869+
"OPENCLAW_BROWSER_ENABLE_NOVNC=0",
870+
"-e",
871+
"OPENCLAW_BROWSER_CDP_PORT=9222",
872+
self._container_image,
873+
],
874+
timeout_seconds=20,
875+
)
876+
if not create.success:
877+
message = (
878+
create.stderr.strip()
879+
or create.stdout.strip()
880+
or "docker_create_failed"
881+
)
882+
raise ValueError(f"sandbox_browser_launch_failed: {message}")
883+
running = False
884+
885+
if not running:
886+
start = await self._execute_host_command(
887+
["docker", "start", self._container_name],
888+
timeout_seconds=20,
889+
)
890+
if not start.success:
891+
message = (
892+
start.stderr.strip()
893+
or start.stdout.strip()
894+
or "docker_start_failed"
895+
)
896+
raise ValueError(f"sandbox_browser_launch_failed: {message}")
897+
898+
host_port = await self._docker_resolve_host_port(self._container_name, 9222)
899+
runtime = _RemoteSandboxRuntime(
900+
port=9222,
901+
pid=0,
902+
base_dir="/tmp/ash-browser/runtime", # noqa: S108 - container-local runtime path
903+
container_name=self._container_name,
904+
host_port=host_port,
905+
)
906+
self._runtime = runtime
907+
try:
908+
await self._wait_for_cdp_ready(runtime=runtime)
909+
except ValueError as e:
910+
await self._kill_runtime(runtime)
911+
self._runtime = None
912+
raise e
913+
logger.info(
914+
"browser_sandbox_runtime_ready",
915+
extra={
916+
"browser.provider": "sandbox",
917+
"browser.runtime_port": runtime.port,
918+
"browser.runtime_pid": runtime.pid,
919+
},
920+
)
921+
return runtime
922+
763923
base_dir = await self._resolve_runtime_base_dir()
764924
last_error = "sandbox_browser_launch_failed: unknown startup failure"
765925
launch_deadline = (
@@ -838,6 +998,8 @@ async def _launch_runtime(self) -> _RemoteSandboxRuntime:
838998

839999
async def _resolve_runtime_base_dir(self) -> str:
8401000
"""Pick a writable runtime directory for Chromium profile/log data."""
1001+
if self._runtime_mode == "dedicated":
1002+
return "/tmp/ash-browser/runtime" # noqa: S108 - container-local runtime path
8411003
if self._runtime_base_dir:
8421004
return self._runtime_base_dir
8431005
candidates = (
@@ -889,6 +1051,12 @@ async def _is_runtime_healthy(self, runtime: _RemoteSandboxRuntime) -> bool:
8891051
return ws_ok
8901052

8911053
async def _kill_runtime(self, runtime: _RemoteSandboxRuntime) -> None:
1054+
if self._runtime_mode == "dedicated" and runtime.container_name:
1055+
_ = await self._execute_host_command(
1056+
["docker", "rm", "-f", runtime.container_name],
1057+
timeout_seconds=10,
1058+
)
1059+
return
8921060
_ = await self._execute_sandbox_command(
8931061
command=f"bash -lc {shlex.quote(f'kill {runtime.pid} >/dev/null 2>&1 || true')}",
8941062
phase="runtime_kill",
@@ -986,6 +1154,30 @@ async def _build_cdp_not_ready_error(
9861154
phase: str,
9871155
probe_details: str,
9881156
) -> str:
1157+
if self._runtime_mode == "dedicated" and runtime.container_name:
1158+
state_probe = await self._execute_host_command(
1159+
[
1160+
"docker",
1161+
"inspect",
1162+
"-f",
1163+
"{{.State.Status}}",
1164+
runtime.container_name,
1165+
],
1166+
timeout_seconds=8,
1167+
)
1168+
logs = await self._execute_host_command(
1169+
["docker", "logs", "--tail", "40", runtime.container_name],
1170+
timeout_seconds=8,
1171+
)
1172+
status = (state_probe.stdout or "").strip() or "unknown"
1173+
details = (logs.stderr or logs.stdout or "").strip()
1174+
prefix = f"sandbox_browser_launch_failed: cdp_not_ready:{phase}"
1175+
if details:
1176+
return f"{prefix}; process={status}; chromium_log={details}"
1177+
if probe_details:
1178+
return f"{prefix}; process={status}; probe={probe_details}"
1179+
return f"{prefix}; process={status}; probe=unavailable"
1180+
9891181
proc_alive = await self._execute_sandbox_command(
9901182
command=f"bash -lc {shlex.quote(f'kill -0 {runtime.pid} >/dev/null 2>&1 && echo alive || echo dead')}",
9911183
phase="runtime_alive_probe",
@@ -1019,6 +1211,33 @@ async def _execute_sandbox_command(
10191211
reuse_container: bool,
10201212
environment: dict[str, str] | None = None,
10211213
) -> ExecutionResult:
1214+
if self._runtime_mode == "dedicated":
1215+
runtime = self._runtime
1216+
if runtime is None or not runtime.container_name:
1217+
raise ValueError(
1218+
"sandbox_browser_runtime_unavailable: dedicated_container_missing"
1219+
)
1220+
env_args: list[str] = []
1221+
for key, value in (environment or {}).items():
1222+
env_args.extend(["-e", f"{key}={value}"])
1223+
result = await self._execute_host_command(
1224+
[
1225+
"docker",
1226+
"exec",
1227+
*env_args,
1228+
runtime.container_name,
1229+
"bash",
1230+
"-lc",
1231+
command,
1232+
],
1233+
timeout_seconds=max(5, timeout_seconds + 10),
1234+
)
1235+
if result.timed_out:
1236+
raise ValueError(
1237+
f"sandbox_browser_action_timeout:{phase}:{max(5, timeout_seconds + 10)}s"
1238+
)
1239+
return result
1240+
10221241
executor = self._require_executor()
10231242
# Guard against hangs in container/image startup paths that can occur
10241243
# before sandbox command-level timeouts are applied.

0 commit comments

Comments
 (0)