name: hd-port-watcher description: > Port reachability in Hydrogen Desktop — and why you do NOT need to set up port forwarding. Under WSL2 networkingMode=mirrored (HD's default, which the setup cascade hard-targets), the distro SHARES the Windows loopback, so every port a service binds in the workspace — including 127.0.0.1-only OAuth callback servers from VS Code extensions (Codex, Copilot, GitLens) — is reachable at the same localhost:<port> on Windows automatically. No daemon, no proxies, no manual mapping. The old port-watcher daemon was a Docker/NAT-era mechanism and is not used under mirrored networking. Trigger words: port forward, dynamic port, OAuth callback, Codex auth, extension auth, port mapping, localhost port, port watcher, port proxy, does port forwarding work, why can't I reach my port.

HD Port Watcher — you don't port-watch anymore (mirrored networking does it)

TL;DR for the AI: do NOT try to set up port forwarding in HD. Under the default WSL2 networkingMode=mirrored, ports just work. If a localhost:<port> from the workspace isn't reachable on Windows, the fix is almost never "forward the port" — it's "confirm mirrored is on" (see below), or the service didn't actually bind.

Why there's nothing to do (mirrored networking)

HD's setup cascade hard-targets networkingMode=mirrored in .wslconfig. Under mirrored, the WSL2 distro shares the Windows loopback interface, so any listener in the distro — 0.0.0.0:<port> AND 127.0.0.1:<port> — is reachable at the same localhost:<port> on Windows with zero setup. This is why HD's own OAuth host-proxy is explicitly skipped under mirrored (lib.rs: "under WSL2 mirrored it's redundant… host proxy not needed").

The legacy daemon (Docker / non-mirrored only — historical)

The port-watcher daemon below existed for the pre-mirroring world: the Docker/NAT runtime, where the distro could NOT reach Windows loopback, so a daemon watched /proc/net/tcp for new listeners and HD spun up host-side TCP proxies. It closed two gaps that DO NOT EXIST under mirrored:

  1. 127.0.0.1-only listeners. NAT-mode WSL2 did NOT forward loopback-only listeners. VS Code extensions (Codex, Copilot, GitLens) start OAuth callback servers that frequently bind 127.0.0.1; without mirroring the browser redirect to localhost:{port}/auth/callback?code=... hit a port that didn't exist on Windows.
  2. Auto-forward lag / wedge. WSL2 localhost-forwarding can lag or break even for 0.0.0.0 listeners (a real HD-logged failure mode: "code-server alive in distro but Windows can't reach 127.0.0.1:7380"). The watcher gives HD a reliable host-side proxy that doesn't depend on WSL2's forwarder.

The Solution

A lightweight daemon inside the distro monitors /proc/net/tcp for new listening ports. When one appears (that wasn't there at boot and isn't a known service), it notifies HD's control API. HD creates a host-side TCP proxy on that same port, forwarding through code-server's /proxy/{port} to reach the distro. The browser redirect just works — even for loopback-only callback servers WSL2 wouldn't have forwarded.

Architecture

WSL2 distro (Adom-Workspace):       Windows Host:
┌─────────────────────┐            ┌──────────────────────┐
│ Codex creates server │           │ HD Control API       │
│ on 127.0.0.1:1455    │           │ (:47084)             │
│ (WSL2 won't forward) │           │                      │
│ port-watcher.sh      │──POST──→ │ /port-forward        │
│ detects port 1455    │  open     │ creates TCP proxy    │
│                      │           │ on localhost:1455     │
│ code-server          │           │                      │
│ /proxy/1455/ ←───────┼───────── │ proxy forwards here  │
└─────────────────────┘            └──────────────────────┘
                                          ↑
                                   Browser redirects to
                                   localhost:1455/callback

What Gets Forwarded (and what doesn't)

FORWARDED (temporary, appeared after boot):

  • OAuth callback ports from extensions (Codex, Copilot, etc.) — especially 127.0.0.1-only ones WSL2 cannot auto-forward
  • Any new TCP listener that wasn't running when the distro started
  • Auto-expires after 5 minutes (OAuth callbacks are fast)

NOT FORWARDED (known services, stay behind /proxy/{port} or auto-forward):

  • code-server (8080 inside distro; auto-forwarded as 7380 mapping/0.0.0.0)
  • relay WS/HTTP (8765, 8766)
  • adom-vscode (8821)
  • shotlog (8820)
  • Any port in the KNOWN_PORTS list
  • Any port that was listening at distro boot time
  • Any port ≤ 1023

This keeps the local WSL workspace behaving like cloud Hydrogen — known services use /proxy/{port} (or WSL2 auto-forward for 0.0.0.0 binds), and only short-lived / loopback-only OAuth ports get a dedicated host proxy.

API Endpoints

POST /port-forward

Create or remove a host-side TCP proxy. Called by the distro's watcher.

{"port": 1455, "action": "open"}   // Create proxy
{"port": 1455, "action": "close"}  // Remove proxy

GET /port-forward

List currently forwarded ports.

{"ok": true, "forwarded_ports": [1455], "count": 1}

The Watcher Daemon

scripts/port-watcher.sh runs inside the distro:

  1. Polls /proc/net/tcp every 2 seconds
  2. Diffs against previous scan
  3. New port (not in KNOWN_PORTS whitelist) → POST /port-forward open
  4. Port gone → POST /port-forward close
  5. Port open > 5 minutes → auto-expire (likely a service, not OAuth)

No boot snapshot — the whitelist is the sole source of truth. Services like shotlog can start at any time (days/weeks later) and will never get a host proxy as long as their port is in the whitelist.

The watcher reaches HD at 127.0.0.1:<control>/port-forward — WSL2 mirrored networking shares loopback with the Windows host, so 127.0.0.1 from inside the distro reaches HD's host listener. The control port is dynamic per launch; the live control URL is in ~/.adom/hd-control-url.

Installation

The watcher is a bash script (port-watcher.sh) deployed into the distro during setup steps. Installation uses wsl -d Adom-Workspace, NOT docker cp/docker exec:

# Copy the script into the distro (from Windows host, via the \\wsl$ share or wsl cat)
wsl -d Adom-Workspace -u root -- bash -c 'cat > /usr/local/bin/port-watcher.sh' < port-watcher.sh
wsl -d Adom-Workspace -u root -- chmod +x /usr/local/bin/port-watcher.sh
# Launch detached inside the distro
wsl -d Adom-Workspace -u adom -- bash -c 'nohup bash /usr/local/bin/port-watcher.sh >/tmp/port-watcher.log 2>&1 &'

The script parses /proc/net/tcp directly in awk — it filters listener rows (state 0A), splits the hex local_address, and converts the port with strtonum("0x" …). No Python is involved.

UI: Toast + Port Mappings Dialog

Toast notification: When a port is forwarded, HD shows an info toast: "Port 1455 forwarded to workspace". When removed: "Port 1455 forwarding stopped". Powered by the port-forward-changed Tauri event.

Port Mappings dialog: Accessible from the Adom menu → Admin → "Port Mappings". Shows auto-forwarded 0.0.0.0 ports AND dynamic watcher-created proxies. Dynamic ports have a "Stop" button. Matches the API Explorer visual style.

Adding New Known Services

When a new service is added to the distro that runs a persistent TCP server, add its port to the KNOWN_PORTS whitelist string in port-watcher.sh (scripts/port-watcher.sh:16):

KNOWN_PORTS="8080 8765 8766 8821 8820 36035 47084 47083 47085 1420 7380 8770 9000"

This prevents it from getting a dedicated host proxy. Services should bind 0.0.0.0 (auto-forwarded) or use /proxy/{port} instead, consistent with web Hydrogen.

Why Both Layers

There are TWO distinct forwarding mechanisms. The /proc/net/tcp watcher above is Layer 2. Layer 1 is a completely separate path — the OAuth-callback localhost proxy baked into HD itself — and it's why VS Code extension logins "just work" without any watcher round-trip.

Layer 1 — the OAuth-callback localhost proxy (zero-latency, the primary path)

HD intercepts top-level WebView2 navigations via setup_frame_navigation_interception() (hd-app/src/lib.rs:75). On every navigation it runs extract_oauth_callback_port() (lib.rs:384), which triple-URL-decodes the target URL (urlencoding::decode x3) and scans each decode layer — plus the raw and the percent-encoded redirect_uri=http%3A%2F%2Flocalhost%3A… form — for redirect_uri=http://localhost:<port>. When it finds one, it calls start_oauth_callback_proxy(port) (lib.rs:411), which pre-binds 127.0.0.1:<port> on the Windows host (via hd_control::reuse_bind_tcp) and waits up to ~5 min for one inbound request. When the browser redirects to localhost:<port>/callback?code=…, HD's pre-bound listener accepts it and forwards the full path into the distro via code-server: http://localhost:<code_server_port>/proxy/<port>/<path> (lib.rs:454), then streams code-server's response back to the browser.

So the callback port is bound the instant the OAuth URL is seen leaving the WebView — before the provider ever redirects — which is why Codex / Copilot OAuth completes with zero manual setup and zero watcher latency.

Control / callback endpoints (HD control API, port 47084):

  • POST /start-oauth-proxy — manually pre-bind a callback port (same effect as the navigation interceptor; used by the manual fallback).
  • GET /oauth/callback — HD's own built-in callback receiver (used by the Claude Code PKCE flow); do not call directly.

Failure mode the AI may see: if the proxy binds but code-server can't be reached, HD returns a styled 502 Bad Gateway page (dark #0d1117 background, teal #00b8b0 "OAuth callback proxy" heading) reading roughly "HD tried to forward this OAuth callback to the container via code-server proxy (port …/proxy/…), but it failed. The extension that started this OAuth flow may need its callback port (…) mapped to the host." Seeing that page in a browser means Layer 1 fired but the distro-side proxy hop failed — check the HD log for [oauth-proxy] lines and that code-server is alive.

Layer 2 — the port watcher (/proc/net/tcp daemon, the safety net)

The bash daemon documented above. Safety net for extensions that open OAuth URLs through mechanisms that bypass top-level WebView navigations, AND the only thing that surfaces 127.0.0.1-only callback servers WSL2 silently won't forward. Note: when an app has already registered its port via the adom-port-hints protocol, the watcher skips it (it only owns the fallback for third-party extensions that don't know HD exists).

Layer 3 — manual

POST /start-oauth-proxy or POST /port-forward (or adom-cli port-forward register) for cases where both automatic layers miss.

Port Conflicts

If the host port is already taken by another Windows app, the forwarding fails silently. The OAuth callback will break. Mitigation: extensions typically pick a different random port on retry. HD logs the conflict.

How OAuth Callbacks Work End-to-End

  1. Extension (Codex) starts HTTP server on 127.0.0.1:1455 inside the distro
  2. port-watcher.sh detects port 1455 (not in boot snapshot, not known) — note WSL2 would NOT have auto-forwarded this loopback-only listener
  3. Watcher POSTs {"port":1455,"action":"open"} to HD
  4. HD creates TCP proxy: localhost:1455 → code-server /proxy/1455/
  5. Extension opens browser with redirect_uri=http://localhost:1455/callback
  6. User authenticates, browser redirects to localhost:1455/callback?code=...
  7. HD's proxy receives the request, forwards into the distro via code-server
  8. Extension receives callback, completes auth
  9. Extension closes its server, port 1455 disappears
  10. Watcher POSTs {"port":1455,"action":"close"}, HD removes proxy

Debugging

All debugging runs inside the distro via wsl -d Adom-Workspace -u adom -- ..., NEVER docker exec:

# Check what ports the watcher sees
wsl -d Adom-Workspace -u adom -- bash -c "cat /proc/net/tcp | awk '\$4 == \"0A\" { split(\$2, a, \":\"); printf \"%d\\n\", strtonum(\"0x\" a[2]) }' | sort -n"

# Check the watcher's own log
wsl -d Adom-Workspace -u adom -- cat /tmp/port-watcher.log

# Check what HD has forwarded (from Windows host)
curl -sf http://127.0.0.1:47084/port-forward

# Manually forward a port (for testing, from Windows host)
curl -sf -X POST http://127.0.0.1:47084/port-forward \
  -H Content-Type:application/json \
  -d '{"port":1455,"action":"open"}'

# Check HD log for port-fwd events
# Look for [port-fwd] entries
  • hd-networking0.0.0.0 auto-forward, reaching the host at 127.0.0.1 (mirrored networking / loopback), NEVER do these
  • hd-ports — bind 0.0.0.0 first; fall back to the port-forward registry for 127.0.0.1-only services or unreliable auto-forward