21. Relay fixed OAuth callback ports into the jail#

Date: 2026-09-11

Status#

Accepted

Extends 20. Relay a set of loopback ports to every agent (ADR 20) with a relay in the opposite direction. 19. Add Pi and an explicit single-port localhost relay (ADR 19) still governs pasta: its port forwarding stays disabled for every agent.

Context#

Pi’s Claude Pro/Max login opens an HTTP server on 127.0.0.1:53692 inside the agent’s private loopback and asks claude.ai to redirect the browser to http://localhost:53692/callback. Inside the egress jail that loopback is the agent’s own network namespace, so the browser on the host reaches nothing, and the tab spins until it times out. The authorization code is only in the URL of that hung tab, and a user who copies the address bar during the spin gets the previous page’s URL instead, whose code=true parameter pi then sends to the token endpoint. Pi offers no alternative: its earlier code-display flow was dropped upstream in 0.59.0 and the pasted-URL prompt is the only fallback.

Claude Code needs nothing here. Its login uses a random port but always offers Anthropic’s code-display page. Codex CLI and pi’s Codex login share fixed port 1455 and also offer a device-code flow. Pi’s Radius login uses fixed port 1456; its OpenRouter login picks a random port.

ADR 19 disabled pasta’s automatic forwarding in both directions because it exposed unrelated host-loopback listeners and occupied the relay’s port. ADR 20’s relay carries connections outward only: the outer socat connects to a host service, the inner socat listens inside the jail. Nothing in the sandbox gives a host process a path to a socket the agent opens.

Decision#

Add a callback-port relay set: TCP ports on which the outer container’s 127.0.0.1 listens and forwards into the same port on the agent’s loopback. The mechanism is ADR 20’s mirrored per port: one private Unix socket under the jail’s /tmp relay directory, the outer socat listening on the outer loopback and connecting to the socket, the inner socat listening on the socket and connecting to the agent’s loopback only when a connection arrives. No IP route is added and pasta’s forwarding stays off.

The set comes from repeatable callback-port lines in the root-owned conf merged with CLAUDE_SANDBOX_CALLBACK_PORTS from the environment, deduplicated and validated like local-port. A port may not appear in both sets: the outbound relay would hold the port the agent needs, and the inbound listener would hold the host service’s port.

The shipped conf lists 53692 live, because pi’s Claude login has no other route, and shows 1455 and 1456 as commented examples. The relay serves every agent: a port nothing inside listens on is refused immediately rather than held open.

Because this end listens on the outer loopback, it can collide with a port already taken there by a second agent session or an unwrapped agent. The relay fails soft: the session starts without that port, prints a warning naming it, and the browser fails fast so the redirect URL appears in the address bar for the pasted-URL fallback. This differs from the outbound relay, where a relay that cannot start is fatal.

Consequences#

  • Pi’s /login for Claude Pro/Max completes in the browser wherever the browser can reach the outer container’s loopback: on the same machine with --net=host, and through VS Code’s automatic port forwarding, which detects the outer listener, for a remote devcontainer.

  • The published image in bridge mode gains nothing until its launcher publishes the port on the host loopback; the pasted-URL fallback remains the route there.

  • This is the first path from the host into the jail. What it grants is narrow: a jailed agent can serve TCP on one fixed loopback port to processes on the host. The threat model concerns lateral movement outward; the realistic misuse here is a compromised agent serving a page to a host browser, which is the interaction the OAuth flow relies on anyway. Pi’s callback checks the OAuth state, so a stray request cannot inject a code. Any request with a bad state aborts pi’s login, a known upstream nuisance now reachable from the host loopback.

  • Every jailed session on a host with the shipped conf runs two more relay processes and holds 127.0.0.1:53692 on the outer loopback. Two concurrent sessions cannot both hold it; the second warns and falls back.

  • Logins that pick a random port (Claude Code, pi’s OpenRouter) cannot use a static relay and keep their existing fallbacks.

  • tests/local_model.sh proves a host-side connection reaches a listener inside the jail, that a missing inner listener is refused fast, that a neighbouring port is not exposed, that the relay dies with the session, and that a taken port fails soft with the warning. tests/pi.sh covers the shipped default, merging, deduplication, validation and the both-ways overlap rejection.