Skip to content

Commit e386555

Browse files
dcramercodex
andcommitted
browser: add authenticated loopback bridge for sandbox runtime
- add loopback HTTP bridge with bearer auth for runtime command execution - route dedicated sandbox provider command execution through bridge - add bridge auth unit tests and provider/cli test updates Co-Authored-By: GPT-5 Codex <codex@openai.com>
1 parent 3a9b13e commit e386555

6 files changed

Lines changed: 370 additions & 40 deletions

File tree

specs/browser.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ It does not own:
4646
9. Runtime/doctor guidance MUST NOT document host-runtime bypasses for sandbox provider.
4747
10. Sandbox browser runtime MUST be dedicated-container only; legacy shared-executor Chromium launch paths are unsupported.
4848
11. Dedicated browser containers MUST be scope-keyed (effective user scope) so independent scopes do not share a runtime container.
49+
12. Dedicated runtime command execution MUST traverse an authenticated loopback bridge (bearer token) rather than unauthenticated control surfaces.
4950

5051
## Integration Contract
5152

@@ -109,3 +110,4 @@ Expected failure semantics:
109110
- [x] Docs match actual runtime behavior and contain no host-bypass guidance.
110111
- [x] Dedicated browser runtime uses scope-keyed container naming.
111112
- [x] Legacy shared-executor browser runtime path removed from active behavior.
113+
- [x] Dedicated runtime command execution uses authenticated loopback bridge.

