Skip to content

Commit 4127d5e

Browse files
feat(proxy): serve on a Unix domain socket (--uds)
`headroom proxy --uds PATH` binds an AF_UNIX socket instead of a TCP port. Request handling is untouched; this is the transport shell only. The motivation is the client-side gate in #1779. Claude Code v2.1.196+ parses ANTHROPIC_BASE_URL and compares the host against api.anthropic.com exactly, disabling Remote Control, the 1M window, and on-demand tool loading whenever a loopback proxy is configured. The gate is also satisfied by ANTHROPIC_UNIX_SOCKET, so a socket transport lets a client keep a first-party base URL while its traffic still reaches Headroom. Headroom cannot fix this server-side the way --1m restores the context-1m header, because both the eligibility check and the Remote Control bridge live in the client. The socket is useful on its own terms too: no port to collide with, nothing listening on the network, and access governed by filesystem permissions. Path handling refuses more than it deletes. It clears a stale socket left by a crashed proxy, but refuses to start when something is still listening (two proxies would split traffic), when the path exists as a non-socket (far more likely a typo'd argument over real data than a leftover), or when the path exceeds the platform's sun_path limit, which otherwise fails inside bind() with an error that names nothing. The parent directory is created 0700, which is the whole access-control boundary since a Unix socket carries no credentials of its own. POSIX only: Windows exposes no socket.AF_UNIX to Python and asyncio has no Windows UDS transport, so the CLI rejects --uds up front and points at --port. Docs cover the flag, the permission model, and the Claude Code wiring, with the Remote Control route marked experimental and unverified end-to-end. Refs #1779
1 parent 17522fb commit 4127d5e

7 files changed

Lines changed: 559 additions & 10 deletions

File tree

