name: hd-port-watcher description: > Dynamic port forwarding for Hydrogen Desktop. A daemon inside the Adom-Workspace machine watches for new TCP listeners (OAuth callbacks from VS Code extensions like Codex, Copilot, GitLens) and notifies HD to create host-side TCP proxies. Covers the cases macOS/Lima port exposure can't: 127.0.0.1-only listeners and exposure gaps. Makes localhost:{port} on the Mac reach the machine. Extensions just work — no manual setup. Trigger words: port forward, dynamic port, OAuth callback, Codex auth, extension auth, port mapping, localhost port, port watcher, port proxy.

HD Port Watcher — Dynamic Port Forwarding

This is the machine-runtime version (default). For the legacy Docker container runtime (HD_RUNTIME=docker) see the docker/ bucket.

Why the daemon is still here on the machine

When a service inside the Adom-Workspace machine binds 0.0.0.0:<port>, Lima's vz networking makes it reachable from the Mac at localhost:<sameport> — so you might think a port watcher is unnecessary. It isn't. The daemon is RETAINED because that 0.0.0.0 exposure has two gaps the watcher closes:

  1. 127.0.0.1-only listeners. vz does NOT mirror loopback, so a loopback-only listener inside the machine is never exposed on the Mac. VS Code extensions (Codex, Copilot, GitLens) start OAuth callback servers that frequently bind 127.0.0.1 — the browser redirects to localhost:{port}/auth/callback?code=... but the Mac never had that port, so it doesn't exist.
  2. Exposure lag / wedge. macOS/Lima port exposure can lag or break even for 0.0.0.0 listeners (a real HD-logged failure mode: "code-server alive in the machine but the Mac can't reach 127.0.0.1:7380"). The watcher gives HD a reliable host-side proxy that doesn't depend on Lima's automatic exposure.

The Solution

A lightweight daemon inside the machine 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 machine. The browser redirect just works — even for loopback-only callback servers Lima wouldn't have exposed.

Architecture

Adom-Workspace machine:             macOS Host:
┌─────────────────────┐            ┌──────────────────────┐
│ Codex creates server │           │ HD Control API       │
│ on 127.0.0.1:1455    │           │ (:47084)             │
│ (loopback, no expose)│           │                      │
│ 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 Lima cannot expose
  • Any new TCP listener that wasn't running when the machine started
  • Auto-expires after 5 minutes (OAuth callbacks are fast)

NOT FORWARDED (known services, stay behind /proxy/{port} or 0.0.0.0 exposure):

  • code-server (8080 inside the machine; exposed 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 machine boot time
  • Any port ≤ 1023

This keeps the local workspace machine behaving like cloud Hydrogen — known services use /proxy/{port} (or macOS/Lima exposure 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 machine'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 machine:

  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 the adom-host gateway plus the control port (<adom-host>:<control>/port-forward). vz does NOT mirror loopback, so the machine cannot reach HD at its own 127.0.0.1; instead the machine's /etc/hosts maps adom-host to the gateway, and that's where HD's host listener lives. The control port is dynamic per launch; the live control URL (already pointing at the gateway host) is in ~/.adom/hd-control-url.

Installation

The watcher is a bash script (port-watcher.sh) deployed into the machine during setup steps. Installation uses limactl shell hd-builder + nsenter into the machine, NOT docker cp/docker exec:

# Stage the script into the Lima VM, then install it into the machine via nsenter.
# (No \\wsl$ share / wsl cat exists on macOS — copy into the VM, then nsenter.)
limactl copy port-watcher.sh hd-builder:/tmp/port-watcher.sh
limactl shell hd-builder -- sudo nsenter -t <leader> -a -- bash -lc \
  'cp /tmp/port-watcher.sh /usr/local/bin/port-watcher.sh && chmod +x /usr/local/bin/port-watcher.sh'
# Launch detached inside the machine (as the adom user)
limactl shell hd-builder -- sudo nsenter -t <leader> -a -S 1001 -G 1001 -- bash -lc \
  '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 exposed 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 machine 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 (exposed by Lima) or use /proxy/{port} instead, consistent with web Hydrogen.

The forwarding layers

There are distinct forwarding mechanisms. The /proc/net/tcp watcher above is Layer 2 — and on macOS it is the primary path. Layer 1 (the navigation-interception localhost proxy) is Windows-only and does NOT run on macOS — see the note below.

Layer 1 — the navigation-interception proxy (Windows-only; NOT active on macOS)

On Windows, HD intercepts top-level WebView2 navigations via setup_frame_navigation_interception() (hd-app/src/lib.rs, #[cfg(windows)]). On every navigation it runs extract_oauth_callback_port(), 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), which pre-binds 127.0.0.1:<port> on the host and waits up to ~5 min for one inbound request, then forwards the full path into the machine via code-server (http://localhost:<code_server_port>/proxy/<port>/<path>) and streams the response back to the browser.

This interceptor does NOT exist on macOS. It depends on WebView2's ICoreWebView2 navigation events; HD's macOS webview is WKWebView, which exposes no equivalent navigation-interception (the same CDP gap that makes /eval fire-and-forget on macOS). So on macOS the automatic zero-latency pre-bind doesn't happen — the port watcher (Layer 2) is the primary path, with the manual POST /start-oauth-proxy endpoint (Layer 3) as the explicit fallback. The /start-oauth-proxy + /oauth/callback endpoints themselves are cross-platform and work the same on macOS.

Control / callback endpoints (HD control API):

  • POST /start-oauth-proxy — manually pre-bind a callback port (on macOS this is the explicit way to get the Layer-1 effect, since the navigation interceptor doesn't run).
  • 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 a 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 workspace 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 the proxy bound but the machine-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 primary path on macOS)

The bash daemon documented above. On macOS it is the workhorse (Layer 1's navigation interceptor is Windows-only): it surfaces new callback ports — including 127.0.0.1-only callback servers Lima silently won't expose — and asks HD to proxy them. 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 the watcher misses (on macOS, where the Windows-only navigation interceptor doesn't run, this is the explicit pre-bind path).

Port Conflicts

If the host port is already taken by another Mac 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 machine
  2. port-watcher.sh detects port 1455 (not in boot snapshot, not known) — note Lima would NOT have exposed 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 machine 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 machine via limactl shell hd-builder -- sudo nsenter -t <leader> -a -S 1001 -G 1001 -- ..., NEVER docker exec:

# Check what ports the watcher sees
limactl shell hd-builder -- sudo nsenter -t <leader> -a -S 1001 -G 1001 -- bash -lc "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
limactl shell hd-builder -- sudo nsenter -t <leader> -a -S 1001 -G 1001 -- cat /tmp/port-watcher.log

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

# Manually forward a port (for testing, from the Mac 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 exposure, reaching the host at the adom-host gateway (vz does NOT mirror 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 exposure