Skip to content
Open
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
41 changes: 41 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ The `--live` flag mounts the current directory directly into the container witho

- `devs start <name...>` - Start named devcontainers
- `devs vscode <name...>` - Open devcontainers in VS Code
- `devs tunnel <name>` - Start VS Code tunnel for remote access (see VS Code Tunnels below)
- `devs stop <name...>` - Stop and remove devcontainers
- `devs shell <name>` - Open shell in devcontainer
- `devs list` - List active devcontainers for current project
Expand All @@ -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 <name>` - Start a tunnel (interactive, runs in foreground)
- `devs tunnel <name> --status` - Check if a tunnel is running
- `devs tunnel <name> --kill` - Stop a running tunnel

**First-time setup:**
1. Run `devs tunnel <name>`
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-<org>-<repo>-<devname>`
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
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/devs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
151 changes: 150 additions & 1 deletion packages/common/devs_common/core/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
)

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}")
12 changes: 12 additions & 0 deletions packages/common/devs_common/templates/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
24 changes: 24 additions & 0 deletions packages/common/devs_common/templates/scripts/start-tunnel.sh
Original file line number Diff line number Diff line change
@@ -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"
14 changes: 14 additions & 0 deletions packages/common/devs_common/templates/scripts/tunnel-status.sh
Original file line number Diff line number Diff line change
@@ -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"