Cross-container MCP OAuth callback bridge (flakes-wq6s)
Claude Code running inside a nested container does MCP OAuth, but the callback listener is loopback-only and the redirect host is hardcoded to http://localhost:<port>/callback. The browser opens on the Mac, so localhost resolves to the Mac, not the container — today this needs a manual URL-copy + in-container curl to finish the flow.
Ground truth (from the CC binary)
- Callback port default is a random ephemeral port.
MCP_OAUTH_CALLBACK_PORTenv wins unconditionally (no availability check) — pinning is mandatory.- Listener binds
127.0.0.1only; redirect host/path is hardcoded (only the port is configurable, not the host).
Fix — pinned-port loopback bridge
Mac browser -> http://localhost:3118/callback
-> [Mac launchd] socat 127.0.0.1:3118 -> <container-ip>:3118
-> [container] Caddy site http://:3118 { bind <container-ip>; reverse_proxy 127.0.0.1:3118 }
-> Claude Code listener @ 127.0.0.1:3118 (done)
- Pin
MCP_OAUTH_CALLBACK_PORT=3118in the container env. - Container-side bridge relays
<container-ip>:3118 -> 127.0.0.1:3118. A socket bound tolowon’t serve a connection arriving oneth0, so a bridge is required. Reuse the already-running reverse proxy (Caddy) instead of a new socat daemon.- Gotcha: a Caddy site-address host controls Host matching, not the listen interface.
http://<ip>:3118listens on0.0.0.0and steals CC’s127.0.0.1:3118(env port has no availability check). Usehttp://:3118 { bind <ip> }so it binds the container IP only, leaving loopback free for CC.
- Gotcha: a Caddy site-address host controls Host matching, not the listen interface.
- Mac forwarder = launchd socat over the existing host→container route.
Phase 2 — auto-open the authorize URL (true zero-touch)
Mac launchd listener that runs open <url>; container sets BROWSER to a script that ships the URL to it (+ an xdg-open shim). Restrict by shared secret, NOT subnet — the bridge NATs container traffic so the Mac sees the VM’s source IP, not the container’s.
Scope in this repo
- In-repo (NixOS container configs under
nixos/containers/): theMCP_OAUTH_CALLBACK_PORT=3118env var and the Caddyhttp://:3118 { bind <ip> }site. Container IPs are already statically assigned (seemixins/nixos/services/incus-ingress.nix). - External (not managed by this flake): the Mac-side launchd socat forwarder and the Phase-2
open <url>listener live on the macOS host (lima VM), outside this repo. - Verification caveat: the end-to-end OAuth flow can only be confirmed against the live container + Mac; a build here proves the config evaluates, not that the bridge works.
Todo
- Pinned
MCP_OAUTH_CALLBACK_PORT=3118(NixOS container env viapacks/nixos/container/mcp-oauth-bridge.nix; also fed to the ingress-sync service) - Caddy bridge site emitted by the ingress generator (
generate-ingress-config.bash, both nixos + home copies): detects container IP viaip route getand writes/var/lib/caddy/mcp-oauth.confwithhttp://:3118 { bind <ip>; reverse_proxy 127.0.0.1:3118 }. Verified withcaddy validate - Mac-side launchd socat forwarder documented as external (see follow-up bean)
- (external follow-up) Phase 2:
BROWSERshim + xdg-open script + shared-secret auth - (external follow-up) Verify end-to-end against a live container + Mac forwarder
Summary of Changes
Implemented the in-repo (container-side) half of the bridge and verified it; the macOS-host half is tracked separately in flakes-mqe9.
Files
packs/nixos/container/mcp-oauth-bridge.nix(new): pinsMCP_OAUTH_CALLBACK_PORT=3118(login-shell env for Claude Code) and passes the same port to theingress-syncsystemd service.packs/nixos/container/ingress-scripts/generate-ingress-config.bashandpacks/home/container/ingress/generate-ingress-config.bash(identical edits): the ingress generator now detects the container IP (ip -4 route get 1) and writes/var/lib/caddy/mcp-oauth.conf=http://:3118 { bind <ip>; reverse_proxy 127.0.0.1:3118 }, before the existing atomiccaddy reload. The IP-detect assignment uses|| trueso a missing default route at early boot can’t abort the whole generator underset -euo pipefail.
Why a dedicated IP-bound site: CC’s callback listener binds 127.0.0.1:3118 only and its env-pinned port has no availability check, so the bridge must bind the container IP (eth0) — not 0.0.0.0 — leaving loopback free for CC.
Verified: nix eval confirms the env var + ingress-sync Environment; the full yolo system toplevel evaluates; caddy validate returns Valid configuration for the generated bridge site (real IP 10.100.0.100) in a Caddyfile mirroring the real structure; bash -n passes; both generator copies remain identical. A review subagent verified module merge, no port collision with the auto-generated 3118.<host>.incus site, env delivery, and found the pipefail bug (fixed).
Not done (external → flakes-mqe9): the Mac launchd socat forwarder (localhost:3118 → <container-ip>:3118), Phase 2 zero-touch auto-open, and live end-to-end verification — these live on the macOS host/lima VM, outside this repo.