src/ash/browser/bridge.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""Authenticated loopback bridge for browser runtime command execution."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import secrets
7+
import subprocess
8+
import threading
9+
from collections.abc import Callable
10+
from dataclasses import dataclass
11+
from http import HTTPStatus
12+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13+
from urllib.error import HTTPError, URLError
14+
from urllib.request import Request, urlopen
15+
16+
from ash.sandbox.executor import ExecutionResult
17+
18+
BridgeExecutor = Callable[[str, int, dict[str, str]], ExecutionResult]
19+
20+
21+
@dataclass(slots=True)
22+
class BrowserExecBridge:
23+
"""Loopback HTTP bridge with bearer-token auth."""
24+
25+
token: str
26+
base_url: str
27+
_server: ThreadingHTTPServer
28+
_thread: threading.Thread
29+
30+
@classmethod
31+
def start(
32+
cls,
33+
*,
34+
executor: BridgeExecutor,
35+
host: str = "127.0.0.1",
36+
token: str | None = None,
37+
) -> BrowserExecBridge:
38+
if host not in {"127.0.0.1", "localhost"}:
39+
raise ValueError(f"bridge_loopback_required:{host}")
40+
41+
bridge_token = (token or secrets.token_hex(24)).strip()
42+
if not bridge_token:
43+
raise ValueError("bridge_token_required")
44+
45+
class _BridgeServer(ThreadingHTTPServer):
46+
daemon_threads = True
47+
allow_reuse_address = True
48+
49+
def __init__(self) -> None:
50+
super().__init__((host, 0), _BridgeHandler)
51+
self.bridge_token = bridge_token
52+
self.bridge_executor = executor
53+
54+
class _BridgeHandler(BaseHTTPRequestHandler):
55+
server: _BridgeServer
56+
57+
def do_POST(self) -> None: # noqa: N802
58+
if self.path != "/exec":
59+
self._write_json(
60+
HTTPStatus.NOT_FOUND, {"error": "bridge_route_not_found"}
61+
)
62+
return
63+
expected = f"Bearer {self.server.bridge_token}"
64+
if (self.headers.get("Authorization") or "") != expected:
65+
self._write_json(
66+
HTTPStatus.UNAUTHORIZED, {"error": "bridge_unauthorized"}
67+
)
68+
return
69+
try:
70+
content_length = int(self.headers.get("Content-Length") or "0")
71+
except ValueError:
72+
self._write_json(
73+
HTTPStatus.BAD_REQUEST,
74+
{"error": "bridge_invalid_content_length"},
75+
)
76+
return
77+
body = self.rfile.read(max(0, content_length))
78+
try:
79+
payload = json.loads(body.decode("utf-8", errors="replace"))
80+
except json.JSONDecodeError:
81+
self._write_json(
82+
HTTPStatus.BAD_REQUEST, {"error": "bridge_invalid_json"}
83+
)
84+
return
85+
command = payload.get("command")
86+
timeout_seconds = payload.get("timeout_seconds")
87+
environment = payload.get("environment") or {}
88+
if not isinstance(command, str) or not command.strip():
89+
self._write_json(
90+
HTTPStatus.BAD_REQUEST, {"error": "bridge_invalid_command"}
91+
)
92+
return
93+
if not isinstance(timeout_seconds, int) or timeout_seconds <= 0:
94+
self._write_json(
95+
HTTPStatus.BAD_REQUEST, {"error": "bridge_invalid_timeout"}
96+
)
97+
return
98+
if not isinstance(environment, dict) or not all(
99+
isinstance(k, str) and isinstance(v, str)
100+
for k, v in environment.items()
101+
):
102+
self._write_json(
103+
HTTPStatus.BAD_REQUEST, {"error": "bridge_invalid_environment"}
104+
)
105+
return
106+
try:
107+
result = self.server.bridge_executor(
108+
command, timeout_seconds, environment
109+
)
110+
except Exception as e:
111+
self._write_json(
112+
HTTPStatus.INTERNAL_SERVER_ERROR,
113+
{"error": f"bridge_executor_failed:{e}"},
114+
)
115+
return
116+
self._write_json(
117+
HTTPStatus.OK,
118+
{
119+
"exit_code": result.exit_code,
120+
"stdout": result.stdout,
121+
"stderr": result.stderr,
122+
"timed_out": result.timed_out,
123+
},
124+
)
125+
126+
def _write_json(
127+
self, status: HTTPStatus, payload: dict[str, object]
128+
) -> None:
129+
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
130+
self.send_response(int(status))
131+
self.send_header("Content-Type", "application/json")
132+
self.send_header("Content-Length", str(len(body)))
133+
self.end_headers()
134+
self.wfile.write(body)
135+
136+
def log_message(self, format: str, *args: object) -> None:
137+
_ = (format, args)
138+
return
139+
140+
server = _BridgeServer()
141+
thread = threading.Thread(
142+
target=server.serve_forever,
143+
name="ash-browser-bridge",
144+
daemon=True,
145+
)
146+
thread.start()
147+
port = int(server.server_address[1])
148+
return cls(
149+
token=bridge_token,
150+
base_url=f"http://127.0.0.1:{port}",
151+
_server=server,
152+
_thread=thread,
153+
)
154+
155+
def stop(self) -> None:
156+
self._server.shutdown()
157+
self._server.server_close()
158+
self._thread.join(timeout=2.0)
159+
160+
161+
def request_bridge_exec(
162+
*,
163+
base_url: str,
164+
token: str,
165+
command: str,
166+
timeout_seconds: int,
167+
environment: dict[str, str] | None = None,
168+
) -> ExecutionResult:
169+
payload = json.dumps(
170+
{
171+
"command": command,
172+
"timeout_seconds": timeout_seconds,
173+
"environment": environment or {},
174+
},
175+
ensure_ascii=True,
176+
).encode("utf-8")
177+
request = Request( # noqa: S310
178+
f"{base_url.rstrip('/')}/exec",
179+
method="POST",
180+
data=payload,
181+
headers={
182+
"Content-Type": "application/json",
183+
"Authorization": f"Bearer {token}",
184+
},
185+
)
186+
try:
187+
with urlopen(request, timeout=max(5, timeout_seconds + 10)) as response: # noqa: S310
188+
body = response.read().decode("utf-8", errors="replace")
189+
except HTTPError as e:
190+
if e.code == int(HTTPStatus.UNAUTHORIZED):
191+
raise ValueError("bridge_unauthorized") from None
192+
raise ValueError(f"bridge_http_error:{e.code}") from None
193+
except URLError as e:
194+
raise ValueError(f"bridge_unreachable:{e}") from e
195+
parsed = json.loads(body)
196+
return ExecutionResult(
197+
exit_code=int(parsed.get("exit_code", 1)),
198+
stdout=str(parsed.get("stdout") or ""),
199+
stderr=str(parsed.get("stderr") or ""),
200+
timed_out=bool(parsed.get("timed_out")),
201+
)
202+
203+
204+
def make_docker_exec_bridge_executor(*, container_name: str) -> BridgeExecutor:
205+
def _execute(
206+
command: str, timeout_seconds: int, environment: dict[str, str]
207+
) -> ExecutionResult:
208+
env_args: list[str] = []
209+
for key, value in environment.items():
210+
env_args.extend(["-e", f"{key}={value}"])
211+
args = [
212+
"docker",
213+
"exec",
214+
*env_args,
215+
container_name,
216+
"bash",
217+
"-lc",
218+
command,
219+
]
220+
try:
221+
proc = subprocess.run( # noqa: S603
222+
args,
223+
capture_output=True,
224+
text=True,
225+
timeout=max(5, timeout_seconds + 10),
226+
check=False,
227+
)
228+
except subprocess.TimeoutExpired:
229+
return ExecutionResult(
230+
exit_code=-1,
231+
stdout="",
232+
stderr="bridge_command_timed_out",
233+
timed_out=True,
234+
)
235+
return ExecutionResult(
236+
exit_code=int(proc.returncode),
237+
stdout=proc.stdout or "",
238+
stderr=proc.stderr or "",
239+
)
240+
241+
return _execute

