"""TCP tunnel for accessing services running inside sandboxes. Establishes a WebSocket connection to the daemon's ``/tunnel`` endpoint, runs a yamux multiplexing session on top, and forwards local TCP connections through yamux streams to the target port inside the sandbox. """ from __future__ import annotations import asyncio import logging import socket import struct import threading import time from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Optional from langsmith.sandbox._helpers import merge_headers if TYPE_CHECKING: from langsmith.sandbox._yamux import YamuxSession, YamuxStream logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Tunnel connect-header protocol (layered on top of yamux streams) # --------------------------------------------------------------------------- PROTOCOL_VERSION = 0x01 STATUS_OK = 0x00 STATUS_PORT_NOT_ALLOWED = 0x01 STATUS_DIAL_FAILED = 0x02 STATUS_UNSUPPORTED_VERSION = 0x03 _CONNECT_HEADER_FMT = ">BH" # version(1) + port(2, big-endian) def _write_connect_header(stream: YamuxStream, port: int) -> None: """Write the 3-byte connect header on a freshly opened yamux stream.""" stream.write(struct.pack(_CONNECT_HEADER_FMT, PROTOCOL_VERSION, port)) def _read_status(stream: YamuxStream) -> int: """Read the 1-byte status response from the daemon.""" data = stream.read(1) if not data: raise ConnectionError("tunnel: connection closed before status") return data[0] # --------------------------------------------------------------------------- # WebSocket adapter # --------------------------------------------------------------------------- class _WSAdapter: """Adapts the ``websockets`` message API to a byte-stream interface. yamux requires a plain read/write/close byte stream. WebSocket is message-based, so this adapter buffers partially consumed messages on reads and sends one binary message per write. """ def __init__(self, ws: Any) -> None: self._ws = ws self._buf = bytearray() self._write_lock = threading.Lock() def read(self, n: int) -> bytes: while len(self._buf) < n: msg = self._ws.recv() if isinstance(msg, str): msg = msg.encode() self._buf.extend(msg) result = bytes(self._buf[:n]) del self._buf[:n] return result def write(self, data: bytes) -> int: with self._write_lock: self._ws.send(data) return len(data) def close(self) -> None: try: self._ws.close() except Exception: pass # --------------------------------------------------------------------------- # Bridge: bidirectional copy between yamux stream and TCP socket # --------------------------------------------------------------------------- _BRIDGE_BUF_SIZE = 16384 def _bridge(stream: YamuxStream, tcp_conn: socket.socket) -> None: """Copy data bidirectionally until one side closes or errors.""" done = threading.Event() def _stream_to_tcp() -> None: try: while True: data = stream.read(_BRIDGE_BUF_SIZE) if not data: break tcp_conn.sendall(data) except Exception: pass finally: done.set() def _tcp_to_stream() -> None: try: while True: data = tcp_conn.recv(_BRIDGE_BUF_SIZE) if not data: break stream.write(data) except Exception: pass finally: done.set() t1 = threading.Thread(target=_stream_to_tcp, daemon=True) t2 = threading.Thread(target=_tcp_to_stream, daemon=True) t1.start() t2.start() done.wait() try: stream.close() except Exception: pass try: tcp_conn.shutdown(socket.SHUT_RDWR) except OSError: pass try: tcp_conn.close() except OSError: pass t1.join(timeout=5) t2.join(timeout=5) # --------------------------------------------------------------------------- # Tunnel # --------------------------------------------------------------------------- def _ensure_websockets(): """Import websockets sync client or raise a clear error.""" try: from websockets.sync.client import connect as ws_connect return ws_connect except ImportError: raise ImportError( "TCP tunnel requires the 'websockets' package. " "Install it with: pip install 'langsmith[sandbox]'" ) from None class Tunnel: """TCP tunnel to a port inside a sandbox. Opens a local TCP listener and forwards each accepted connection through a yamux-multiplexed WebSocket to the daemon, which dials the target port inside the sandbox. Typically used as a context manager:: with sandbox.tunnel(remote_port=5432) as t: conn = psycopg2.connect(host="127.0.0.1", port=t.local_port) Or with explicit lifecycle:: t = sandbox.tunnel(remote_port=5432) # ... use tunnel ... t.close() """ _BACKOFF_BASE = 0.5 _BACKOFF_MAX = 8.0 def __init__( self, dataplane_url: str, api_key: Optional[str], remote_port: int, *, local_port: int = 0, max_reconnects: int = 3, headers: Optional[Mapping[str, str]] = None, ) -> None: self._dataplane_url = dataplane_url self._api_key = api_key self._headers = headers self._remote_port = remote_port self._requested_local_port = local_port or remote_port self._local_port = self._requested_local_port self._max_reconnects = max_reconnects self._ws: object = None self._yamux: Optional[YamuxSession] = None self._server_socket: Optional[socket.socket] = None self._accept_thread: Optional[threading.Thread] = None self._reconnect_lock = threading.Lock() self._closed = False self._started = False @property def local_port(self) -> int: """Local port the tunnel is listening on.""" return self._local_port @property def remote_port(self) -> int: """Port inside the sandbox that the tunnel connects to.""" return self._remote_port # -- Context manager ---------------------------------------------------- def __enter__(self) -> Tunnel: return self def __exit__(self, *args: object) -> None: self.close() # -- Lifecycle ---------------------------------------------------------- def _start(self) -> None: if self._started: return self._started = True try: self._do_start() except Exception: self.close() raise def _do_start(self) -> None: self._connect() self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Check if another process is actively listening on this port. # SO_REUSEADDR lets us rebind over TIME_WAIT, but we don't want to # silently steal a port from a running service. port = self._requested_local_port if port != 0: probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: probe.settimeout(0.5) probe.connect(("127.0.0.1", port)) probe.close() raise OSError( f"Port {port} is already in use by another service. " f"Choose a different local_port." ) except ConnectionRefusedError: pass # nothing listening — safe to bind except OSError as e: if "Connection refused" in str(e): pass # same as above, different OS error message elif "already in use" in str(e).lower(): raise else: pass # TIME_WAIT or other transient state — safe to bind finally: try: probe.close() except OSError: pass self._server_socket.bind(("127.0.0.1", self._requested_local_port)) self._server_socket.listen(128) self._local_port = self._server_socket.getsockname()[1] self._accept_thread = threading.Thread( target=self._accept_loop, daemon=True, name="tunnel-accept" ) self._accept_thread.start() def _connect(self) -> None: """Establish (or re-establish) the WebSocket + yamux session.""" from langsmith.sandbox._yamux import YamuxSession old_yamux = self._yamux if old_yamux: try: old_yamux.close() except Exception: pass ws_connect = _ensure_websockets() ws_url = self._build_ws_url() headers = merge_headers( {"X-Api-Key": self._api_key} if self._api_key else None, self._headers, ) self._ws = ws_connect( ws_url, additional_headers=headers, open_timeout=15, close_timeout=5, ping_interval=None, # yamux handles keepalive ) adapter = _WSAdapter(self._ws) self._yamux = YamuxSession(adapter) def _ensure_session(self) -> YamuxSession: """Return a live yamux session, reconnecting if needed.""" from langsmith.sandbox._exceptions import TunnelError if self._yamux and not self._yamux.is_closed: return self._yamux with self._reconnect_lock: if self._yamux and not self._yamux.is_closed: return self._yamux last_err: Optional[Exception] = None for attempt in range(self._max_reconnects): try: self._connect() logger.debug("tunnel: reconnected (attempt %d)", attempt + 1) return self._yamux # type: ignore[return-value] except Exception as exc: last_err = exc if attempt < self._max_reconnects - 1: delay = min( self._BACKOFF_BASE * (2**attempt), self._BACKOFF_MAX, ) time.sleep(delay) raise TunnelError( f"tunnel: reconnect failed after {self._max_reconnects} attempts" ) from last_err def close(self) -> None: """Shut down the tunnel, closing all connections.""" if self._closed: return self._closed = True if self._server_socket: try: self._server_socket.close() except OSError: pass if self._yamux: self._yamux.close() # -- Internal ----------------------------------------------------------- def _accept_loop(self) -> None: while not self._closed: try: conn, _ = self._server_socket.accept() # type: ignore[union-attr] except OSError: break threading.Thread( target=self._handle_conn, args=(conn,), daemon=True, name="tunnel-bridge", ).start() def _handle_conn(self, tcp_conn: socket.socket) -> None: try: session = self._ensure_session() stream = session.open_stream() _write_connect_header(stream, self._remote_port) status = _read_status(stream) if status == STATUS_OK: _bridge(stream, tcp_conn) return stream.close() tcp_conn.close() if status == STATUS_PORT_NOT_ALLOWED: logger.warning( "tunnel: port %d not allowed by daemon", self._remote_port, ) elif status == STATUS_DIAL_FAILED: logger.warning( "tunnel: nothing listening on port %d inside sandbox", self._remote_port, ) elif status == STATUS_UNSUPPORTED_VERSION: logger.warning( "tunnel: protocol version mismatch (client v%d)", PROTOCOL_VERSION, ) else: logger.warning("tunnel: unknown status %d", status) except Exception as exc: logger.debug("tunnel: connection handler error: %s", exc) try: tcp_conn.close() except OSError: pass def _build_ws_url(self) -> str: url = self._dataplane_url.rstrip("/") url = url.replace("https://", "wss://").replace("http://", "ws://") return f"{url}/tunnel" # --------------------------------------------------------------------------- # AsyncTunnel # --------------------------------------------------------------------------- class AsyncTunnel: """Async wrapper around :class:`Tunnel`. The underlying tunnel runs in background threads (TCP listener + bridges); async context-manager methods delegate to the sync tunnel via the event loop's executor. Usage:: async with await sandbox.tunnel(remote_port=5432) as t: conn = await asyncpg.connect(host="127.0.0.1", port=t.local_port) """ def __init__( self, dataplane_url: str, api_key: Optional[str], remote_port: int, *, local_port: int = 0, max_reconnects: int = 3, headers: Optional[Mapping[str, str]] = None, ) -> None: self._tunnel = Tunnel( dataplane_url, api_key, remote_port, local_port=local_port, max_reconnects=max_reconnects, headers=headers, ) @property def local_port(self) -> int: return self._tunnel.local_port @property def remote_port(self) -> int: return self._tunnel.remote_port async def __aenter__(self) -> AsyncTunnel: loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._tunnel._start) return self async def __aexit__(self, *args: object) -> None: loop = asyncio.get_running_loop() await loop.run_in_executor(None, self._tunnel.close) def close(self) -> None: """Shut down the tunnel (sync, safe to call from any context).""" self._tunnel.close()