From c80dcdf90f409143860a165656f270b7eea1acd1 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Mon, 1 Dec 2025 23:09:17 -0600 Subject: [PATCH 1/7] expose ports - tcp --- .../src/prime_sandboxes/models.py | 2 ++ .../src/prime_sandboxes/sandbox.py | 10 +++--- .../prime/src/prime_cli/commands/sandbox.py | 32 +++++++++++++++---- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/prime-sandboxes/src/prime_sandboxes/models.py b/packages/prime-sandboxes/src/prime_sandboxes/models.py index 4f08f0e2..f8ab91a7 100644 --- a/packages/prime-sandboxes/src/prime_sandboxes/models.py +++ b/packages/prime-sandboxes/src/prime_sandboxes/models.py @@ -156,6 +156,7 @@ class ExposePortRequest(BaseModel): port: int name: Optional[str] = None + protocol: str = "HTTP" # HTTP or TCP/UDP class ExposedPort(BaseModel): @@ -168,6 +169,7 @@ class ExposedPort(BaseModel): url: str tls_socket: str protocol: Optional[str] = None + external_port: Optional[int] = None # For TCP/UDP exposures created_at: Optional[str] = None diff --git a/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py b/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py index cd111c6c..70417f3e 100644 --- a/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py +++ b/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py @@ -550,9 +550,10 @@ def expose( sandbox_id: str, port: int, name: Optional[str] = None, + protocol: str = "HTTP", ) -> ExposedPort: - """Expose an HTTP port from a sandbox.""" - request = ExposePortRequest(port=port, name=name) + """Expose a port from a sandbox.""" + request = ExposePortRequest(port=port, name=name, protocol=protocol) response = self.client.request( "POST", f"/sandbox/{sandbox_id}/expose", @@ -1006,9 +1007,10 @@ async def expose( sandbox_id: str, port: int, name: Optional[str] = None, + protocol: str = "HTTP", ) -> ExposedPort: - """Expose an HTTP port from a sandbox.""" - request = ExposePortRequest(port=port, name=name) + """Expose a port from a sandbox.""" + request = ExposePortRequest(port=port, name=name, protocol=protocol) response = await self.client.request( "POST", f"/sandbox/{sandbox_id}/expose", diff --git a/packages/prime/src/prime_cli/commands/sandbox.py b/packages/prime/src/prime_cli/commands/sandbox.py index 046ae13a..f477b0a6 100644 --- a/packages/prime/src/prime_cli/commands/sandbox.py +++ b/packages/prime/src/prime_cli/commands/sandbox.py @@ -918,20 +918,34 @@ def expose_port( sandbox_id: str = typer.Argument(..., help="Sandbox ID to expose port from"), port: int = typer.Argument(..., help="Port number to expose"), name: Optional[str] = typer.Option(None, help="Optional name for the exposed port"), + protocol: str = typer.Option( + "HTTP", + "--protocol", + "-p", + help="Protocol: HTTP or TCP/UDP", + ), output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"), ) -> None: - """Expose an HTTP port from a sandbox. + """Expose a port from a sandbox. - Currently only HTTP is supported. TCP, UDP, and SSH support coming soon. + Protocols: + - HTTP: Exposed via Cloudflare Tunnel with HTTPS URL (default) + - TCP/UDP: Exposed via LoadBalancer with direct TCP/UDP access """ validate_output_format(output, console) + # Validate protocol + protocol = protocol.upper() + if protocol not in ("HTTP", "TCP", "UDP"): + console.print(f"[red]Error:[/red] Invalid protocol '{protocol}'. Use HTTP, TCP, or UDP.") + raise typer.Exit(1) + try: base_client = APIClient() sandbox_client = SandboxClient(base_client) with console.status("[bold blue]Exposing port...", spinner="dots"): - exposed = sandbox_client.expose(sandbox_id, port, name) + exposed = sandbox_client.expose(sandbox_id, port, name, protocol) if output == "json": output_data_as_json(exposed.model_dump(), console) @@ -939,9 +953,12 @@ def expose_port( console.print("[green]✓[/green] Port exposed successfully!") console.print(f"[bold green]Exposure ID:[/bold green] {exposed.exposure_id}") console.print(f"[bold green]Port:[/bold green] {exposed.port}") + console.print(f"[bold green]Protocol:[/bold green] {exposed.protocol or protocol}") if exposed.name: console.print(f"[bold green]Name:[/bold green] {exposed.name}") console.print(f"[bold green]URL:[/bold green] {exposed.url}") + if protocol in ("TCP", "UDP") and exposed.external_port: + console.print(f"[bold green]External Port:[/bold green] {exposed.external_port}") console.print(f"[bold green]TLS Socket:[/bold green] {exposed.tls_socket}") except APIError as e: @@ -1011,20 +1028,23 @@ def list_ports( f"Exposed Ports for Sandbox {sandbox_id}", [ ("Exposure ID", "cyan"), + ("Protocol", "white"), ("Port", "blue"), + ("External", "blue"), ("Name", "green"), ("URL", "magenta"), - ("TLS Socket", "yellow"), ], ) for exp in response.exposures: + external_port = str(exp.external_port) if exp.external_port else "-" table.add_row( exp.exposure_id, + exp.protocol or "HTTP", str(exp.port), - exp.name or "N/A", + external_port, + exp.name or "-", exp.url, - exp.tls_socket, ) console.print(table) From 6c8ad35e69666b6bbe46cc8cfa770c6c3a56b7ea Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Mon, 1 Dec 2025 23:11:04 -0600 Subject: [PATCH 2/7] clean up docstring --- packages/prime/src/prime_cli/commands/sandbox.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/prime/src/prime_cli/commands/sandbox.py b/packages/prime/src/prime_cli/commands/sandbox.py index f477b0a6..b8a82a73 100644 --- a/packages/prime/src/prime_cli/commands/sandbox.py +++ b/packages/prime/src/prime_cli/commands/sandbox.py @@ -926,12 +926,7 @@ def expose_port( ), output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"), ) -> None: - """Expose a port from a sandbox. - - Protocols: - - HTTP: Exposed via Cloudflare Tunnel with HTTPS URL (default) - - TCP/UDP: Exposed via LoadBalancer with direct TCP/UDP access - """ + """Expose a port from a sandbox.""" validate_output_format(output, console) # Validate protocol From ad142fa284377f0653459fdf0587cd0c88c6a20d Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 4 Dec 2025 16:22:52 -0800 Subject: [PATCH 3/7] port expose example --- examples/sandbox_port_expose_demo.py | 195 +++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 examples/sandbox_port_expose_demo.py diff --git a/examples/sandbox_port_expose_demo.py b/examples/sandbox_port_expose_demo.py new file mode 100644 index 00000000..65991f1f --- /dev/null +++ b/examples/sandbox_port_expose_demo.py @@ -0,0 +1,195 @@ +import socket +import time +import urllib.request + +from prime_sandboxes import APIClient, APIError, CreateSandboxRequest, SandboxClient + + +def verify_http(url: str) -> bool: + """Verify HTTP endpoint is accessible and returns expected response.""" + try: + # Add User-Agent header to avoid 403 from bot protection + req = urllib.request.Request(url, headers={"User-Agent": "curl/8.0"}) + with urllib.request.urlopen(req, timeout=10) as response: + status = response.getcode() + body = response.read().decode("utf-8") + # Python's http.server returns a directory listing HTML + if status == 200 and "Directory listing" in body: + return True + return False + except Exception as e: + print(f" HTTP verification error: {e}") + return False + + +def verify_tcp(tls_socket: str, test_message: bytes = b"Hello") -> bool: + """Verify TCP endpoint is accessible and echoes back data.""" + try: + # Parse host:port from socket address + host, port_str = tls_socket.rsplit(":", 1) + port = int(port_str) + + # Connect with raw TCP + with socket.create_connection((host, port), timeout=10) as sock: + sock.sendall(test_message) + response = sock.recv(1024) + expected = b"Echo: " + test_message + return response == expected + except Exception as e: + print(f" TCP verification error: {e}") + return False + + +def main() -> None: + """Demonstrate HTTP and TCP port exposure""" + try: + client = APIClient() + sandbox_client = SandboxClient(client) + + request = CreateSandboxRequest( + name="port-expose-demo", + docker_image="python:3.11-slim", + start_command="tail -f /dev/null", + cpu_cores=1, + memory_gb=2, + timeout_minutes=30, + ) + + print("Creating sandbox...") + sandbox = sandbox_client.create(request) + print(f"Created: {sandbox.name} ({sandbox.id})") + + print("\nWaiting for sandbox to be running...") + sandbox_client.wait_for_creation(sandbox.id, max_attempts=60) + print("Sandbox is running!") + + print("\n--- HTTP Port Exposure ---") + print("Starting HTTP server on port 8000...") + sandbox_client.execute_command( + sandbox.id, + "nohup python -m http.server 8000 > /tmp/http.log 2>&1 &", + ) + time.sleep(2) # Give server time to start + + # Expose the HTTP port + http_exposure = sandbox_client.expose( + sandbox_id=sandbox.id, + port=8000, + name="web-server", + protocol="HTTP", + ) + print("HTTP port exposed!") + print(f" Exposure ID: {http_exposure.exposure_id}") + print(f" URL: {http_exposure.url}") + print(f" TLS Socket: {http_exposure.tls_socket}") + time.sleep(10) + + # Verify HTTP endpoint is accessible + print(" Verifying HTTP endpoint...") + if verify_http(http_exposure.url): + print(" HTTP verification: SUCCESS") + else: + print(" HTTP verification: FAILED") + + # Start a TCP echo server in the sandbox + print("\n--- TCP Port Exposure ---") + print("Starting TCP echo server on port 9000...") + + # Create a simple TCP echo server + tcp_server_code = """ +import socket +import threading + +def handle_client(conn, addr): + print(f"Connection from {addr}") + while True: + data = conn.recv(1024) + if not data: + break + conn.sendall(b"Echo: " + data) + conn.close() + +server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +server.bind(("0.0.0.0", 9000)) +server.listen(5) +print("TCP server listening on port 9000") + +while True: + conn, addr = server.accept() + thread = threading.Thread(target=handle_client, args=(conn, addr)) + thread.daemon = True + thread.start() +""" + # Write and run the TCP server + sandbox_client.execute_command( + sandbox.id, + f"cat > /tmp/tcp_server.py << 'SCRIPT'\n{tcp_server_code}\nSCRIPT", + ) + sandbox_client.execute_command( + sandbox.id, + "nohup python /tmp/tcp_server.py > /tmp/tcp.log 2>&1 &", + ) + time.sleep(2) # Give server time to start + + # Expose the TCP port + tcp_exposure = sandbox_client.expose( + sandbox_id=sandbox.id, + port=9000, + name="echo-server", + protocol="TCP", + ) + print("TCP port exposed!") + print(f" Exposure ID: {tcp_exposure.exposure_id}") + print(f" TLS Socket: {tcp_exposure.tls_socket}") + if tcp_exposure.external_port: + print(f" External Port: {tcp_exposure.external_port}") + time.sleep(120) + + # Verify TCP endpoint is accessible + print(" Verifying TCP endpoint...") + if verify_tcp(tcp_exposure.tls_socket): + print(" TCP verification: SUCCESS (echo server responded correctly)") + else: + print(" TCP verification: FAILED") + + # List all exposed ports + print("\n--- All Exposed Ports ---") + ports_response = sandbox_client.list_exposed_ports(sandbox.id) + for port in ports_response.exposures: + print(f" {port.name} (port {port.port}):") + print(f" Protocol: {port.protocol}") + print(f" Exposure ID: {port.exposure_id}") + if port.protocol == "HTTP": + print(f" URL: {port.url}") + else: + print(f" TLS Socket: {port.tls_socket}") + + # Usage instructions + print("\n--- How to Connect ---") + print(f"HTTP: curl {http_exposure.url}") + print("TCP: Use the TLS socket address with a TCP client") + + # Clean up exposures + # print("\n--- Cleanup ---") + # print("Removing port exposures...") + # sandbox_client.unexpose(sandbox.id, http_exposure.exposure_id) + # print(f" Removed HTTP exposure: {http_exposure.exposure_id}") + # sandbox_client.unexpose(sandbox.id, tcp_exposure.exposure_id) + # print(f" Removed TCP exposure: {tcp_exposure.exposure_id}") + + # # Delete sandbox + # print(f"\nDeleting sandbox {sandbox.name}...") + # sandbox_client.delete(sandbox.id) + print("Done!") + + except APIError as e: + print(f"API Error: {e}") + print("Make sure you're logged in: run 'prime login' first") + except Exception as e: + print(f"Error: {e}") + raise + + +if __name__ == "__main__": + main() From 13a3c81e71aaa06b77959d3c6a1dc9d802daebb8 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 4 Dec 2025 16:50:07 -0800 Subject: [PATCH 4/7] uncomment clean up --- examples/sandbox_port_expose_demo.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/sandbox_port_expose_demo.py b/examples/sandbox_port_expose_demo.py index 65991f1f..b353b727 100644 --- a/examples/sandbox_port_expose_demo.py +++ b/examples/sandbox_port_expose_demo.py @@ -171,16 +171,16 @@ def handle_client(conn, addr): print("TCP: Use the TLS socket address with a TCP client") # Clean up exposures - # print("\n--- Cleanup ---") - # print("Removing port exposures...") - # sandbox_client.unexpose(sandbox.id, http_exposure.exposure_id) - # print(f" Removed HTTP exposure: {http_exposure.exposure_id}") - # sandbox_client.unexpose(sandbox.id, tcp_exposure.exposure_id) - # print(f" Removed TCP exposure: {tcp_exposure.exposure_id}") - - # # Delete sandbox - # print(f"\nDeleting sandbox {sandbox.name}...") - # sandbox_client.delete(sandbox.id) + print("\n--- Cleanup ---") + print("Removing port exposures...") + sandbox_client.unexpose(sandbox.id, http_exposure.exposure_id) + print(f" Removed HTTP exposure: {http_exposure.exposure_id}") + sandbox_client.unexpose(sandbox.id, tcp_exposure.exposure_id) + print(f" Removed TCP exposure: {tcp_exposure.exposure_id}") + + # Delete sandbox + print(f"\nDeleting sandbox {sandbox.name}...") + sandbox_client.delete(sandbox.id) print("Done!") except APIError as e: From 10042b526c8f2c056359160ba5b47dbfd1022002 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 4 Dec 2025 17:16:58 -0800 Subject: [PATCH 5/7] ssh command --- .../prime/src/prime_cli/commands/sandbox.py | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/packages/prime/src/prime_cli/commands/sandbox.py b/packages/prime/src/prime_cli/commands/sandbox.py index b8a82a73..32d351f9 100644 --- a/packages/prime/src/prime_cli/commands/sandbox.py +++ b/packages/prime/src/prime_cli/commands/sandbox.py @@ -1,6 +1,8 @@ import json import random +import shutil import string +import subprocess import time from typing import Any, Dict, List, Optional @@ -1051,3 +1053,126 @@ def list_ports( console.print(f"[red]Unexpected error:[/red] {escape(str(e))}") console.print_exception(show_locals=True) raise typer.Exit(1) + + +@app.command("ssh", no_args_is_help=True) +def ssh_connect( + sandbox_id: str = typer.Argument(..., help="Sandbox ID to SSH into"), + user: str = typer.Option("root", "--user", "-u", help="SSH user to connect as"), + port: int = typer.Option(22, "--port", "-p", help="SSH port in the sandbox"), + identity: Optional[str] = typer.Option( + None, "--identity", "-i", help="Path to SSH private key file" + ), + ssh_args: Optional[List[str]] = typer.Argument( + None, help="Additional SSH arguments (e.g., -- -v for verbose)" + ), +) -> None: + """Connect to a sandbox via SSH. + + This command exposes the SSH port, connects via SSH, and automatically cleans + up the port exposure when the session ends. + + Examples:\n + prime sandbox ssh sb_abc123\n + prime sandbox ssh sb_abc123 -u ubuntu -p 2222\n + prime sandbox ssh sb_abc123 -i ~/.ssh/my_key\n + prime sandbox ssh sb_abc123 -- -v -L 8080:localhost:8080\n + """ + exposure_id: Optional[str] = None + sandbox_client: Optional[SandboxClient] = None + + def cleanup() -> None: + """Clean up the port exposure.""" + if exposure_id and sandbox_client: + try: + console.print("\n[bold blue]Cleaning up SSH tunnel...[/bold blue]") + sandbox_client.unexpose(sandbox_id, exposure_id) + console.print("[green]✓[/green] SSH tunnel closed") + except Exception: + pass + + try: + # Check if ssh command is available + if not shutil.which("ssh"): + console.print("[red]Error:[/red] SSH client not found. Please install OpenSSH.") + raise typer.Exit(1) + + base_client = APIClient() + sandbox_client = SandboxClient(base_client) + + # Check if sandbox is running + with console.status("[bold blue]Checking sandbox status...", spinner="dots"): + sandbox = sandbox_client.get(sandbox_id) + + if sandbox.status != "RUNNING": + console.print(f"[red]Error:[/red] Sandbox is not running (status: {sandbox.status})") + console.print( + f"[yellow]Tip:[/yellow] Check sandbox status with: prime sandbox get {sandbox_id}" + ) + raise typer.Exit(1) + + # Expose SSH port as TCP + console.print(f"[bold blue]Exposing SSH port {port}...[/bold blue]") + with console.status("[bold blue]Setting up SSH tunnel...", spinner="dots"): + exposed = sandbox_client.expose(sandbox_id, port, "ssh", "TCP") + time.sleep(120) + + exposure_id = exposed.exposure_id + + # Parse the TLS socket to get host and external port + # Format is typically "hostname:port" + tls_parts = exposed.tls_socket.rsplit(":", 1) + if len(tls_parts) != 2: + console.print(f"[red]Error:[/red] Invalid TLS socket format: {exposed.tls_socket}") + cleanup() + raise typer.Exit(1) + + ssh_host = tls_parts[0] + ssh_port = int(tls_parts[1]) + + console.print("[green]✓[/green] SSH tunnel established!") + console.print(f"[bold green]Connecting to:[/bold green] {user}@{ssh_host}") + console.print(f"[bold green]Port:[/bold green] {ssh_port}") + console.print() + console.print("[dim]Press Ctrl+D or type 'exit' to disconnect[/dim]") + console.print() + + # Build SSH command + ssh_cmd = ["ssh", f"{user}@{ssh_host}", "-p", str(ssh_port)] + + # Disable strict host key checking for dynamic hosts + ssh_cmd.extend(["-o", "StrictHostKeyChecking=no"]) + ssh_cmd.extend(["-o", "UserKnownHostsFile=/dev/null"]) + + # Add identity file if specified + if identity: + ssh_cmd.extend(["-i", identity]) + + # Add any additional SSH arguments + if ssh_args: + ssh_cmd.extend(ssh_args) + + # Connect via SSH (this will be interactive) + result = subprocess.run(ssh_cmd) + + # Check if SSH connection failed + if result.returncode != 0 and result.returncode != 255: + console.print(f"\n[yellow]SSH connection exited with code {result.returncode}[/yellow]") + + cleanup() + + except KeyboardInterrupt: + console.print("\n[yellow]SSH connection interrupted[/yellow]") + cleanup() + raise typer.Exit(130) + except APIError as e: + console.print(f"[red]Error:[/red] {str(e)}") + cleanup() + raise typer.Exit(1) + except typer.Exit: + raise + except Exception as e: + console.print(f"[red]Unexpected error:[/red] {escape(str(e))}") + console.print_exception(show_locals=True) + cleanup() + raise typer.Exit(1) From 7c13efa5b7216bea6d3f4e282515febacb98e15a Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 4 Dec 2025 17:25:23 -0800 Subject: [PATCH 6/7] move timeout --- packages/prime/src/prime_cli/commands/sandbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/prime/src/prime_cli/commands/sandbox.py b/packages/prime/src/prime_cli/commands/sandbox.py index 6fbf1468..86ba0fe9 100644 --- a/packages/prime/src/prime_cli/commands/sandbox.py +++ b/packages/prime/src/prime_cli/commands/sandbox.py @@ -1126,7 +1126,6 @@ def cleanup() -> None: console.print(f"[bold blue]Exposing SSH port {port}...[/bold blue]") with console.status("[bold blue]Setting up SSH tunnel...", spinner="dots"): exposed = sandbox_client.expose(sandbox_id, port, "ssh", "TCP") - time.sleep(120) exposure_id = exposed.exposure_id @@ -1140,6 +1139,7 @@ def cleanup() -> None: ssh_host = tls_parts[0] ssh_port = int(tls_parts[1]) + time.sleep(120) console.print("[green]✓[/green] SSH tunnel established!") console.print(f"[bold green]Connecting to:[/bold green] {user}@{ssh_host}") From 4d7e7f5ad502a6aab5cbce9db589a979bb926076 Mon Sep 17 00:00:00 2001 From: Cooper Miller Date: Thu, 4 Dec 2025 21:04:51 -0800 Subject: [PATCH 7/7] list all ports --- .../src/prime_sandboxes/sandbox.py | 10 ++ .../prime/src/prime_cli/commands/sandbox.py | 111 ++++++++++++------ 2 files changed, 87 insertions(+), 34 deletions(-) diff --git a/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py b/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py index 70417f3e..6519a209 100644 --- a/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py +++ b/packages/prime-sandboxes/src/prime_sandboxes/sandbox.py @@ -570,6 +570,11 @@ def list_exposed_ports(self, sandbox_id: str) -> ListExposedPortsResponse: response = self.client.request("GET", f"/sandbox/{sandbox_id}/expose") return ListExposedPortsResponse.model_validate(response) + def list_all_exposed_ports(self) -> ListExposedPortsResponse: + """List all exposed ports across all sandboxes for the current user""" + response = self.client.request("GET", "/sandbox/expose/all") + return ListExposedPortsResponse.model_validate(response) + class AsyncSandboxClient: """Async client for sandbox API operations""" @@ -1026,3 +1031,8 @@ async def list_exposed_ports(self, sandbox_id: str) -> ListExposedPortsResponse: """List all exposed ports for a sandbox""" response = await self.client.request("GET", f"/sandbox/{sandbox_id}/expose") return ListExposedPortsResponse.model_validate(response) + + async def list_all_exposed_ports(self) -> ListExposedPortsResponse: + """List all exposed ports across all sandboxes for the current user""" + response = await self.client.request("GET", "/sandbox/expose/all") + return ListExposedPortsResponse.model_validate(response) diff --git a/packages/prime/src/prime_cli/commands/sandbox.py b/packages/prime/src/prime_cli/commands/sandbox.py index 86ba0fe9..3ce3cec6 100644 --- a/packages/prime/src/prime_cli/commands/sandbox.py +++ b/packages/prime/src/prime_cli/commands/sandbox.py @@ -1009,53 +1009,96 @@ def unexpose_port( raise typer.Exit(1) -@app.command("list-ports", no_args_is_help=True) +@app.command("list-ports") def list_ports( - sandbox_id: str = typer.Argument(..., help="Sandbox ID"), + sandbox_id: Optional[str] = typer.Argument( + None, help="Sandbox ID (omit to list all exposed ports across all sandboxes)" + ), output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"), ) -> None: - """List all exposed ports for a sandbox""" + """List exposed ports for a sandbox, or all sandboxes if no ID is provided""" validate_output_format(output, console) try: base_client = APIClient() sandbox_client = SandboxClient(base_client) - with console.status("[bold blue]Fetching exposed ports...", spinner="dots"): - response = sandbox_client.list_exposed_ports(sandbox_id) + if sandbox_id: + # List ports for a specific sandbox + with console.status("[bold blue]Fetching exposed ports...", spinner="dots"): + response = sandbox_client.list_exposed_ports(sandbox_id) - if output == "json": - output_data_as_json( - {"exposures": [exp.model_dump() for exp in response.exposures]}, console - ) - else: - if not response.exposures: - console.print(f"[yellow]No exposed ports for sandbox {sandbox_id}[/yellow]") - else: - table = build_table( - f"Exposed Ports for Sandbox {sandbox_id}", - [ - ("Exposure ID", "cyan"), - ("Protocol", "white"), - ("Port", "blue"), - ("External", "blue"), - ("Name", "green"), - ("URL", "magenta"), - ], + if output == "json": + output_data_as_json( + {"exposures": [exp.model_dump() for exp in response.exposures]}, console ) + else: + if not response.exposures: + console.print(f"[yellow]No exposed ports for sandbox {sandbox_id}[/yellow]") + else: + table = build_table( + f"Exposed Ports for Sandbox {sandbox_id}", + [ + ("Exposure ID", "cyan"), + ("Protocol", "white"), + ("Port", "blue"), + ("External", "blue"), + ("Name", "green"), + ("URL", "magenta"), + ], + ) + + for exp in response.exposures: + external_port = str(exp.external_port) if exp.external_port else "-" + table.add_row( + exp.exposure_id, + exp.protocol or "HTTP", + str(exp.port), + external_port, + exp.name or "-", + exp.url, + ) + + console.print(table) + else: + # List all exposed ports across all sandboxes + with console.status("[bold blue]Fetching all exposed ports...", spinner="dots"): + response = sandbox_client.list_all_exposed_ports() - for exp in response.exposures: - external_port = str(exp.external_port) if exp.external_port else "-" - table.add_row( - exp.exposure_id, - exp.protocol or "HTTP", - str(exp.port), - external_port, - exp.name or "-", - exp.url, + if output == "json": + output_data_as_json( + {"exposures": [exp.model_dump() for exp in response.exposures]}, console + ) + else: + if not response.exposures: + console.print("[yellow]No exposed ports found[/yellow]") + else: + table = build_table( + "All Exposed Ports", + [ + ("Sandbox ID", "yellow"), + ("Exposure ID", "cyan"), + ("Protocol", "white"), + ("Port", "blue"), + ("External", "blue"), + ("Name", "green"), + ("URL", "magenta"), + ], ) - console.print(table) + for exp in response.exposures: + external_port = str(exp.external_port) if exp.external_port else "-" + table.add_row( + exp.sandbox_id, + exp.exposure_id, + exp.protocol or "HTTP", + str(exp.port), + external_port, + exp.name or "-", + exp.url, + ) + + console.print(table) except APIError as e: console.print(f"[red]Error:[/red] {str(e)}") @@ -1139,7 +1182,7 @@ def cleanup() -> None: ssh_host = tls_parts[0] ssh_port = int(tls_parts[1]) - time.sleep(120) + time.sleep(15) console.print("[green]✓[/green] SSH tunnel established!") console.print(f"[bold green]Connecting to:[/bold green] {user}@{ssh_host}")