This document describes guest port forwarding: making a TCP port inside a running sandbox reachable from the host, through the standalone sandbox-server, by tunneling raw bytes over the existing vsock channel.
It is the standalone-only path. The Kubernetes Service/Ingress routing
and the CRD template/claim port-declaration fields are explicit follow-ups
and are NOT built here (see “Follow-ups” below). For the
internet-facing, signed-URL, per-sandbox reverse-proxy exposure (the E2B
get_host(port) equivalent), see docs/preview-urls.md; that is a separate
mechanism. This document is about a plain host TCP socket bridged to a guest
port for local use.
What ships in this slice
A TCP-over-vsock tunnel that makes a guest port reachable through the standalone sandbox-server:
host TCP client ──▶ host listener (127.0.0.1:N) ──▶ vsock tunnel ──▶ guest agent ──▶ guest 127.0.0.1:<port>- gRPC
PortForwardstream (internal/sandboxrpc/portforward.go): a bidirectional gRPC stream over the guest vsock gRPC channel (vsock port 53). The host opens one stream per accepted connection and sends the target port; the guest dials127.0.0.1:<port>inside the VM and, on success, the stream becomes a raw bidirectional byte splice to the guest TCP socket until either side closes. One stream carries exactly one TCP connection (no multiplexing). - Guest agent port-forward handler (
guest/agent-rs/src/service/portforward.rs): dials LOOPBACK only, refuses a non-loopback or out-of-range port and a port with no listener with a clean error (never a hang), and splices both directions with full teardown on either close. - Host proxy (
internal/daemonSandboxAPI.ForwardPort/forward.go): opens a host TCP listener on127.0.0.1:0and bridges every accepted connection over a fresh vsock tunnel to the guest port. Listeners and their in-flight tunnels are tracked per sandbox and closed onUnregisterSandbox(terminate), so nothing outlives the sandbox. - sandbox-server
POST /v1/sandboxes/{id}/forward(cmd/sandbox-server): opens a forward and returns the host address to dial.
The endpoint
POST /v1/sandboxes/{id}/forward
Request body:
{ "guest_port": 8000 }Response (200):
{ "host": "127.0.0.1:53652", "guest_port": 8000 }The caller dials the returned host address with a plain TCP client; bytes are
piped to and from the guest’s 127.0.0.1:8000.
Error responses use the standard LLM-legible envelope
(docs/api/errors.md):
501in mock mode: forwarding bridges a real guest TCP socket, which mock mode does not have. Run sandbox-server in real mode (a KVM-backed engine).404for an unknown sandbox, or a sandbox whose guest agent is not connected.400for aguest_portoutside 1-65535.
A guest port with no listener does not fail the endpoint (the listener is already open by then); instead each connection to the host address is closed promptly when the guest refuses the tunnel, so a client sees a closed connection rather than a hang.
Security properties
These are recorded as a row in docs/threat-model.md (section 3); the summary:
- Loopback only, both ends. The host listener binds
127.0.0.1(reachable only from the host running the server), and the guest agent forces the dial to127.0.0.1(the host carries only a bare port; the tunnel cannot be steered to another guest interface or back out to the host network). - Per-sandbox concurrency cap. The number of concurrent forwards per sandbox
is bounded (default 16,
SetMaxForwardsPerSandbox, mirroring the streaming-exec ceiling) so one sandbox cannot exhaust host sockets. Each forward and all its tunnels are closed on terminate. - No auth on the host listener. This is the SAME tokenless trust model as the
rest of the standalone sandbox-server (a single-tenant local server that runs
with
AllowTokenless), not a new weakening. Auth on the forward path is not yet available; do not expose the standalone server’s forward listeners to an untrusted network. - No secret logging. The tunnel bytes are application traffic and are never logged; only the sandbox id, host address, and guest port are logged.
Follow-ups (NOT in this slice)
These are explicitly out of scope here:
- Kubernetes Service/Ingress routing for the forkd path (this slice is the standalone sandbox-server only).
- CRD template/sandbox port-declaration fields (declaring exposed ports on
SandboxPool.spec.template/Sandbox). - Auth on the forward path (a token gate on the host listener).
- UDP forwarding (this slice is TCP only).
Authenticated guest HTTP proxy (Mitos Expose)
GET|POST /v1/sandboxes/{id}/expose/{port}/<sub-path> reverse-proxies an HTTP
request to the guest’s 127.0.0.1:<port> over the vsock PortForward tunnel.
Unlike the raw /forward socket tunnel, this path speaks HTTP, streams the
response immediately (Server-Sent-Events safe, no buffering), and is gated by
the per-sandbox bearer token on forkd. On the standalone sandbox-server it
inherits the loopback tokenless trust model. The guest dial is forced to
loopback by the guest agent; the host never derives the dial target from request
input. Each request uses its own tunnel and guest TCP connection.