docs/content/docs/proxy.mdx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Telemetry is **local-only and off by default**. `HEADROOM_TELEMETRY=on` (or `--t
3232
|--------|---------|-------------|
3333
| `--host` | `127.0.0.1` | Host to bind to |
3434
| `--port` | `8787` | Port to bind to |
35+
| `--uds` | None | Serve on a Unix domain socket at this path instead of `--host`/`--port`. POSIX only — Windows has no `AF_UNIX` support in Python or asyncio. See [Serving on a Unix socket](#serving-on-a-unix-socket) |
3536
| `--workers` | `1` | Number of Uvicorn worker processes |
3637
| `--limit-concurrency` | `1000` | Maximum concurrent connections before Uvicorn returns 503 |
3738
| `--max-connections` | `500` | Maximum upstream HTTP connections |
@@ -193,6 +194,54 @@ HEADROOM_SAVINGS_PROFILE=balanced HEADROOM_TARGET_RATIO=0.15 headroom proxy
193194

194195
For permanent custom profiles, see the profile definitions in `headroom/agent_savings.py`. Each profile is an `AgentSavingsProfile` dataclass with fields for compression mode, target ratio, turn protection, and pipeline toggles.
195196

197+
## Serving on a Unix socket
198+
199+
`--uds` binds an `AF_UNIX` socket instead of a TCP port. Request handling is
200+
identical — only the transport changes.
201+
202+
```bash
203+
headroom proxy --uds ~/.headroom/run/proxy.sock
204+
```
205+
206+
Two reasons to prefer it over a loopback port:
207+
208+
- **No port to collide with, and no port exposed.** Access is governed by
209+
filesystem permissions rather than by anything listening on the network.
210+
- **Clients that gate features on the API host.** Claude Code v2.1.196+ turns off
211+
Remote Control, the 1M context window, and on-demand tool loading when
212+
`ANTHROPIC_BASE_URL` names a host other than `api.anthropic.com`. A socket lets
213+
the base URL stay first-party while the traffic still arrives at Headroom — see
214+
[Remote Control unavailable through custom ANTHROPIC_BASE_URL](/docs/troubleshooting#remote-control-unavailable-through-custom-anthropic_base_url).
215+
216+
Point any Unix-socket-capable client at the path. With curl:
217+
218+
```bash
219+
curl --unix-socket ~/.headroom/run/proxy.sock http://localhost/health
220+
```
221+
222+
### Permissions and lifecycle
223+
224+
Headroom creates the socket's parent directory `0700`, so only the owning user can
225+
reach the socket inside it. A Unix socket carries no credentials of its own, so
226+
**the directory mode is the access-control boundary** — do not place the socket in
227+
a world-writable directory such as a bare `/tmp`.
228+
229+
On startup Headroom removes a stale socket left behind by a crashed process, but
230+
it refuses to start when:
231+
232+
- something is still listening on the path (two proxies would silently split traffic),
233+
- the path exists and is not a socket (far more likely a typo'd argument pointing
234+
at real data than a leftover), or
235+
- the path exceeds the platform's `sun_path` limit — 108 bytes on Linux, 104 on
236+
macOS and the BSDs. Keep it short; a long path fails inside `bind()` with an
237+
error that names nothing.
238+
239+
<Callout type="warn">
240+
`--uds` is POSIX-only. On Windows the command exits immediately with an error
241+
telling you to use `--port`; Python exposes no `socket.AF_UNIX` there and
242+
asyncio has no Windows UDS transport.
243+
</Callout>
244+
196245
## Configuration in depth
197246

198247
Proxy behavior is set by three layers, **each overriding the one before**:

docs/content/docs/troubleshooting.mdx

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,39 @@ See [issue #746](https://github.com/headroomlabs-ai/headroom/issues/746) for the
215215

216216
## Remote Control unavailable through custom ANTHROPIC_BASE_URL
217217

218-
**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent.
218+
**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent. Invoking `/remote-control` reports `Remote Control is only available when using Claude via api.anthropic.com`.
219219

220-
**Cause**: This is a Claude-side gate. Headroom only receives normal API traffic and can still compress it, but Claude evaluates Remote Control availability before proxy traffic reaches the server.
220+
**Cause**: A client-side gate, evaluated before any traffic reaches Headroom. Claude Code v2.1.196+ parses `ANTHROPIC_BASE_URL` and compares the host against `api.anthropic.com` exactly, so a loopback address fails it. The check is satisfied by any one of:
221221

222-
**Fix**: Use Headroom for normal proxied API sessions, and launch Claude directly (without `ANTHROPIC_BASE_URL`) when you need Claude Remote Control.
222+
- `ANTHROPIC_BASE_URL` unset, or naming `api.anthropic.com` as its host, or
223+
- `ANTHROPIC_UNIX_SOCKET` pointing at a Unix domain socket.
224+
225+
Note that Remote Control does **not** honor `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL`, the flag that re-enables the 1M window and on-demand tool loading — it reads the base URL directly. Headroom cannot re-enable Remote Control from the server side the way `--1m` restores the `context-1m` beta header, because the eligibility decision and the bridge connection both happen in the client.
226+
227+
**Fix**: Launch Claude directly, without `ANTHROPIC_BASE_URL`, for sessions that need Remote Control, and use Headroom for the rest.
228+
229+
**Alternative (experimental)**: serve Headroom on a Unix socket and hand Claude Code the socket instead of a URL. The base URL stays first-party, so the gate passes, while the traffic still reaches Headroom:
230+
231+
```bash
232+
headroom proxy --uds ~/.headroom/run/proxy.sock
233+
234+
# in the session that should be routed
235+
export ANTHROPIC_UNIX_SOCKET=~/.headroom/run/proxy.sock
236+
export ANTHROPIC_BASE_URL=http://api.anthropic.com
237+
claude
238+
```
239+
240+
<Callout type="warning">
241+
This route is unverified end-to-end and is not yet wired into `headroom wrap
242+
claude`. Remote Control also depends on a server-side rollout flag and on
243+
subscription authentication, so a passing host check is necessary but not
244+
sufficient. Claude Code additionally strips `ANTHROPIC_UNIX_SOCKET` and
245+
`ANTHROPIC_BASE_URL` from child processes, so a hook that shells out to
246+
`claude` bypasses Headroom entirely. Report results on
247+
[issue #1779](https://github.com/headroomlabs-ai/headroom/issues/1779).
248+
</Callout>
249+
250+
The upstream request to allow loopback proxies is tracked at [anthropics/claude-code#76653](https://github.com/anthropics/claude-code/issues/76653).
223251

224252
`ENABLE_TOOL_SEARCH` is unaffected and can stay enabled for context-window savings while routing through Headroom.
225253

headroom/cli/proxy.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,17 @@ def dashboard(port: int, no_open: bool) -> None:
228228
envvar="HEADROOM_HOST",
229229
help="Host to bind to (default: 127.0.0.1, env: HEADROOM_HOST)",
230230
)
231+
@click.option(
232+
"--uds",
233+
default=None,
234+
envvar="HEADROOM_UDS",
235+
metavar="PATH",
236+
help=(
237+
"Serve on a Unix domain socket instead of --host/--port. POSIX only. "
238+
"Lets a client keep a first-party base URL while its traffic still "
239+
"reaches Headroom (env: HEADROOM_UDS)."
240+
),
241+
)
231242
@click.option(
232243
"--port",
233244
"-p",
@@ -1014,6 +1025,7 @@ def proxy(
10141025
mode: str | None,
10151026
target_ratio: float | None,
10161027
host: str,
1028+
uds: str | None,
10171029
port: int,
10181030
workers: int,
10191031
limit_concurrency: int,
@@ -1116,6 +1128,17 @@ def proxy(
11161128
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
11171129
"""
11181130
_reexec_with_malloc_tuning()
1131+
1132+
# Fail before any dependency loading or config work: an unusable --uds is a
1133+
# typo or an unsupported platform, and both are cheaper to report up front.
1134+
if uds:
1135+
from headroom.proxy.uds import UdsError, require_uds_support
1136+
1137+
try:
1138+
require_uds_support()
1139+
except UdsError as exc:
1140+
raise click.ClickException(str(exc)) from exc
1141+
11191142
ensure_proxy_dependencies()
11201143

11211144
# Import here to avoid slow startup
@@ -1296,6 +1319,7 @@ def proxy(
12961319
config = ProxyConfig(
12971320
host=host,
12981321
port=port,
1322+
uds=uds,
12991323
rollout=rollout_snapshot,
13001324
anthropic_api_url=provider_api_overrides.anthropic,
13011325
anthropic_extra_headers=resolved_anthropic_extra_headers,
@@ -1612,6 +1636,25 @@ def proxy(
16121636
else:
16131637
tuning_section = ""
16141638

1639+
# A socket has no URL, and the client-side wiring for it is a different pair
1640+
# of env vars, so the banner's URL and Usage blocks both switch shape.
1641+
if config.uds:
1642+
listen_display = f"unix:{config.uds}"
1643+
usage_section = "\n".join(
1644+
(
1645+
f" Claude Code: ANTHROPIC_UNIX_SOCKET={config.uds} \\",
1646+
" ANTHROPIC_BASE_URL=http://api.anthropic.com claude",
1647+
)
1648+
)
1649+
else:
1650+
listen_display = f"http://{config.host}:{config.port}"
1651+
usage_section = "\n".join(
1652+
(
1653+
f" Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude",
1654+
f" Codex / OpenAI: OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app",
1655+
)
1656+
)
1657+
16151658
click.echo(f"""
16161659
╔═══════════════════════════════════════════════════════════════════════╗
16171660
║ HEADROOM PROXY ║
@@ -1620,7 +1663,7 @@ def proxy(
16201663
16211664
Starting proxy server...
16221665
1623-
URL: http://{config.host}:{config.port}
1666+
URL: {listen_display}
16241667
Mode: {config.mode}
16251668
Optimization: {"ENABLED" if config.optimize else "DISABLED"}
16261669
Caching: {"ENABLED" if config.cache_enabled else "DISABLED"}
@@ -1641,8 +1684,7 @@ def proxy(
16411684
/v1/projects/.../publishers/... → {vertex_url}
16421685
16431686
Usage:
1644-
Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude
1645-
Codex / OpenAI: OPENAI_BASE_URL=http://{config.host}:{config.port}/v1 your-app
1687+
{usage_section}
16461688
{memory_section}
16471689
Endpoints:
16481690
GET /livez Process liveness

headroom/proxy/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ class ProxyConfig:
140140
# Server
141141
host: str = "127.0.0.1"
142142
port: int = 8787
143+
# Serve on this Unix domain socket instead of host:port. POSIX only; see
144+
# headroom/proxy/uds.py for why a socket transport exists at all (GH #1779).
145+
uds: str | None = None
143146
# Resolved at this configuration boundary and then injected unchanged.
144147
rollout: RolloutSnapshot | None = None
145148
anthropic_api_url: str | None = None # Custom Anthropic API URL override

headroom/proxy/server.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5385,13 +5385,20 @@ def run_server(
53855385
# Resolve upstream API targets for display in the banner (#583).
53865386
api_targets = resolve_api_targets(config.provider_api_overrides)
53875387

5388+
if config.uds:
5389+
listen_display = f"unix:{config.uds}"
5390+
usage_display = f"ANTHROPIC_UNIX_SOCKET={config.uds} claude"
5391+
else:
5392+
listen_display = f"http://{config.host}:{config.port}"
5393+
usage_display = f"ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude"
5394+
53885395
if print_banner:
53895396
print(f"""
53905397
╔══════════════════════════════════════════════════════════════════════╗
53915398
║ HEADROOM PROXY SERVER ║
53925399
╠══════════════════════════════════════════════════════════════════════╣
53935400
║ Version: 1.0.0 ║
5394-
║ Listening: http://{config.host}:{config.port:<5}
5401+
║ Listening: {listen_display:<57}
53955402
║ Workers: {workers:<3} Concurrency Limit: {limit_concurrency:<5}
53965403
║ Backend: {backend_status:<59}
53975404
╠══════════════════════════════════════════════════════════════════════╣
@@ -5413,7 +5420,7 @@ def run_server(
54135420
║ Conn Pool: {pool_info:<52}
54145421
╠══════════════════════════════════════════════════════════════════════╣
54155422
║ USAGE: ║
5416-
║ Claude Code: ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude
5423+
║ Claude Code: {usage_display:<51}
54175424
║ Cursor: Set base URL in settings ║
54185425
╠══════════════════════════════════════════════════════════════════════╣
54195426
║ ENDPOINTS: ║
@@ -5485,10 +5492,39 @@ def run_server(
54855492
else:
54865493
app_target = create_app(config)
54875494

5495+
# Bind target: a Unix socket when one is configured, otherwise host:port.
5496+
# uvicorn treats `uds` and `host`/`port` as alternatives, so they are built
5497+
# here rather than passed together.
5498+
bind_kwargs: dict[str, Any]
5499+
uds_path: Path | None = None
5500+
if config.uds:
5501+
from headroom.proxy.uds import prepare_uds_path
5502+
5503+
uds_path = prepare_uds_path(config.uds)
5504+
bind_kwargs = {"uds": str(uds_path)}
5505+
else:
5506+
bind_kwargs = {"host": config.host, "port": config.port}
5507+
5508+
try:
5509+
_run_uvicorn(app_target, bind_kwargs, workers, limit_concurrency, uvicorn_kwargs)
5510+
finally:
5511+
if uds_path is not None:
5512+
from headroom.proxy.uds import remove_uds_path
5513+
5514+
remove_uds_path(uds_path)
5515+
5516+
5517+
def _run_uvicorn(
5518+
app_target: Any,
5519+
bind_kwargs: dict[str, Any],
5520+
workers: int,
5521+
limit_concurrency: int,
5522+
uvicorn_kwargs: dict[str, Any],
5523+
) -> None:
5524+
"""Hand off to uvicorn. Split out so the bind target stays testable."""
54885525
uvicorn.run(
54895526
app_target,
5490-
host=config.host,
5491-
port=config.port,
5527+
**bind_kwargs,
54925528
log_level="warning",
54935529
workers=workers if workers > 1 else None, # None = single process (default)
54945530
limit_concurrency=limit_concurrency,

0 commit comments

Comments
 (0)