Skip to content

Commit 0ffa498

Browse files
committed
feat(agent-browser): add Hermes Chrome CDP helper for Windows + portproxy workflow
Windows host Chrome CDP connections previously required launching Chrome with --remote-debugging-port flags by hand and still hit HTTP 500 errors because Chrome CDP rejects DNS Host headers like host.docker.internal. Add start-hermes-chrome.ps1: runs from Administrator PowerShell, launches Chrome on 127.0.0.1:9222 with a dedicated profile, and creates a netsh portproxy mapping 0.0.0.0:9223 -> 127.0.0.1:9222 plus matching firewall rule. Container side resolves host.docker.internal to IPv4 and connects on port 9223. Also adds HERMES_CDP_ENDPOINT env var and updates the agent-browser feature, skill, and docs site with the new workflow.
1 parent 726986a commit 0ffa498

11 files changed

Lines changed: 280 additions & 43 deletions

File tree

container/.devcontainer/devcontainer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"CLAUDE_CONFIG_DIR": "/home/vscode/.claude",
2929
"GH_CONFIG_DIR": "/workspaces/.gh",
3030
"TMPDIR": "/workspaces/.tmp",
31+
"HERMES_CDP_ENDPOINT": "http://192.168.65.254:9223",
3132
"TERM": "${localEnv:TERM:xterm-256color}",
3233
"COLORTERM": "truecolor",
3334
"CLAUDECODE": null

container/.devcontainer/features/agent-browser/README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,24 +50,35 @@ agent-browser close
5050

5151
Connect to Chrome running on your host machine via CDP (Chrome DevTools Protocol). Useful when the container's bundled Chromium is insufficient (e.g., specific browser extensions or logged-in sessions needed).
5252

53+
**Windows**: Run the helper from an Administrator PowerShell on the host. It starts Chrome on `127.0.0.1:9222` with a non-default profile, then exposes `0.0.0.0:9223 -> 127.0.0.1:9222` through Windows `netsh portproxy`:
54+
55+
```powershell
56+
.\.devcontainer\scripts\start-hermes-chrome.ps1
57+
```
58+
59+
Then connect from the container through the proxy port:
60+
61+
```bash
62+
CDP_HOST=$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print $1}')
63+
curl http://$CDP_HOST:9223/json/version
64+
agent-browser connect $CDP_HOST:9223
65+
```
66+
5367
**Chrome 144+** (recommended): No CLI launch needed. Enable remote debugging via a checkbox:
5468
1. Open `chrome://inspect/#remote-debugging` in Chrome
5569
2. Check "Enable remote debugging" — Chrome listens on port 9222 by default
5670

57-
**Chrome 136143**: Chrome 136+ requires `--user-data-dir` alongside `--remote-debugging-port` or the flag is silently ignored:
71+
**Linux / macOS Chrome 136-143**: Chrome 136+ requires `--user-data-dir` alongside `--remote-debugging-port` or the flag is silently ignored:
5872

5973
```bash
60-
# Linux / macOS
6174
google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug"
62-
63-
# Windows PowerShell
64-
& "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="$env:TEMP\chrome-debug"
6575
```
6676

67-
Connect from the container using `host.docker.internal` — not `localhost`, which refers to the container itself:
77+
Connect from the container using the resolved IPv4 for `host.docker.internal` — not `localhost`, which refers to the container itself. Chrome CDP rejects non-IP Host headers, so use the IP address in the URL:
6878

6979
```bash
70-
agent-browser connect host.docker.internal:9222
80+
CDP_HOST=$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print $1}')
81+
agent-browser connect $CDP_HOST:9223
7182
```
7283

7384
> **Security note:** CDP exposes the full browser session (cookies, storage, DOM) to anything that can reach the debug port. Use with caution on shared or networked machines.

container/.devcontainer/features/agent-browser/install.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,7 @@ echo " agent-browser screenshot # Capture screenshot"
7777
echo " agent-browser close # Close browser"
7878
echo ""
7979
echo "Host Chrome connection (if container browser insufficient):"
80-
echo " # Start Chrome on host with: chrome --remote-debugging-port=9222"
81-
echo " agent-browser connect 9222"
80+
echo " # Windows: run .devcontainer\\scripts\\start-hermes-chrome.ps1 on the host"
81+
echo " CDP_HOST=\$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print \$1}')"
82+
echo " curl http://\$CDP_HOST:9223/json/version"
83+
echo " agent-browser connect \$CDP_HOST:9223"

