Skip to content

feat(proxy): serve on a Unix domain socket (--uds) - #3151

Open
Ayushraj06-bit wants to merge 3 commits into
headroomlabs-ai:mainfrom
Ayushraj06-bit:feat/proxy-uds-1779
Open

feat(proxy): serve on a Unix domain socket (--uds)#3151
Ayushraj06-bit wants to merge 3 commits into
headroomlabs-ai:mainfrom
Ayushraj06-bit:feat/proxy-uds-1779

Conversation

@Ayushraj06-bit

@Ayushraj06-bit Ayushraj06-bit commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Update — the Remote Control premise was field-tested on Linux and is wrong.\n> ANTHROPIC_UNIX_SOCKET passes Remote Control's host check but makes Claude Code\n> classify the session as API-key auth, which fails that feature's separate\n> subscription-auth requirement. Details under Field result.\n> The socket transport itself is unaffected and still stands on its own merits;\n> this PR no longer claims to help #1779 beyond recording the negative result.\n\n## Description\n\nheadroom proxy --uds PATH binds an AF_UNIX socket instead of a TCP port. Request handling is untouched — this is the transport shell only.\n\nReasons to prefer it over a loopback port: no port to collide with, nothing listening on the network, and access governed by filesystem permissions. Containers and systemd units can share a socket through a bind mount or a runtime directory without publishing a port.\n\nPath handling refuses more than it deletes. It clears a stale socket from 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 pointing at real data than a leftover), or when the path exceeds sun_path — 108 bytes on Linux, 104 on macOS/BSD — which otherwise fails inside �ind() with an error that names nothing. The parent directory is created

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR governance

This PR does not yet satisfy the required template fields:

  • Missing required section Description.
  • Missing required section Type of Change.
  • Missing required section Changes Made.
  • Missing required section Testing.
  • Missing required section Real Behavior Proof.
  • Missing required section Runtime Rollout Safety.
  • Missing required section Review Readiness.
  • Check I have performed a self-review before requesting human review.
  • Check This PR is ready for human review or convert the PR back to draft.

Please update the PR body, or move the PR back to draft while it is still in progress.

@github-actions github-actions Bot added the status: ready for review Pull request body is complete and the author marked it ready for human review label Aug 20, 2026
@codecov-commenter

codecov-commenter commented Aug 20, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 88.70968% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
headroom/proxy/uds.py 89.01% 7 Missing and 3 partials ⚠️
headroom/cli/proxy.py 69.23% 3 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot added status: ci failing Required or reported CI checks are failing and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Aug 20, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for keeping the transport change focused. Two concrete blockers remain on the latest commit:\n\n1. prepare_uds_path() unconditionally calls parent.chmod(0o700) even when the parent already existed. That can silently change permissions on a caller-owned shared directory and disrupt unrelated users/files. Please only apply 0700 to a directory this function actually creates (or validate/refuse an unsafe existing parent without mutating it), and add a regression test proving an existing parent's mode is preserved.\n\n2. CI's est (3) shard fails because the new HEADROOM_UDS Click env var is absent from settings_store.SETTINGS. Please either register it with the appropriate setting metadata or explicitly add it to the intentional-exclusion mechanism with a documented rationale, as the registry contract requires.\n\nThe branch is also currently conflicted with main; resolving that should rerun the suite. I did not treat the coverage comment alone as a blocker.

@foxy82

foxy82 commented Aug 20, 2026

Copy link
Copy Markdown

Running this on Linux and it still doesn't seem to allow remote control - in fact it seems to push you to the API subscription...

I ran with ANTHROPIC_UNIX_SOCKET=/tmp/hr-uds-fresh/run/proxy.sock claude --remote-control socket-test --debug-file /tmp/headroom-uds-test-logs/socket-test-manual3.log

In the terminal this message comes up on start up:

       ⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and takes precedence over your claude.ai login · Unset it to load your organization's connectors

I try to send a message:

▎ Debug mode enabled · logging to /tmp/headroom-uds-test-logs/socket-test-manual4.log

❯ Are you working?
⎿  Not logged in · Please run /login

✻ Cooked for 0s

I log in using my subscription but it stil doesn't work and in the logs I see:

2026-08-20T20:11:36.857Z [ERROR] API invalid_api_key: [REDACTED] not resolve authentication method. Expected one of apiKey, authToken, credentials, config, or profile to be set. Or for one of the "X-Api-Key" or "Authorization" headers to be explicitly omitted

@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review status: has conflicts Pull request has merge conflicts with the base branch and removed status: ci failing Required or reported CI checks are failing status: ready for review Pull request body is complete and the author marked it ready for human review labels Aug 20, 2026
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Aug 20, 2026
`headroom proxy --uds PATH` binds an AF_UNIX socket instead of a TCP port.
Request handling is untouched; this is the transport shell only.

No port to collide with, nothing listening on the network, and access
governed by filesystem permissions. Containers and systemd units can share a
socket through a bind mount or runtime directory without publishing a port.

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.

