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
62 changes: 55 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,26 @@ Claude Code has a "Claude in Chrome" feature that lets it control your browser

## How it works

A socat bridge in the container and a Node.js script on the host forward messages between them:
A socat bridge in the container and a script on the host forward messages between them:

```
Container Host
Claude Code Chrome
| |
v ^
socat (entrypoint) --TCP:9229--> bridge-host.js
(Unix socket) (Unix socket)
socat (entrypoint) --TCP:9229--> bridge-host.js / bridge-host.ps1
(Unix socket) (Unix socket or Named Pipe)
```

## Prerequisites

- Docker (Docker Desktop, OrbStack, or similar)
- Node.js on the host (for bridge-host.js)
- Chrome with the [Claude browser extension](https://claude.ai/chrome) installed
- Claude account credentials
- **Mac/Linux host:** Node.js (for `bridge-host.js`)
- **Windows host:** PowerShell 7+ (for `bridge-host.ps1`) — no Node.js needed

## Setup
## Setup (Mac / Linux host)

1. **Create `.env.local`** with your credentials:

Expand Down Expand Up @@ -56,6 +57,52 @@ socat (entrypoint) --TCP:9229--> bridge-host.js

5. Ask Claude to do something in Chrome, e.g. `open google.com`.

## Setup (Windows host — VS Code Dev Containers)

On Windows, Claude Code uses a **Named Pipe** (`\\.\pipe\claude-mcp-browser-bridge-<user>`) instead of a Unix socket, so `bridge-host.js` won't work. Use `bridge-host.ps1` instead — no Node.js required.

### Additional prerequisites

- PowerShell 7+: `winget install Microsoft.PowerShell`
- Claude Code installed on Windows (registers the Chrome Native Messaging Host):
```powershell
npm install -g @anthropic-ai/claude-code
```

### Steps

1. **Start Claude with Chrome on Windows** to activate the Named Pipe (keep this terminal open):

```powershell
claude --chrome
```

2. **Start the PowerShell bridge** (separate terminal):

```powershell
# -BridgeHost '::' is required on WSL2: Dev Containers resolve
# host.docker.internal as an IPv6 address
pwsh -ExecutionPolicy Bypass -File bridge-host.ps1 -BridgeHost '::'
```

3. **Inside the Dev Container**, run the helper script once:

```bash
bash bridge-devcontainer.sh
```

This installs socat, sets up the required `chrome-native-host` stub, and starts the socat bridge.

4. **Inside the Dev Container**, start Claude:

```bash
claude --chrome
```

> **Why two Claude instances?** The Windows instance keeps the Chrome Native Messaging Host
> (and its Named Pipe) alive. The Dev Container instance is where you do your actual work —
> it routes through the Named Pipe via the bridge to control Chrome.

## Commands

| Command | Description |
Expand All @@ -69,6 +116,7 @@ This project is a proof-of-concept solution for [anthropics/claude-code#15450](h

## Troubleshooting

- **"Extension not detected"** — Make sure `bridge-host.js` is running on the host and Chrome has the Claude extension active.
- **Bridge container can't connect** — Verify `bridge-host.js` is listening on port 9229.
- **"Extension not detected"** — Make sure the bridge is running on the host and Chrome has the Claude extension active.
- **Bridge container can't connect** — Verify the bridge is listening on port 9229. On Windows with WSL2, use `-BridgeHost '::'`.
- **Username mismatch** — The `USER` env var in the container must match the socket directory name. It defaults to `claude`.
- **"Pipe not found" (Windows)** — Make sure `claude --chrome` is running on Windows *before* starting `bridge-host.ps1`.
71 changes: 71 additions & 0 deletions bridge-devcontainer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# bridge-devcontainer.sh — start the socat bridge inside the devcontainer
# Run this once before `claude --chrome` to connect to the host's bridge-host.ps1

set -euo pipefail

BRIDGE_PORT="${BRIDGE_PORT:-9229}"
BRIDGE_HOST="host.docker.internal"
USER_NAME="$(whoami)"
SOCK_DIR="/tmp/claude-mcp-browser-bridge-${USER_NAME}"

# ── 1. Install socat if missing ───────────────────────────────────────────────
if ! command -v socat &>/dev/null; then
echo "[bridge] socat not found — installing..."
sudo apt-get install -y --quiet socat
fi

# ── 2. Install fake chrome-native-host (Claude Code checks for this) ──────────
NATIVE_HOST="${HOME}/.claude/chrome/chrome-native-host"
if [[ ! -x "$NATIVE_HOST" ]]; then
echo "[bridge] Installing fake chrome-native-host..."
mkdir -p "$(dirname "$NATIVE_HOST")"
cat > "$NATIVE_HOST" << 'EOF'
#!/usr/bin/env bash
# Stub: Claude Code extension detection only.
# Actual browser comms go through the MCP Unix socket bridge.
exec cat
EOF
chmod +x "$NATIVE_HOST"
fi
echo "[bridge] chrome-native-host v"

# ── 3. Clean up stale socket files ───────────────────────────────────────────
mkdir -p -m 700 "$SOCK_DIR"
find "$SOCK_DIR" -name '*.sock' -delete 2>/dev/null || true

# ── 4. Verify host is reachable on the bridge port ───────────────────────────
echo "[bridge] Checking ${BRIDGE_HOST}:${BRIDGE_PORT}..."
if ! timeout 2 bash -c "echo >/dev/tcp/${BRIDGE_HOST}/${BRIDGE_PORT}" 2>/dev/null; then
echo ""
echo " ERROR: Cannot reach ${BRIDGE_HOST}:${BRIDGE_PORT}"
echo ""
echo " Make sure bridge-host.ps1 is running on your Windows host:"
echo " pwsh -ExecutionPolicy Bypass -File bridge-host.ps1 -BridgeHost '::'"
echo ""
exit 1
fi
echo "[bridge] Host reachable v"

# ── 5. Start socat in background ─────────────────────────────────────────────
SOCK_PATH="${SOCK_DIR}/$$.sock"
setsid socat \
UNIX-LISTEN:"${SOCK_PATH}",mode=600,fork \
TCP:${BRIDGE_HOST}:${BRIDGE_PORT} &

SOCAT_PID=$!
sleep 0.5

# ── 6. Confirm it's still running ────────────────────────────────────────────
if ! kill -0 "$SOCAT_PID" 2>/dev/null; then
echo ""
echo " ERROR: socat exited immediately."
echo " Check that bridge-host.ps1 is running with -BridgeHost '::'"
echo ""
exit 1
fi

echo "[bridge] socat bridge running (PID $SOCAT_PID)"
echo "[bridge] Socket: $SOCK_PATH"
echo ""
echo " Ready — run: claude --chrome"
142 changes: 142 additions & 0 deletions bridge-host.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env pwsh
#Requires -Version 7.0
<#
.SYNOPSIS
TCP-to-Named Pipe bridge for Claude Code Chrome extension on Windows.
Drop-in replacement for bridge-host.js — no Node.js required.

.DESCRIPTION
Listens on TCP port 9229 and forwards each connection to the Claude browser
extension's Named Pipe (\\.\pipe\claude-mcp-browser-bridge-<user>), enabling
Claude Code running inside a devcontainer to control Chrome on the host machine.

On Windows, Claude Code uses a Named Pipe instead of a Unix socket.

.PARAMETER BridgeUser
Username whose named pipe to connect to. Defaults to BRIDGE_USER env var,
then the current Windows user ($env:USERNAME).

.PARAMETER BridgePort
TCP port to listen on. Defaults to BRIDGE_PORT env var, then 9229.

.PARAMETER BridgeHost
IP address to bind to. Defaults to BRIDGE_HOST env var, then 0.0.0.0.

.EXAMPLE
.\bridge-host.ps1

.EXAMPLE
.\bridge-host.ps1 -BridgeHost '::'
#>

param(
[string] $BridgeUser = ($env:BRIDGE_USER ?? $env:USERNAME),
[int] $BridgePort = [int]($env:BRIDGE_PORT ?? '9229'),
[string] $BridgeHost = ($env:BRIDGE_HOST ?? '0.0.0.0')
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'

$PipeName = "claude-mcp-browser-bridge-$BridgeUser"

function Write-Log([string]$Msg) {
$ts = (Get-Date).ToString('HH:mm:ss')
Write-Host "[$ts bridge-host] $Msg"
}

function Test-Pipe([string]$Name) {
try {
$pipes = [System.IO.Directory]::GetFiles('\\.\pipe\')
return ($pipes | Where-Object { $_ -like "*$Name*" }).Count -gt 0
} catch { return $false }
}

# ── Per-connection handler ────────────────────────────────────────────────────
# Runs in its own runspace so the TCP accept loop is never blocked.
$connectionHandler = {
param(
[System.Net.Sockets.TcpClient] $TcpClient,
[string] $Addr,
[string] $PipeName
)

function Log([string]$Msg) { Write-Host "[bridge-host] $Msg" }

Log "TCP client $Addr -> Named Pipe \\.\pipe\$PipeName"

$pipeStream = [System.IO.Pipes.NamedPipeClientStream]::new(
'.',
$PipeName,
[System.IO.Pipes.PipeDirection]::InOut,
[System.IO.Pipes.PipeOptions]::Asynchronous
)

try {
$pipeStream.Connect(5000) # 5-second timeout
Log "Connected to Named Pipe"
} catch {
Log "Named Pipe connect error: $_"
$TcpClient.Close()
$pipeStream.Dispose()
return
}

$tcpStream = $TcpClient.GetStream()

try {
# Pipe both directions concurrently
$toTcp = $pipeStream.CopyToAsync($tcpStream)
$toPipe = $tcpStream.CopyToAsync($pipeStream)

# Wait until either side closes
[void][System.Threading.Tasks.Task]::WaitAny($toTcp, $toPipe)
} catch {
# Expected when either side disconnects
} finally {
Log "TCP client $Addr disconnected"
try { $tcpStream.Close() } catch { }
try { $pipeStream.Close() } catch { }
try { $TcpClient.Close() } catch { }
}
}

# ── Main ─────────────────────────────────────────────────────────────────────

$listener = [System.Net.Sockets.TcpListener]::new(
[System.Net.IPAddress]::Parse($BridgeHost),
$BridgePort
)
$listener.Start()

Write-Log "Listening on ${BridgeHost}:${BridgePort}"
Write-Log "Named Pipe: \\.\pipe\$PipeName"
if (Test-Pipe $PipeName) { Write-Log "Pipe found -- ready" }
else { Write-Log "Pipe not found yet -- will connect on each incoming connection" }

# RunspacePool lets each connection run in its own PowerShell thread
$pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, 8)
$pool.Open()

Write-Log "Press Ctrl+C to stop."

try {
while ($true) {
# Poll instead of blocking so Ctrl+C can interrupt cleanly
while (-not $listener.Pending()) { Start-Sleep -Milliseconds 100 }
$tcpClient = $listener.AcceptTcpClient()
$addr = "$($tcpClient.Client.RemoteEndPoint)"

$ps = [System.Management.Automation.PowerShell]::Create()
$ps.RunspacePool = $pool
[void]$ps.AddScript($connectionHandler)
.AddArgument($tcpClient)
.AddArgument($addr)
.AddArgument($PipeName)
[void]$ps.BeginInvoke()
}
} finally {
$listener.Stop()
$pool.Close()
Write-Log "Stopped."
}