container/.devcontainer/plugins/devs-marketplace/plugins/skill-engine/skills/agent-browser/SKILL.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ agent-browser close
6161
| `fill @eN "text"` | Type text into an input element | `agent-browser fill @e3 "hello"` |
6262
| `select @eN "value"` | Select an option from a dropdown | `agent-browser select @e5 "option1"` |
6363
| `cookie set "..."` | Set a cookie for authenticated sessions | `agent-browser cookie set "session=abc123; domain=.example.com"` |
64-
| `connect <host:port>` | Connect to host Chrome via CDP | `agent-browser connect host.docker.internal:9222` |
64+
| `connect <host:port>` | Connect to host Chrome via CDP | `agent-browser connect $CDP_HOST:9223` |
6565
| `close` | End the browser session | `agent-browser close` |
6666

6767
> **Full details:** See `references/cli-reference.md` for complete command syntax, output formats, and all options.
@@ -133,16 +133,20 @@ Connect to Chrome running on your host machine via CDP (Chrome DevTools Protocol
133133
# Linux / macOS
134134
google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug"
135135

136-
# Windows PowerShell
137-
& "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="$env:TEMP\chrome-debug"
136+
# Windows PowerShell, from repo root as Administrator
137+
.\.devcontainer\scripts\start-hermes-chrome.ps1
138138
```
139139

140140
Connect from the container — always use `host.docker.internal`, not `localhost`:
141141

142142
```bash
143-
agent-browser connect host.docker.internal:9222
143+
CDP_HOST=$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print $1}')
144+
curl http://$CDP_HOST:9223/json/version
145+
agent-browser connect $CDP_HOST:9223
144146
```
145147

148+
Chrome CDP rejects DNS Host headers. Resolve `host.docker.internal` to IPv4 first and connect to the IP address.
149+
146150
For Windows users, [mirrored networking](/start-here/windows-networking/) provides the smoothest experience for host Chrome CDP and general port forwarding.
147151

148152
---
@@ -151,7 +155,7 @@ For Windows users, [mirrored networking](/start-here/windows-networking/) provid
151155

152156
These defaults apply when the user does not specify a preference. State the assumption when applying a default:
153157

154-
- **Mode:** Always use headless mode (bundled Chromium) unless the user explicitly requests host Chrome connection. When host Chrome is requested, default to `host.docker.internal:9222` as the CDP endpoint.
158+
- **Mode:** Always use headless mode (bundled Chromium) unless the user explicitly requests host Chrome connection. When host Chrome is requested on Windows, assume the host helper has been run, resolve `host.docker.internal` to IPv4, verify with `curl http://$CDP_HOST:9223/json/version`, then connect with `agent-browser connect $CDP_HOST:9223`.
155159
- **Networking:** When connecting to host Chrome from inside a container, always use `host.docker.internal` — never `localhost`
156160
- **Snapshot first:** Always run `snapshot` before interacting with elements — never guess element references
157161
- **Snapshot after:** Always run `snapshot` after interactions to verify results

container/.devcontainer/plugins/devs-marketplace/plugins/skill-engine/skills/agent-browser/references/cli-reference.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ agent-browser connect <host:port>
191191
```
192192
193193
**Arguments:**
194-
- `<host:port>` — The CDP endpoint. Use `host.docker.internal:9222` when running inside a devcontainer.
194+
- `<host:port>` — The CDP endpoint. On Windows, resolve `host.docker.internal` to IPv4 and use that IP with port 9223 after running the host helper.
195195
196196
**Prerequisites:**
197197
Chrome must have remote debugging enabled on the host before connecting.
@@ -205,13 +205,15 @@ Chrome must have remote debugging enabled on the host before connecting.
205205
# Linux / macOS
206206
google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug"
207207
208-
# Windows PowerShell
209-
& "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="$env:TEMP\chrome-debug"
208+
# Windows PowerShell, from repo root as Administrator
209+
.\.devcontainer\scripts\start-hermes-chrome.ps1
210210
```
211211

212212
**Examples:**
213213
```bash
214-
agent-browser connect host.docker.internal:9222
214+
CDP_HOST=$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print $1}')
215+
curl http://$CDP_HOST:9223/json/version
216+
agent-browser connect $CDP_HOST:9223
215217
```
216218

