mirror of
https://github.com/browser-use/browser-use.git
synced 2026-09-14 19:59:47 +08:00
9d7a0c74ed
- New `tunnel` command: expose localhost ports via Cloudflare quick tunnels (`bu tunnel 3000` → trycloudflare.com URL, idempotent, auto-cleanup on session close) - New `skills/remote-browser/SKILL.md`: guides sandboxed agents through cloud browser + tunnel workflow - TunnelInfo dataclass on SessionInfo for tunnel lifecycle management - Tunnel dispatch in both SessionServer and CLI arg parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
3.6 KiB
Python
123 lines
3.6 KiB
Python
"""Tunnel commands - expose localhost ports via Cloudflare quick tunnels."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
import shutil
|
|
from typing import Any
|
|
|
|
from browser_use.skill_cli.sessions import SessionInfo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
COMMANDS = {'tunnel'}
|
|
|
|
_URL_PATTERN = re.compile(r'(https://\S+\.trycloudflare\.com)')
|
|
|
|
|
|
async def handle(action: str, session_info: SessionInfo, params: dict[str, Any]) -> Any:
|
|
"""Handle tunnel command."""
|
|
subcommand = params.get('subcommand', 'start')
|
|
|
|
if subcommand == 'list':
|
|
return list_tunnels(session_info)
|
|
elif subcommand == 'stop':
|
|
port = params.get('port')
|
|
if port is None:
|
|
return {'error': 'port is required for tunnel stop'}
|
|
return await stop_tunnel(session_info, int(port))
|
|
else:
|
|
# Default: start
|
|
port = params.get('port')
|
|
if port is None:
|
|
return {'error': 'port is required for tunnel start'}
|
|
return await start_tunnel(session_info, int(port))
|
|
|
|
|
|
async def start_tunnel(session_info: SessionInfo, port: int) -> dict[str, Any]:
|
|
"""Start a cloudflare quick tunnel for a local port."""
|
|
# Check if tunnel already exists for this port
|
|
if port in session_info.tunnels:
|
|
info = session_info.tunnels[port]
|
|
return {'url': info.url, 'port': port, 'existing': True}
|
|
|
|
# Check cloudflared binary
|
|
if not shutil.which('cloudflared'):
|
|
return {
|
|
'error': 'cloudflared not found. Install it:\n'
|
|
' macOS: brew install cloudflared\n'
|
|
' Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n'
|
|
' Windows: winget install Cloudflare.cloudflared',
|
|
}
|
|
|
|
# Spawn cloudflared process
|
|
process = await asyncio.create_subprocess_exec(
|
|
'cloudflared',
|
|
'tunnel',
|
|
'--url',
|
|
f'http://localhost:{port}',
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
|
|
# Read stderr lines until we find the tunnel URL
|
|
assert process.stderr is not None
|
|
url: str | None = None
|
|
try:
|
|
deadline = asyncio.get_event_loop().time() + 15
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
try:
|
|
line_bytes = await asyncio.wait_for(process.stderr.readline(), timeout=1.0)
|
|
except TimeoutError:
|
|
continue
|
|
if not line_bytes:
|
|
break
|
|
line = line_bytes.decode(errors='replace')
|
|
match = _URL_PATTERN.search(line)
|
|
if match:
|
|
url = match.group(1)
|
|
break
|
|
except Exception as e:
|
|
process.terminate()
|
|
return {'error': f'Failed to start tunnel: {e}'}
|
|
|
|
if url is None:
|
|
process.terminate()
|
|
return {'error': 'Timed out waiting for cloudflare tunnel URL (15s)'}
|
|
|
|
# Store tunnel info
|
|
from browser_use.skill_cli.sessions import TunnelInfo
|
|
|
|
session_info.tunnels[port] = TunnelInfo(port=port, url=url, process=process)
|
|
logger.info(f'Tunnel started: localhost:{port} -> {url}')
|
|
|
|
return {'url': url, 'port': port}
|
|
|
|
|
|
def list_tunnels(session_info: SessionInfo) -> dict[str, Any]:
|
|
"""List active tunnels."""
|
|
tunnels = [{'port': info.port, 'url': info.url} for info in session_info.tunnels.values()]
|
|
return {'tunnels': tunnels, 'count': len(tunnels)}
|
|
|
|
|
|
async def stop_tunnel(session_info: SessionInfo, port: int) -> dict[str, Any]:
|
|
"""Stop a tunnel for a specific port."""
|
|
if port not in session_info.tunnels:
|
|
return {'error': f'No tunnel running on port {port}'}
|
|
|
|
info = session_info.tunnels.pop(port)
|
|
info.process.terminate()
|
|
try:
|
|
await asyncio.wait_for(info.process.wait(), timeout=5)
|
|
except TimeoutError:
|
|
info.process.kill()
|
|
logger.info(f'Tunnel stopped: localhost:{port}')
|
|
|
|
return {'stopped': port, 'url': info.url}
|
|
|
|
|
|
async def stop_all_tunnels(session_info: SessionInfo) -> None:
|
|
"""Stop all tunnels. Called during session cleanup."""
|
|
for port in list(session_info.tunnels.keys()):
|
|
await stop_tunnel(session_info, port)
|