HEADROOM_UDS is registered in settings_store.SETTINGS alongside HEADROOM_HOST
and HEADROOM_PORT, as the registry drift guard in test_settings_store.py
requires of every HEADROOM_* Click envvar. It is manifest_managed for the same
reason the other two are: on docker/service installs the bind target belongs
to the install manifest.

This branch originally proposed the socket as a way to restore Claude Code's
Remote Control behind the proxy (headroomlabs-ai#1779). A Linux field test disproved that,
and the docs now record the negative result instead. ANTHROPIC_UNIX_SOCKET
does satisfy Remote Control's api.anthropic.com host check, but it is reserved
for `claude ssh`, where the process on the far end of the socket supplies
credentials; Claude Code 2.1.198 carries the diagnostic "ANTHROPIC_UNIX_SOCKET
is set (claude ssh remote), and the local proxy is API-key-authed." Setting it
classifies the session as API-key auth, while Remote Control separately
requires claude.ai subscription auth -- one variable opens one gate and closes
the other. Sessions start "Not logged in", and a subscription login still ends
in "Could not resolve authentication method" from the SDK. No proxy-side
change can repair that, because the decision is made in the client before any
request leaves it.

The socket stands on its own merits as a transport. It does not close headroomlabs-ai#1779.

Refs headroomlabs-ai#1779
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: has conflicts Pull request has merge conflicts with the base branch status: ready for review Pull request body is complete and the author marked it ready for human review labels Aug 20, 2026
prepare_uds_path() chmod'd the socket's parent to 0700 unconditionally, so
pointing --uds at an existing shared directory silently tightened it and
locked out whatever else was using it. Review feedback on headroomlabs-ai#3151.

The 0700 now applies only to directories this call brings into existence,
tracked level by level via _missing_ancestors() so an existing root above a
newly created subtree keeps its mode.

An existing parent is validated instead of modified: startup refuses when it
is group- or world-writable without the sticky bit, since any other local user
could then replace the socket. Sticky directories such as /tmp are accepted --
others may create their own entries but cannot unlink or rename ours. An
unreadable parent is left to bind() to report authoritatively.

Tests cover the mode policy as pure logic (injected st_mode, so it runs on
Windows too, where chmod is a no-op) plus AF_UNIX-backed regressions proving
an existing 0755 parent and a bystander file inside it survive untouched.
@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Aug 20, 2026
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

@JerrettDavis Thanks — the chmod one was a real defect and a good catch. All three points are addressed. Two of them had already landed on commits newer than the one you reviewed, so here is where each stands.

1. prepare_uds_path() tightening a caller-owned parent — fixed in a63919d.

You were right about the failure mode: --uds /run/shared/hr.sock where /run/shared is someone else's 0755 directory would have silently locked out every other user of it.

  • 0700 now applies only to directories the call actually creates. _missing_ancestors() walks up to the first existing level and returns just the absent ones, so an existing root above a newly created subtree keeps its mode.
  • An existing parent is validated rather than modified. Startup refuses when it is group- or world-writable without the sticky bit, since any other local user could replace the socket; the error names the mode and says Headroom will not change permissions on a directory it did not create. Sticky directories such as /tmp are accepted, because others can create their own entries there but cannot unlink or rename ours. An unreadable parent is left to bind() to report authoritatively.

On the regression test you asked for — the direct one needs AF_UNIX, which my dev box (Windows) does not have, so I split the coverage rather than leaving it all to CI:

  • test_prepare_preserves_an_existing_parents_mode and test_prepare_only_chmods_directories_it_creates assert the real thing on a real filesystem — an existing 0755 parent, and a bystander file inside it, both survive untouched. These run on Linux CI.
  • The mode policy is also tested as pure logic with an injected st_mode, parametrized over 0700/0750/0755/0770/0777/1777/1770, so the accept/refuse decision is verified everywhere including Windows.

2. HEADROOM_UDS absent from settings_store.SETTINGS — fixed in 656dd28, which predates your review; the shard-3 failure you saw was from the commit before it. It is registered next to HEADROOM_HOST/HEADROOM_PORT and marked manifest_managed for the same reason those two are: on docker/service installs the bind target belongs to the install manifest. I registered it rather than using the exclusion list, since it is a plain bind-level knob and not a secret.

3. Conflict with main — resolved in 1da7ffd. Rebased onto 81fe9d53. The collision was with the configurable uvicorn log level that landed in the same uvicorn.run call I had split into _run_uvicorn; I kept both, so _run_uvicorn takes the resolved log_level as a parameter and HEADROOM_UVICORN_LOG_LEVEL still works on either transport.

Local state: mypy headroom clean at 527 files, ruff check/format clean, 47 passed on the socket and settings suites. CI on a63919d8 is still finishing.


One thing worth flagging separately, in case you reviewed before I updated the description: the Remote Control premise this branch opened with turned out to be wrong, and a field test on Linux is what killed it. ANTHROPIC_UNIX_SOCKET does pass Remote Control's api.anthropic.com host check, but it is reserved for claude ssh, where the far end of the socket supplies credentials — Claude Code 2.1.198 carries the diagnostic ANTHROPIC_UNIX_SOCKET is set (claude ssh remote), and the local proxy is API-key-authed. Setting it classifies the session as API-key auth, and Remote Control separately requires claude.ai subscription auth, so the one variable opens one gate and closes the other. Sessions start Not logged in, and a subscription login still ends in Could not resolve authentication method from the SDK. No proxy-side change can repair that.

So this PR no longer claims anything about #1779 beyond recording the negative result in troubleshooting.mdx. What is left is a self-contained transport flag. If you would rather not carry it for its own sake, closing this is a reasonable call and I will resubmit the troubleshooting section as a docs-only PR — the finding is worth keeping either way, so nobody repeats the experiment.

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing both earlier blockers: the updated parent-directory policy preserves caller-owned modes, rejects unsafe non-sticky writable parents, and the regression coverage is strong; HEADROOM_UDS is also now intentionally accounted for. One actionable correctness issue remains in the latest head: the PR now correctly documents that ANTHROPIC_UNIX_SOCKET cannot make Claude Code work through this transport, but headroom proxy --uds still prints that exact non-working Claude Code command under Usage (ANTHROPIC_UNIX_SOCKET=... ANTHROPIC_BASE_URL=http://api.anthropic.com claude). That turns a known-negative field result into first-party runtime guidance and will send users into the authentication failure described in this PR. Please remove that Claude Code recipe from the UDS banner and replace it with transport-neutral guidance (for example, state that the client must natively support HTTP over UDS, optionally pointing to the docs), with a banner regression asserting the unsupported recipe is absent. After that, the transport implementation itself looks ready.

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 headroomlabs-ai#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.
@Ayushraj06-bit

Copy link
Copy Markdown
Contributor Author

@JerrettDavis Good catch, and the framing is right — I corrected the docs to record the negative result and left the runtime output still handing users the exact configuration those docs call broken. Fixed in 73f77da.

Both banners, not one. The recipe was in the click CLI banner and in run_server's legacy banner (python -m headroom.proxy.server), so the socket branch of both now comes from a single uds.socket_usage_lines():

Usage:
  Socket:        /run/headroom/proxy.sock
  Client:        must support HTTP over a Unix socket natively
  Example:       curl --unix-socket /run/headroom/proxy.sock http://localhost/health
  Details:       https://headroom-docs.vercel.app/docs/proxy#serving-on-a-unix-socket

The legacy banner's label was hardcoded Claude Code:, which on a socket bind addressed one agent by name even after the value was replaced, so that is computed alongside the value now — Client: on a socket, Claude Code: on host/port. Both branches still render to the same 70-column inner width.

The function carries a comment explaining why it must stay agent-neutral, so the next person to add a convenience line here sees the field result rather than rediscovering it.

Regression coverage, as requested — three tests, all pure string assertions so they run everywhere rather than only where AF_UNIX exists:

  • test_socket_usage_lines_omit_the_unsupported_claude_code_recipe — asserts ANTHROPIC_UNIX_SOCKET, ANTHROPIC_BASE_URL, and claude are all absent.
  • test_socket_usage_lines_state_the_transport_requirement — asserts the replacement is actually useful: the path, the requirement, the curl example, and the docs anchor.
  • test_socket_usage_lines_name_no_agent — asserts no client is singled out, checked against claude/codex/opencode/cursor/aider/copilot.

One thing I caught while writing this: my first draft of the banner linked docs.headroom.dev, which does not exist. It now points at headroom-docs.vercel.app, matching the other doc links in the tree, and the test pins the anchor.

Local state: mypy headroom clean at 527 files, ruff check/format clean, 727 passed across the socket, settings, and CLI suites — the three failures there are the pre-existing Windows-only ones in test_recover_codex.py / test_unwrap_claude.py that also fail on a clean main.

I cannot re-request review from this account (no write access on the base repo — the API 404s), so tagging you here instead.

@github-actions github-actions Bot added status: ready for review Pull request body is complete and the author marked it ready for human review and removed status: ready for review Pull request body is complete and the author marked it ready for human review labels Aug 21, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 73f77da after the requested correction. The startup surfaces no longer advertise the nonworking Claude Code configuration: both banners now stay transport-neutral, the detailed CLI banner points to qualified documentation, and the regression tests explicitly prevent reintroducing agent/env guidance. I also rechecked the UDS bind/path lifecycle and default TCP path against current main. Local validation: 50 passed, 14 platform skips; Ruff passed. Exact-head CI is fully green. The earlier blocker is resolved; ready for final human merge review.

@github-actions github-actions Bot added the status: needs author action Pull request body or readiness checklist still needs author updates label Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: needs author action Pull request body or readiness checklist still needs author updates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants