diff --git a/CLAUDE.md b/CLAUDE.md index e06e1e6..7eeb761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,7 @@ The `--live` flag mounts the current directory directly into the container witho - `devs start ` - Start named devcontainers - `devs vscode ` - Open devcontainers in VS Code +- `devs tunnel ` - Start VS Code tunnel for remote access (see VS Code Tunnels below) - `devs stop ` - Stop and remove devcontainers - `devs shell ` - Open shell in devcontainer - `devs list` - List active devcontainers for current project @@ -178,6 +179,46 @@ Both Claude (Anthropic) and Codex (OpenAI) are supported with similar interfaces Both commands support `--reset-workspace`, `--live`, and `--env` options. +### VS Code Tunnels + +VS Code tunnels allow you to connect VS Code directly to a devcontainer without SSH. The container initiates an outbound connection to Microsoft's tunnel service, and your local VS Code connects through that tunnel. + +**Use cases:** +- Connecting to containers on remote servers without port forwarding +- Working through firewalls (only outbound connections needed) +- Avoiding the "double-hop" of SSH to host + docker attach to container + +**Commands:** +- `devs tunnel ` - Start a tunnel (interactive, runs in foreground) +- `devs tunnel --status` - Check if a tunnel is running +- `devs tunnel --kill` - Stop a running tunnel + +**First-time setup:** +1. Run `devs tunnel ` +2. A URL and device code will be displayed +3. Open the URL in your browser (usually github.com/login/device) +4. Enter the code to authenticate +5. After authentication, the tunnel is ready + +**Connecting with VS Code:** +1. After starting a tunnel, VS Code will show connection instructions +2. In VS Code, open the Remote Explorer (sidebar) +3. Look for "Tunnels" section +4. Your tunnel will appear with the name `dev---` +5. Click to connect + +**Example workflow:** +```bash +# Start a container +devs start mydev + +# Start a tunnel (keep this terminal open) +devs tunnel mydev + +# In another terminal or on a different machine: +# Open VS Code and connect via Remote Explorer > Tunnels +``` + ### Example Workflow ```bash diff --git a/packages/cli/devs/cli.py b/packages/cli/devs/cli.py index 797a456..c227d62 100644 --- a/packages/cli/devs/cli.py +++ b/packages/cli/devs/cli.py @@ -778,6 +778,92 @@ def runtests(dev_name: str, reset_workspace: bool, live: bool, env: tuple, debug sys.exit(1) +@cli.command() +@click.argument('dev_name') +@click.option('--status', is_flag=True, help='Check tunnel status instead of starting') +@click.option('--kill', 'kill_tunnel', is_flag=True, help='Kill running tunnel') +@click.option('--live', is_flag=True, help='Start container with current directory mounted as workspace') +@click.option('--env', multiple=True, help='Environment variables to pass to container (format: VAR=value)') +@debug_option +def tunnel(dev_name: str, status: bool, kill_tunnel: bool, live: bool, env: tuple, debug: bool) -> None: + """Start a VS Code tunnel in devcontainer. + + VS Code tunnels allow you to connect VS Code directly to the container + without SSH - the container initiates an outbound connection to Microsoft's + tunnel service, and your local VS Code connects through that. + + This is useful for: + - Connecting to containers on remote servers without port forwarding + - Working through firewalls (only outbound connections needed) + - Avoiding the "double-hop" of SSH + container attach + + DEV_NAME: Development environment name + + Example: devs tunnel sally # Start tunnel (interactive) + Example: devs tunnel sally --status # Check tunnel status + Example: devs tunnel sally --kill # Stop running tunnel + Example: devs tunnel sally --live # Start with current directory mounted + """ + check_dependencies() + project = get_project() + + # Load environment variables from DEVS.yml and merge with CLI --env flags + devs_env = DevsConfigLoader.load_env_vars(dev_name, project.info.name) + cli_env = parse_env_vars(env) if env else {} + extra_env = merge_env_vars(devs_env, cli_env) if devs_env or cli_env else None + + if extra_env: + console.print(f"Environment variables: {', '.join(f'{k}={v}' for k, v in extra_env.items())}") + + container_manager = ContainerManager(project, config) + workspace_manager = WorkspaceManager(project, config) + + try: + # Ensure workspace exists (handles live mode internally) + workspace_dir = workspace_manager.create_workspace(dev_name, live=live) + + if status: + # Check tunnel status + is_running, status_msg = container_manager.get_tunnel_status( + dev_name=dev_name, + workspace_dir=workspace_dir, + debug=debug, + live=live, + extra_env=extra_env + ) + if is_running: + console.print(f"Tunnel status for {dev_name}:") + console.print(status_msg) + else: + console.print(f"No tunnel running in {dev_name}") + console.print(f" {status_msg}") + + elif kill_tunnel: + # Kill the tunnel + console.print(f"Stopping tunnel in {dev_name}...") + container_manager.kill_tunnel( + dev_name=dev_name, + workspace_dir=workspace_dir, + debug=debug, + live=live, + extra_env=extra_env + ) + + else: + # Start the tunnel (interactive) + container_manager.start_tunnel( + dev_name=dev_name, + workspace_dir=workspace_dir, + debug=debug, + live=live, + extra_env=extra_env + ) + + except (ContainerError, WorkspaceError) as e: + console.print(f"Error with tunnel for {dev_name}: {e}") + sys.exit(1) + + @cli.command() @click.option('--all-projects', is_flag=True, help='List containers for all projects') def list(all_projects: bool) -> None: diff --git a/packages/common/devs_common/core/container.py b/packages/common/devs_common/core/container.py index 583169d..8f6c89c 100644 --- a/packages/common/devs_common/core/container.py +++ b/packages/common/devs_common/core/container.py @@ -809,4 +809,153 @@ def exec_codex(self, dev_name: str, workspace_dir: Path, prompt: str, debug: boo stream=stream, live=live, extra_env=extra_env - ) \ No newline at end of file + ) + + def start_tunnel(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> None: + """Start a VS Code tunnel in the container. + + This runs an interactive tunnel that allows VS Code to connect directly + to the container without SSH. The tunnel process will run in the foreground + and can be stopped with Ctrl+C. + + First-time usage requires authentication via a device code flow. + + Args: + dev_name: Development environment name + workspace_dir: Workspace directory path + debug: Show debug output for devcontainer operations + live: Whether the container is in live mode + extra_env: Additional environment variables to pass to container + + Raises: + ContainerError: If tunnel startup fails + """ + try: + # Prepare container for execution + container_name, container_workspace_dir = self._prepare_container_exec( + dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env + ) + + # Create a tunnel name from the container's devcontainer name + # This will be the name that appears in VS Code Remote Explorer + project_prefix = self.config.project_prefix if self.config else "dev" + tunnel_name = f"{project_prefix}-{self.project.info.name}-{dev_name}" + # Sanitize: tunnel names can only contain alphanumeric, dash, underscore + tunnel_name = tunnel_name.replace(".", "-").replace("_", "-") + + console.print(f"[bold cyan]Starting VS Code tunnel for: {dev_name}[/bold cyan]") + console.print(f" Container: {container_name}") + console.print(f" Tunnel name: {tunnel_name}") + console.print("") + console.print("[yellow]First-time usage requires authentication:[/yellow]") + console.print(" 1. A URL and code will be displayed") + console.print(" 2. Open the URL in your browser") + console.print(" 3. Enter the code to authenticate with GitHub/Microsoft") + console.print("") + console.print("[dim]Press Ctrl+C to stop the tunnel[/dim]") + console.print("") + + # Start the tunnel interactively + # The code CLI binary is installed at /usr/local/bin/code in the container + cmd = [ + 'docker', 'exec', '-it', + container_name, + '/usr/local/bin/code', 'tunnel', + '--accept-server-license-terms', + '--name', tunnel_name + ] + + if debug: + console.print(f"[dim]Running: {' '.join(cmd)}[/dim]") + + # Run interactively so user can see auth prompts and tunnel status + subprocess.run(cmd, check=False) + + except (DockerError, subprocess.SubprocessError) as e: + raise ContainerError(f"Failed to start tunnel in {dev_name}: {e}") + + def get_tunnel_status(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> tuple[bool, str]: + """Get VS Code tunnel status in the container. + + Args: + dev_name: Development environment name + workspace_dir: Workspace directory path + debug: Show debug output for devcontainer operations + live: Whether the container is in live mode + extra_env: Additional environment variables to pass to container + + Returns: + Tuple of (is_running, status_message) + + Raises: + ContainerError: If status check fails + """ + try: + # Prepare container for execution + container_name, container_workspace_dir = self._prepare_container_exec( + dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env + ) + + # Check tunnel status + cmd = [ + 'docker', 'exec', + container_name, + '/usr/local/bin/code', 'tunnel', 'status' + ] + + if debug: + console.print(f"[dim]Running: {' '.join(cmd)}[/dim]") + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + return True, result.stdout.strip() + else: + return False, result.stderr.strip() if result.stderr else "No tunnel running" + + except (DockerError, subprocess.SubprocessError) as e: + raise ContainerError(f"Failed to get tunnel status in {dev_name}: {e}") + + def kill_tunnel(self, dev_name: str, workspace_dir: Path, debug: bool = False, live: bool = False, extra_env: Optional[Dict[str, str]] = None) -> bool: + """Kill a running VS Code tunnel in the container. + + Args: + dev_name: Development environment name + workspace_dir: Workspace directory path + debug: Show debug output for devcontainer operations + live: Whether the container is in live mode + extra_env: Additional environment variables to pass to container + + Returns: + True if tunnel was killed successfully + + Raises: + ContainerError: If tunnel kill fails + """ + try: + # Prepare container for execution + container_name, container_workspace_dir = self._prepare_container_exec( + dev_name, workspace_dir, debug=debug, live=live, extra_env=extra_env + ) + + # Kill the tunnel + cmd = [ + 'docker', 'exec', + container_name, + '/usr/local/bin/code', 'tunnel', 'kill' + ] + + if debug: + console.print(f"[dim]Running: {' '.join(cmd)}[/dim]") + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + console.print(f" Tunnel killed in: {dev_name}") + return True + else: + console.print(f" No tunnel to kill in: {dev_name}") + return False + + except (DockerError, subprocess.SubprocessError) as e: + raise ContainerError(f"Failed to kill tunnel in {dev_name}: {e}") \ No newline at end of file diff --git a/packages/common/devs_common/templates/Dockerfile b/packages/common/devs_common/templates/Dockerfile index e5e9d17..eea023f 100644 --- a/packages/common/devs_common/templates/Dockerfile +++ b/packages/common/devs_common/templates/Dockerfile @@ -122,6 +122,18 @@ RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/ # Install Claude CLI and OpenAI Codex CLI RUN npm install -g @anthropic-ai/claude-code @openai/codex +# Install VS Code CLI for tunnel support +# Using the standalone CLI which supports the 'tunnel' command +RUN ARCH=$(dpkg --print-architecture) && \ + if [ "$ARCH" = "amd64" ]; then \ + curl -Lk 'https://code.visualstudio.com/sha/download?build=stable&os=cli-linux-x64' -o /tmp/vscode-cli.tar.gz; \ + elif [ "$ARCH" = "arm64" ]; then \ + curl -Lk 'https://code.visualstudio.com/sha/download?build=stable&os=cli-linux-arm64' -o /tmp/vscode-cli.tar.gz; \ + fi && \ + tar -xf /tmp/vscode-cli.tar.gz -C /usr/local/bin && \ + rm /tmp/vscode-cli.tar.gz && \ + chmod +x /usr/local/bin/code + # Set up environment variables and aliases for Claude and Codex USER root RUN for shell_rc in /home/node/.zshrc /home/node/.bashrc; do \ diff --git a/packages/common/devs_common/templates/scripts/start-tunnel.sh b/packages/common/devs_common/templates/scripts/start-tunnel.sh new file mode 100644 index 0000000..ec74a86 --- /dev/null +++ b/packages/common/devs_common/templates/scripts/start-tunnel.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Start VS Code tunnel with the container's dev name +# This script is run inside the container + +set -euo pipefail + +# Get tunnel name from environment or argument +TUNNEL_NAME="${DEVCONTAINER_NAME:-${1:-devs-tunnel}}" + +# Sanitize the tunnel name (replace invalid characters) +TUNNEL_NAME=$(echo "$TUNNEL_NAME" | tr -cd '[:alnum:]-_') + +echo "🚇 Starting VS Code tunnel as '$TUNNEL_NAME'..." + +# Check if code CLI is available +if ! command -v code &> /dev/null; then + echo "❌ VS Code CLI not found. Please rebuild the container." + exit 1 +fi + +# Start the tunnel +# --accept-server-license-terms: Skip the license acceptance prompt +# --name: Set the machine name that appears in VS Code +exec code tunnel --accept-server-license-terms --name "$TUNNEL_NAME" diff --git a/packages/common/devs_common/templates/scripts/tunnel-status.sh b/packages/common/devs_common/templates/scripts/tunnel-status.sh new file mode 100644 index 0000000..44f2b44 --- /dev/null +++ b/packages/common/devs_common/templates/scripts/tunnel-status.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Check VS Code tunnel status +# This script is run inside the container + +set -euo pipefail + +# Check if code CLI is available +if ! command -v code &> /dev/null; then + echo "❌ VS Code CLI not found" + exit 1 +fi + +# Check tunnel status +code tunnel status 2>/dev/null || echo "No tunnel running"