Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,37 @@ sbx run my-sbx --mount /home/me/src/tooling:/workspace/tooling

# Disable automatic OAuth callback forwarding.
sbx run my-sbx --no-auth-port
```

Port forwarding lets your host connect to a TCP service running inside the sandbox, such as a dev server or web app. `sbx network forward` runs in the foreground; Ctrl-C stops the forwarding.

A forward `SPEC` maps a host address/port to a guest port:

```text
GUEST_PORT host 127.0.0.1:GUEST_PORT -> guest 127.0.0.1:GUEST_PORT
HOST_PORT:GUEST_PORT host 127.0.0.1:HOST_PORT -> guest 127.0.0.1:GUEST_PORT
BIND_HOST:HOST_PORT:GUEST_PORT
host BIND_HOST:HOST_PORT -> guest 127.0.0.1:GUEST_PORT
```

Examples:

# Temporarily forward a running guest web server until Ctrl-C.
```text
3000 host 127.0.0.1:3000 -> guest 127.0.0.1:3000
8080:80 host 127.0.0.1:8080 -> guest 127.0.0.1:80
0.0.0.0:8080:80 host 0.0.0.0:8080 -> guest 127.0.0.1:80
```

You can pass multiple specs in one command; one Ctrl-C stops all of them:

```bash
sbx network forward my-sbx 3000
sbx network forward 8080:3000
sbx network forward 0.0.0.0:3000:3000
sbx network forward my-sbx 3000 8080:80
```

```bash
# Keep the VM running after the agent/shell exits.
sbx run my-sbx --keep-running
sbx shell my-sbx --keep-running
Expand Down Expand Up @@ -116,7 +141,7 @@ sbx run my-sbx --agent claude
| `shell [NAME]` | Open a shell in a sandbox. |
| `ls` | List running sandboxes. Use `ls -a` / `ls --all` to include stopped ones. |
| `network status [NAME]` | Expert helper: show sandbox networking and auth callback tunnel status. |
| `network forward [NAME] SPEC` | Temporarily forward a host TCP port to a running sandbox until Ctrl-C. |
| `network forward [NAME] SPEC...` | Temporarily forward host TCP ports to a running sandbox until Ctrl-C. |
| `network auth-port [NAME]` | Expert helper: manually expose the OAuth callback port for an already-running sandbox. |
| `network close-auth-port [NAME]` | Expert helper: close the tracked OAuth callback tunnel. |
| `image build-debian` | Advanced helper: build a local Debian/Pi image, optionally with `--with-docker`. |
Expand Down
14 changes: 12 additions & 2 deletions docs/network-command-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@ sbx network ...

## Current commands

### `sbx network forward [NAME] SPEC`
### `sbx network forward [NAME] SPEC...`

Forwards a host TCP port to a running sandbox in the foreground. Press Ctrl-C to stop.
Forwards host TCP ports to a running sandbox in the foreground. Press Ctrl-C to stop all forwards.

```bash
sbx network forward 3000
sbx network forward 8080:3000
sbx network forward 0.0.0.0:3000:3000
sbx network forward 3000 8080:80
sbx network forward my-sbx 3000 8080:80
```

`SPEC` is one of:

```text
3000 host 127.0.0.1:3000 -> guest 127.0.0.1:3000
8080:80 host 127.0.0.1:8080 -> guest 127.0.0.1:80
0.0.0.0:8080:80 host 0.0.0.0:8080 -> guest 127.0.0.1:80
```

Configured forwards live in `.sbx.toml` and are applied when the VM starts:
Expand Down
4 changes: 2 additions & 2 deletions src/sbx/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2101,8 +2101,8 @@ def build_parser() -> argparse.ArgumentParser:
forward.add_argument(
"forward_args",
nargs="+",
metavar="[NAME] SPEC",
help="Forward spec: GUEST_PORT, HOST_PORT:GUEST_PORT, or BIND_HOST:HOST_PORT:GUEST_PORT.",
metavar="[NAME] SPEC...",
help="Forward specs: GUEST_PORT, HOST_PORT:GUEST_PORT, or BIND_HOST:HOST_PORT:GUEST_PORT.",
)
forward.set_defaults(func=network.cmd_forward)

