Skip to content

Commit 73f77da

Browse files
fix(proxy): stop the --uds banner advertising a recipe that cannot work
Both startup banners printed an ANTHROPIC_UNIX_SOCKET=... claude line on a socket bind. That configuration is the one this branch's own docs document as broken: it satisfies Claude Code's api.anthropic.com host check but reclassifies the session as API-key auth, so the session then fails to authenticate. Runtime output was contradicting the docs and walking users into the failure. Review feedback on #3151. The socket branch of both banners now comes from uds.socket_usage_lines(), a single agent-neutral block: the socket path, the transport requirement (the client must speak HTTP over a Unix socket natively), a curl example, and a link to the docs page that carries the per-client detail. The legacy banner's hardcoded "Claude Code:" label is now computed alongside it, so the socket bind no longer addresses one agent by name; both branches still render to the same 70-column inner width. Per-client wiring stays in the docs, where it can be qualified. Three tests pin the contract: the unsupported recipe is absent, the replacement states the requirement and links the docs, and no agent is named at all. They are pure string assertions, so they run on every platform rather than only where AF_UNIX exists.
1 parent a63919d commit 73f77da

4 files changed

Lines changed: 76 additions & 10 deletions

File tree

headroom/cli/proxy.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,16 +1636,13 @@ def proxy(
16361636
else:
16371637
tuning_section = ""
16381638

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.
1639+
# A socket has no URL, and no per-agent recipe belongs here — see
1640+
# uds.socket_usage_lines() for why the banner stays transport-neutral.
16411641
if config.uds:
1642+
from headroom.proxy.uds import socket_usage_lines
1643+
16421644
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-
)
1645+
usage_section = "\n".join(socket_usage_lines(config.uds))
16491646
else:
16501647
listen_display = f"http://{config.host}:{config.port}"
16511648
usage_section = "\n".join(

headroom/proxy/server.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5386,10 +5386,13 @@ def run_server(
53865386
api_targets = resolve_api_targets(config.provider_api_overrides)
53875387

53885388
if config.uds:
5389+
# No per-agent recipe on a socket bind; see uds.socket_usage_lines().
53895390
listen_display = f"unix:{config.uds}"
5390-
usage_display = f"ANTHROPIC_UNIX_SOCKET={config.uds} claude"
5391+
usage_label = "Client: "
5392+
usage_display = "must support HTTP over a Unix socket natively"
53915393
else:
53925394
listen_display = f"http://{config.host}:{config.port}"
5395+
usage_label = "Claude Code:"
53935396
usage_display = f"ANTHROPIC_BASE_URL=http://{config.host}:{config.port} claude"
53945397

53955398
if print_banner:
@@ -5420,7 +5423,7 @@ def run_server(
54205423
║ Conn Pool: {pool_info:<52}
54215424
╠══════════════════════════════════════════════════════════════════════╣
54225425
║ USAGE: ║
5423-
Claude Code: {usage_display:<51}
5426+
{usage_label} {usage_display:<51}
54245427
║ Cursor: Set base URL in settings ║
54255428
╠══════════════════════════════════════════════════════════════════════╣
54265429
║ ENDPOINTS: ║

headroom/proxy/uds.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
__all__ = [
3737
"UDS_SUPPORTED",
3838
"UdsError",
39+
"socket_usage_lines",
3940
"max_uds_path_length",
4041
"prepare_uds_path",
4142
"remove_uds_path",
@@ -105,6 +106,30 @@ def _is_live_socket(path: Path) -> bool:
105106
sock.close()
106107

107108

109+
# Docs page carrying the client-compatibility detail the banner has no room for.
110+
UDS_DOCS_URL = "https://headroom-docs.vercel.app/docs/proxy#serving-on-a-unix-socket"
111+
112+
113+
def socket_usage_lines(path: str | os.PathLike[str]) -> tuple[str, ...]:
114+
"""How the startup banner describes a socket bind, for every banner.
115+
116+
Deliberately names no agent and hands out no environment variables. An
117+
earlier revision printed an ``ANTHROPIC_UNIX_SOCKET=... claude`` recipe here,
118+
which is a configuration that does not work: it satisfies Claude Code's
119+
``api.anthropic.com`` host check but reclassifies the session as API-key
120+
auth, and the session then fails to authenticate. Printing it at startup
121+
turned a known-negative result into first-party guidance. The rule this
122+
encodes is that the banner states a transport requirement and points at the
123+
docs; per-client wiring belongs in the docs, where it can be qualified.
124+
"""
125+
return (
126+
f" Socket: {path}",
127+
" Client: must support HTTP over a Unix socket natively",
128+
f" Example: curl --unix-socket {path} http://localhost/health",
129+
f" Details: {UDS_DOCS_URL}",
130+
)
131+
132+
108133
def _missing_ancestors(target: Path) -> list[Path]:
109134
"""Directories along *target* that do not exist yet, shallowest first."""
110135
missing: list[Path] = []

tests/test_proxy_uds.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
prepare_uds_path,
2626
remove_uds_path,
2727
require_uds_support,
28+
socket_usage_lines,
2829
)
2930

3031
requires_uds = pytest.mark.skipif(
@@ -130,6 +131,46 @@ def test_unreadable_existing_parent_defers_to_bind(tmp_path: Path) -> None:
130131
_require_safe_existing_parent(tmp_path)
131132

132133

134+
# --------------------------------------------------------------------------
135+
# Startup banner — a socket bind must not advertise a broken recipe.
136+
# --------------------------------------------------------------------------
137+
138+
139+
def test_socket_usage_lines_omit_the_unsupported_claude_code_recipe() -> None:
140+
"""Regression: the banner once printed a configuration that cannot work.
141+
142+
`ANTHROPIC_UNIX_SOCKET=... claude` passes Claude Code's api.anthropic.com
143+
host check but reclassifies the session as API-key auth, and the session
144+
then fails to authenticate. Printing it at startup turned a known-negative
145+
field result into first-party runtime guidance.
146+
"""
147+
rendered = "\n".join(socket_usage_lines("/run/headroom/proxy.sock"))
148+
149+
assert "ANTHROPIC_UNIX_SOCKET" not in rendered
150+
assert "ANTHROPIC_BASE_URL" not in rendered
151+
assert "claude" not in rendered.lower()
152+
153+
154+
def test_socket_usage_lines_state_the_transport_requirement() -> None:
155+
"""What replaces the recipe has to be useful, not merely absent."""
156+
path = "/run/headroom/proxy.sock"
157+
158+
rendered = "\n".join(socket_usage_lines(path))
159+
160+
assert path in rendered
161+
assert "HTTP over a Unix socket" in rendered
162+
assert "curl --unix-socket" in rendered
163+
assert "serving-on-a-unix-socket" in rendered
164+
165+
166+
def test_socket_usage_lines_name_no_agent() -> None:
167+
"""Transport-neutral: the banner singles out no client."""
168+
rendered = "\n".join(socket_usage_lines("/run/headroom/proxy.sock")).lower()
169+
170+
for agent in ("claude", "codex", "opencode", "cursor", "aider", "copilot"):
171+
assert agent not in rendered, f"banner should not name {agent}"
172+
173+
133174
# --------------------------------------------------------------------------
134175
# Path preparation — needs a real AF_UNIX platform.
135176
# --------------------------------------------------------------------------

0 commit comments

Comments
 (0)