Files
Gregor Žunič 9d7a0c74ed Add bu tunnel command and remote-browser skill
- 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>
2026-02-09 10:14:42 -08:00

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)