217219
**When to use:**
@@ -245,8 +247,9 @@ agent-browser close
245247
| Page load timeout | URL unreachable or slow response | Verify URL is correct; check network connectivity |
246248
| Element not found | Reference ID from stale snapshot | Run `snapshot` again to get current references |
247249
| Session already active | Tried to `open` without `close` | Run `close` first, then `open` |
248-
| Connection refused (CDP) | Host Chrome not running with debug port | Start Chrome with `--remote-debugging-port` |
249-
| Connection refused via localhost | Container cannot reach host on localhost | Use `host.docker.internal:9222` instead of `localhost:9222` |
250+
| Connection refused (CDP) | Host Chrome not running with debug port or portproxy unavailable | On Windows, run `.devcontainer\scripts\start-hermes-chrome.ps1` from Administrator PowerShell, then test with `curl http://$CDP_HOST:9223/json/version` |
251+
| Connection refused via localhost | Container cannot reach host on localhost | On Windows, use the resolved IPv4 for `host.docker.internal` on port 9223 instead of `localhost:9222` |
252+
| HTTP 500 Host header error | Chrome rejected `Host: host.docker.internal` | Resolve `host.docker.internal` to IPv4 and connect to the IP address |
250253
| Chrome ignores --remote-debugging-port | Chrome 136+ requires --user-data-dir | Add `--user-data-dir` flag when launching Chrome |
251254
252255
**General recovery pattern:**