src/ash/browser/providers/sandbox.py

Lines changed: 49 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
from typing import Any
1818
from urllib.parse import urlparse
1919

20+
from ash.browser.bridge import (
21+
BrowserExecBridge,
22+
make_docker_exec_bridge_executor,
23+
request_bridge_exec,
24+
)
2025
from ash.browser.providers.base import (
2126
ProviderExtractResult,
2227
ProviderGotoResult,
@@ -35,6 +40,8 @@ class _RemoteSandboxRuntime:
3540
base_dir: str
3641
container_name: str | None = None
3742
host_port: int | None = None
43+
bridge_base_url: str | None = None
44+
bridge_token: str | None = None
3845

3946

4047
class SandboxBrowserProvider:
@@ -65,6 +72,7 @@ def __init__(
6572
self._session_targets: dict[str, str] = {}
6673
self._runtime: _RemoteSandboxRuntime | None = None
6774
self._active_scope_hash: str | None = None
75+
self._bridge: BrowserExecBridge | None = None
6876
self._runtime_lock = asyncio.Lock()
6977
self.runs_in_sandbox_executor = True
7078
self._runtime_restart_attempts = max(0, int(runtime_restart_attempts))
@@ -939,17 +947,29 @@ async def _launch_runtime(self, *, scope_hash: str) -> _RemoteSandboxRuntime:
939947
)
940948
raise ValueError(f"sandbox_browser_launch_failed: {message}")
941949

950+
if self._bridge is not None:
951+
self._bridge.stop()
952+
self._bridge = None
953+
bridge = BrowserExecBridge.start(
954+
executor=make_docker_exec_bridge_executor(container_name=container_name)
955+
)
956+
self._bridge = bridge
942957
host_port = await self._docker_resolve_host_port(container_name, 9222)
943958
runtime = _RemoteSandboxRuntime(
944959
port=9222,
945960
pid=0,
946961
base_dir="/tmp/ash-browser/runtime", # noqa: S108 - container-local runtime path
947962
container_name=container_name,
948963
host_port=host_port,
964+
bridge_base_url=bridge.base_url,
965+
bridge_token=bridge.token,
949966
)
950967
try:
951968
await self._wait_for_cdp_ready(runtime=runtime)
952969
except ValueError as e:
970+
bridge.stop()
971+
if self._bridge is bridge:
972+
self._bridge = None
953973
await self._kill_runtime(runtime)
954974
raise e
955975
logger.info(
@@ -997,6 +1017,9 @@ async def _is_runtime_healthy(self, runtime: _RemoteSandboxRuntime) -> bool:
9971017
return ws_ok
9981018

9991019
async def _kill_runtime(self, runtime: _RemoteSandboxRuntime) -> None:
1020+
if self._bridge is not None:
1021+
self._bridge.stop()
1022+
self._bridge = None
10001023
if runtime.container_name:
10011024
_ = await self._execute_host_command(
10021025
["docker", "rm", "-f", runtime.container_name],
@@ -1153,47 +1176,38 @@ async def _execute_sandbox_command(
11531176
raise ValueError(
11541177
"sandbox_browser_runtime_unavailable: dedicated_container_missing"
11551178
)
1156-
env_args: list[str] = []
1157-
for key, value in (environment or {}).items():
1158-
env_args.extend(["-e", f"{key}={value}"])
1159-
result = await self._execute_host_command(
1160-
[
1161-
"docker",
1162-
"exec",
1163-
*env_args,
1164-
active_runtime.container_name,
1165-
"bash",
1166-
"-lc",
1167-
command,
1168-
],
1169-
timeout_seconds=max(5, timeout_seconds + 10),
1179+
if not active_runtime.bridge_base_url or not active_runtime.bridge_token:
1180+
raise ValueError("sandbox_browser_runtime_unavailable: bridge_missing")
1181+
result = await self._execute_via_bridge(
1182+
runtime=active_runtime,
1183+
command=command,
1184+
timeout_seconds=timeout_seconds,
1185+
environment=environment,
11701186
)
11711187
if result.timed_out:
11721188
raise ValueError(
11731189
f"sandbox_browser_action_timeout:{phase}:{max(5, timeout_seconds + 10)}s"
11741190
)
11751191
return result
11761192

1177-
_MAX_LAUNCH_ATTEMPTS = 3
1178-
_MAX_LAUNCH_TOTAL_SECONDS = 45.0
1193+
async def _execute_via_bridge(
1194+
self,
1195+
*,
1196+
runtime: _RemoteSandboxRuntime,
1197+
command: str,
1198+
timeout_seconds: int,
1199+
environment: dict[str, str] | None = None,
1200+
) -> ExecutionResult:
1201+
if not runtime.bridge_base_url or not runtime.bridge_token:
1202+
raise ValueError("sandbox_browser_runtime_unavailable: bridge_missing")
1203+
return await asyncio.to_thread(
1204+
request_bridge_exec,
1205+
base_url=runtime.bridge_base_url,
1206+
token=runtime.bridge_token,
1207+
command=command,
1208+
timeout_seconds=max(1, timeout_seconds),
1209+
environment=environment or {},
1210+
)
1211+
11791212
_HTTP_READY_TIMEOUT_SECONDS = 12.0
11801213
_WS_READY_TIMEOUT_SECONDS = 8.0
1181-
_CHROMIUM_RUNTIME_FLAGS = (
1182-
"--headless=new",
1183-
"--no-sandbox",
1184-
"--disable-setuid-sandbox",
1185-
"--disable-dev-shm-usage",
1186-
"--disable-gpu",
1187-
"--no-first-run",
1188-
"--no-default-browser-check",
1189-
"--disable-sync",
1190-
"--disable-background-networking",
1191-
"--disable-component-update",
1192-
"--disable-features=Translate,MediaRouter",
1193-
"--disable-session-crashed-bubble",
1194-
"--hide-crash-restore-bubble",
1195-
"--password-store=basic",
1196-
"--disable-breakpad",
1197-
"--disable-crash-reporter",
1198-
"--metrics-recording-only",
1199-
)

0 commit comments

Comments
 (0)