Expand Down
36 changes: 18 additions & 18 deletions src/sbx/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,36 +216,36 @@ def expose_auth_port(vm_id: str, host_port: int, guest_port: int, *, replace: bo
return 1


def _foreground_port_forward(vm_id: str, forward: tuple[str, int, int]) -> int:
host, host_port, guest_port = forward
def _foreground_port_forward(vm_id: str, forwards: Sequence[tuple[str, int, int]]) -> int:
cmd = ssh_command(vm_id)
cmd[-1:-1] = [
"-N",
"-L",
f"{host}:{host_port}:127.0.0.1:{guest_port}",
"-o",
"ExitOnForwardFailure=yes",
]
print(f"Forwarding {host}:{host_port} -> guest 127.0.0.1:{guest_port}")
forward_args = ["-N"]
for host, host_port, guest_port in forwards:
forward_args.extend(["-L", f"{host}:{host_port}:127.0.0.1:{guest_port}"])
forward_args.extend(["-o", "ExitOnForwardFailure=yes"])
cmd[-1:-1] = forward_args
for host, host_port, guest_port in forwards:
print(f"Forwarding {host}:{host_port} -> guest 127.0.0.1:{guest_port}")
print("Press Ctrl-C to stop.")
return run(cmd)


def cmd_forward(args: argparse.Namespace) -> int:
if len(args.forward_args) == 1:
spec = args.forward_args[0]
name = None
specs = args.forward_args
if len(specs) > 1:
try:
parse_port_forward(specs[0])
except ConfigError:
name = specs[0]
specs = specs[1:]
if name is None:
name = vm_name_from_arg_or_config(
args, getattr(args, "config_data", None), "network forward"
)
elif len(args.forward_args) == 2:
name, spec = args.forward_args
else:
print("sbx: network forward expects [NAME] SPEC", file=sys.stderr)
return 2
if name is None:
return 2
try:
return _foreground_port_forward(str(name), parse_port_forward(spec))
return _foreground_port_forward(str(name), [parse_port_forward(spec) for spec in specs])
except ConfigError as exc:
print(f"sbx: {exc}", file=sys.stderr)
return 2
Expand Down
49 changes: 43 additions & 6 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1402,17 +1402,17 @@ def test_network_forward_defaults_to_configured_name(
config.write_text('[sbx]\nname = "vm1"\n', encoding="utf-8")
captured: dict[str, object] = {}

def fake_forward(vm_id: str, forward: tuple[str, int, int]) -> int:
def fake_forward(vm_id: str, forwards: list[tuple[str, int, int]]) -> int:
captured["vm_id"] = vm_id
captured["forward"] = forward
captured["forwards"] = forwards
return 0

monkeypatch.setattr(cli.network, "_foreground_port_forward", fake_forward)

assert cli.main(["--config", str(config), "network", "forward", "8080:3000"]) == 0
assert captured == {
"vm_id": "vm1",
"forward": ("127.0.0.1", 8080, 3000),
"forwards": [("127.0.0.1", 8080, 3000)],
}


Expand All @@ -1421,18 +1421,55 @@ def test_network_forward_accepts_explicit_name(monkeypatch: pytest.MonkeyPatch)
monkeypatch.setattr(
cli.network,
"_foreground_port_forward",
lambda vm_id, forward: captured.update({"vm_id": vm_id, "forward": forward}) or 0,
lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0,
)

assert cli.main(["network", "forward", "vm2", "0.0.0.0:3000:3000"]) == 0
assert captured == {
"vm_id": "vm2",
"forward": ("0.0.0.0", 3000, 3000),
"forwards": [("0.0.0.0", 3000, 3000)],
}


def test_network_forward_accepts_multiple_specs_from_config(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = tmp_path / "config.toml"
config.write_text('[sbx]\nname = "vm1"\n', encoding="utf-8")
captured: dict[str, object] = {}
monkeypatch.setattr(
cli.network,
"_foreground_port_forward",
lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0,
)

assert cli.main(["--config", str(config), "network", "forward", "3000", "8080:80"]) == 0
assert captured == {
"vm_id": "vm1",
"forwards": [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)],
}


def test_network_forward_accepts_explicit_name_with_multiple_specs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
monkeypatch.setattr(
cli.network,
"_foreground_port_forward",
lambda vm_id, forwards: captured.update({"vm_id": vm_id, "forwards": forwards}) or 0,
)

assert cli.main(["network", "forward", "vm2", "3000", "8080:80"]) == 0
assert captured == {
"vm_id": "vm2",
"forwards": [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)],
}


def test_network_forward_ctrl_c_returns_130(monkeypatch: pytest.MonkeyPatch) -> None:
def interrupted(vm_id: str, forward: tuple[str, int, int]) -> int:
def interrupted(vm_id: str, forwards: list[tuple[str, int, int]]) -> int:
raise KeyboardInterrupt

monkeypatch.setattr(cli.network, "_foreground_port_forward", interrupted)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_cli_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,33 @@ def poll(self) -> int:
assert cli.network.expose_auth_port("vm1", 1, 2) == 1


def test_foreground_port_forward_uses_one_ssh_for_multiple_ports(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, list[str]] = {}
def fake_run(argv: list[str]) -> int:
captured["argv"] = list(argv)
return 0

monkeypatch.setattr(cli.network, "ssh_command", lambda vm_id: ["ssh", "root@host"])
monkeypatch.setattr(cli.network, "run", fake_run)

assert cli.network._foreground_port_forward(
"vm1", [("127.0.0.1", 3000, 3000), ("127.0.0.1", 8080, 80)]
) == 0
assert captured["argv"] == [
"ssh",
"-N",
"-L",
"127.0.0.1:3000:127.0.0.1:3000",
"-L",
"127.0.0.1:8080:127.0.0.1:80",
"-o",
"ExitOnForwardFailure=yes",
"root@host",
]


def test_delete_vm_error_paths(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down