container/.devcontainer/plugins/devs-marketplace/plugins/skill-engine/skills/agent-browser/references/workflow-patterns.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ The user must enable Chrome remote debugging on their host machine before you ca
165165
# Linux / macOS
166166
google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug"
167167
168-
# Windows PowerShell
169-
& "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="$env:TEMP\chrome-debug"
168+
# Windows PowerShell, from repo root as Administrator
169+
.\.devcontainer\scripts\start-hermes-chrome.ps1
170170
```
171171
172172
> **Why `--user-data-dir`?** Chrome 136+ silently ignores `--remote-debugging-port` unless a user data directory is also specified.
@@ -175,7 +175,9 @@ google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/chrome-debug"
175175
176176
```bash
177177
# Connect to host Chrome from inside the container
178-
agent-browser connect host.docker.internal:9222
178+
CDP_HOST=$(getent ahostsv4 host.docker.internal | awk 'NR==1 {print $1}')
179+
curl http://$CDP_HOST:9223/json/version
180+
agent-browser connect $CDP_HOST:9223
179181
180182
# Snapshot to see what's currently open
181183
agent-browser snapshot
@@ -192,7 +194,9 @@ agent-browser close
192194
```
193195

194196
**Key points:**
195-
- Always use `host.docker.internal:9222` from inside a devcontainer — `localhost` refers to the container itself
197+
- On Windows, the host must run `.devcontainer\scripts\start-hermes-chrome.ps1` first
198+
- Chrome CDP rejects DNS Host headers, so resolve `host.docker.internal` to IPv4 before connecting
199+
- Verify `curl http://$CDP_HOST:9223/json/version` before `agent-browser connect $CDP_HOST:9223`
196200
- The CDP connection gives you access to the host Chrome's full session: cookies, localStorage, extensions, saved passwords
197201
- Closing the agent-browser session does not close Chrome on the host
198202
- For Windows users needing broader port forwarding, see [Windows Mirrored Networking](/start-here/windows-networking/)
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
param(
2+
[int]$ChromePort = 9222,
3+
[int]$ProxyPort = 9223,
4+
[string]$ProfileDir = "$env:TEMP\chrome-cdp-hermes",
5+
[string]$ChromePath,
6+
[switch]$Cleanup,
7+
[switch]$RemoveProfile
8+
)
9+
10+
$ErrorActionPreference = "Stop"
11+
12+
function Test-IsAdministrator {
13+
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
14+
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
15+
}
16+
17+
function Assert-Administrator {
18+
if (-not (Test-IsAdministrator)) {
19+
Write-Error "Run PowerShell as Administrator, then rerun this script."
20+
}
21+
}
22+
23+
function Resolve-ChromePath {
24+
param([string]$OverridePath)
25+
26+
if ($OverridePath) {
27+
if (Test-Path -LiteralPath $OverridePath) {
28+
return (Resolve-Path -LiteralPath $OverridePath).Path
29+
}
30+
31+
Write-Error "ChromePath does not exist: $OverridePath"
32+
}
33+
34+
$candidates = @(
35+
"C:\Program Files\Google\Chrome\Application\chrome.exe",
36+
"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
37+
)
38+
39+
foreach ($candidate in $candidates) {
40+
if (Test-Path -LiteralPath $candidate) {
41+
return $candidate
42+
}
43+
}
44+
45+
Write-Error "Chrome was not found. Pass -ChromePath with the full path to chrome.exe."
46+
}
47+
48+
function Test-CdpEndpoint {
49+
param([string]$Url)
50+
51+
try {
52+
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2
53+
$json = $response.Content | ConvertFrom-Json
54+
return [bool]($json.Browser -and $json.webSocketDebuggerUrl)
55+
}
56+
catch {
57+
return $false
58+
}
59+
}
60+
61+
function Wait-CdpEndpoint {
62+
param(
63+
[string]$Url,
64+
[int]$Attempts = 20,
65+
[int]$DelayMilliseconds = 500
66+
)
67+
68+
for ($i = 1; $i -le $Attempts; $i++) {
69+
if (Test-CdpEndpoint -Url $Url) {
70+
return $true
71+
}
72+
73+
Start-Sleep -Milliseconds $DelayMilliseconds
74+
}
75+
76+
return $false
77+
}
78+
79+
function Remove-PortProxy {
80+
param([int]$ListenPort)
81+
82+
& netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort | Out-Null
83+
}
84+
85+
function Add-PortProxy {
86+
param(
87+
[int]$ListenPort,
88+
[int]$ConnectPort
89+
)
90+
91+
Remove-PortProxy -ListenPort $ListenPort
92+
& netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=$ListenPort connectaddress=127.0.0.1 connectport=$ConnectPort | Out-Null
93+
}
94+
95+
function Ensure-FirewallRule {
96+
param(
97+
[string]$DisplayName,
98+
[int]$LocalPort
99+
)
100+
101+
$existing = Get-NetFirewallRule -DisplayName $DisplayName -ErrorAction SilentlyContinue
102+
if ($existing) {
103+
$existing | Set-NetFirewallRule -Enabled True -Direction Inbound -Action Allow
104+
return
105+
}
106+
107+
New-NetFirewallRule `
108+
-DisplayName $DisplayName `
109+
-Direction Inbound `
110+
-Action Allow `
111+
-Protocol TCP `
112+
-LocalPort $LocalPort | Out-Null
113+
}
114+
115+
function Remove-FirewallRule {
116+
param([string]$DisplayName)
117+
118+
Get-NetFirewallRule -DisplayName $DisplayName -ErrorAction SilentlyContinue | Remove-NetFirewallRule
119+
}
120+
121+
if ($ChromePort -eq $ProxyPort) {
122+
Write-Error "ChromePort and ProxyPort must be different. Chrome uses $ChromePort locally; the portproxy should expose a separate port such as 9223."
123+
}
124+
125+
$firewallRuleName = "Hermes Chrome CDP $ProxyPort"
126+
$chromeUrl = "http://127.0.0.1:$ChromePort/json/version"
127+
$proxyUrl = "http://127.0.0.1:$ProxyPort/json/version"
128+
$containerHostName = "host.docker.internal"
129+
$containerUrl = "http://host.docker.internal:$ProxyPort"
130+
131+
Assert-Administrator
132+
133+
if ($Cleanup) {
134+
Write-Host "Removing portproxy for 0.0.0.0:$ProxyPort..."
135+
Remove-PortProxy -ListenPort $ProxyPort
136+
137+
Write-Host "Removing firewall rule '$firewallRuleName'..."
138+
Remove-FirewallRule -DisplayName $firewallRuleName
139+
140+
if ($RemoveProfile) {
141+
if (Test-Path -LiteralPath $ProfileDir) {
142+
Write-Host "Removing Chrome profile directory: $ProfileDir"
143+
Remove-Item -LiteralPath $ProfileDir -Recurse -Force
144+
}
145+
}
146+
else {
147+
Write-Host "Leaving Chrome profile directory in place: $ProfileDir"
148+
Write-Host "Pass -RemoveProfile with -Cleanup to delete it."
149+
}
150+
151+
Write-Host "Cleanup complete."
152+
exit 0
153+
}
154+
155+
$resolvedChromePath = Resolve-ChromePath -OverridePath $ChromePath
156+
New-Item -ItemType Directory -Path $ProfileDir -Force | Out-Null
157+
158+
Write-Host "Launching Chrome CDP on 127.0.0.1:$ChromePort..."
159+
$chromeArgs = @(
160+
"--remote-debugging-port=$ChromePort",
161+
"--remote-debugging-address=127.0.0.1",
162+
"--user-data-dir=""$ProfileDir""",
163+
"--no-first-run",
164+
"--no-default-browser-check"
165+
)
166+
167+
Start-Process -FilePath $resolvedChromePath -ArgumentList $chromeArgs | Out-Null
168+
169+
Write-Host "Waiting for Chrome CDP at $chromeUrl..."
170+
if (-not (Wait-CdpEndpoint -Url $chromeUrl)) {
171+
Write-Error "Chrome CDP did not become available at $chromeUrl. Close existing Chrome instances using port $ChromePort, then rerun this script."
172+
}
173+
174+
Write-Host "Creating portproxy 0.0.0.0:$ProxyPort -> 127.0.0.1:$ChromePort..."
175+
Add-PortProxy -ListenPort $ProxyPort -ConnectPort $ChromePort
176+
177+
Write-Host "Ensuring firewall rule '$firewallRuleName' allows inbound TCP $ProxyPort..."
178+
Ensure-FirewallRule -DisplayName $firewallRuleName -LocalPort $ProxyPort
179+
180+
Write-Host "Verifying local proxy at $proxyUrl..."
181+
if (-not (Wait-CdpEndpoint -Url $proxyUrl -Attempts 10)) {
182+
Write-Error "The portproxy was created, but $proxyUrl did not return Chrome CDP JSON. Check 'netsh interface portproxy show v4tov4' and Windows Firewall."
183+
}
184+
185+
Write-Host ""
186+
Write-Host "Chrome CDP is available to containers at:"
187+
Write-Host $containerUrl
188+
Write-Host ""
189+
Write-Host "Test from inside the devcontainer:"
190+
Write-Host "CDP_HOST=`$(getent ahostsv4 $containerHostName | awk 'NR==1 {print `$1}')"
191+
Write-Host "curl http://`$CDP_HOST:$ProxyPort/json/version"
192+
Write-Host ""
193+
Write-Host "Hermes/CodeForge endpoint:"
194+
Write-Host "http://`$CDP_HOST:$ProxyPort"
195+
Write-Host ""
196+
Write-Host "Why not use host.docker.internal directly?"
197+
Write-Host "Chrome rejects non-IP Host headers for CDP. Use the resolved IPv4 address from inside the devcontainer."

docs/src/content/docs/reference/environment-variables.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Variables set by the DevContainer environment that define workspace paths.
7474
| `CLAUDE_CONFIG_DIR` | Claude configuration directory | `/home/vscode/.claude` | devcontainer.json |
7575
| `GH_CONFIG_DIR` | GitHub CLI configuration directory | `/workspaces/.gh` | devcontainer.json |
7676
| `TMPDIR` | Temporary files directory | `/workspaces/.tmp` | devcontainer.json |
77+
| `HERMES_CDP_ENDPOINT` | Chrome CDP endpoint for Hermes/browser tooling inside the container | `http://192.168.65.254:9223` | devcontainer.json |
7778
| `CLAUDECODE` | Set to `null` to unset the detection flag, enabling nested Claude Code sessions | `null` | devcontainer.json |
7879

7980
## Tool-Specific Variables
@@ -86,6 +87,7 @@ Variables that configure individual tools within the container.
8687
| `CCMS_FORMAT` | ccms | Default output format (`text`, `json`) _(only when ccms is enabled)_ |
8788
| `OPENAI_API_KEY` | codex | OpenAI API key for Codex CLI authentication |
8889
| `CODEX_HOME` | codex | Override Codex config directory (default: `~/.codex/`) |
90+
| `HERMES_CDP_ENDPOINT` | hermes / browser tooling | Host Chrome CDP endpoint; Windows uses the `.devcontainer\scripts\start-hermes-chrome.ps1` portproxy on port 9223. Use the resolved IPv4 address for `host.docker.internal`; Chrome rejects DNS Host headers. |
8991
| `RUFF_CONFIG` | ruff | Path to ruff configuration file |
9092
| `BIOME_CONFIG_PATH` | biome | Path to biome configuration file |
9193

0 commit comments

Comments
 (0)