11"""Sandbox-backed browser provider.
22
33This 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
78from __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
3841class 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