Skip to content

feat(proxy): support claude.base_url for host-side LLM proxies#191

Merged
dpup merged 5 commits into
mainfrom
feat/claude-base-url
Feb 24, 2026
Merged

feat(proxy): support claude.base_url for host-side LLM proxies#191
dpup merged 5 commits into
mainfrom
feat/claude-base-url

Conversation

@dpup
Copy link
Copy Markdown
Collaborator

@dpup dpup commented Feb 24, 2026

Partial solution for #26

Summary

Add claude.base_url field to agent.yaml for routing Claude Code API traffic through host-side LLM proxies like Headroom.

How it works: Moat sets ANTHROPIC_BASE_URL to a relay endpoint on the Moat proxy (/relay/anthropic/). The relay forwards requests to the configured URL with credentials injected. This works transparently with localhost URLs because the relay runs on the host.

grants:
  - claude

claude:
  base_url: http://localhost:8787

Traffic flow: Claude Code → Moat proxy relay → Headroom (localhost:8787) → api.anthropic.com

Changes

  • Config: claude.base_url field with URL validation (http/https only, must have host)
  • Relay handler (internal/proxy/relay.go): Generic AddRelay(name, url) mechanism with name/URL validation, credential injection via existing getCredential/getExtraHeaders/getRemoveHeaders helpers, proxy header filtering, and response header timeout
  • Manager wiring: Captures anthropic credential, registers relay + credential injection, sets ANTHROPIC_BASE_URL env var
  • Security: Relay check restricted to direct requests (r.URL.Host == "") to prevent proxied requests from bypassing auth; error responses don't leak internal host info
  • Docs: Reference docs for claude.base_url, guide section on LLM proxies, working examples/llm-proxy/ example with Headroom
  • Tests: 12 relay handler tests, 6 config validation tests, 6 ConfigureBaseURLProxy tests

Test plan

  • go build ./...
  • go test ./internal/proxy/ ./internal/config/ ./internal/providers/claude/ ./internal/run/
  • make lint — 0 issues
  • Manual: moat claude examples/llm-proxy with Headroom on port 8787 — Claude Code API traffic routed through Headroom with credentials injected

@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 21s —— View job


Code Review

  • Gather context
  • Read changed files
  • Post inline comments
  • Post summary

Summary

The feature design is solid — localhost rewriting, credential mirroring, and the strict-policy allowlist integration are all sensible. There's one bug that would cause the primary use case to silently fail, and a couple of defensive-programming gaps.

One bug (critical):

ConfigureBaseURLProxy is called with rewrittenURL.Host (e.g., host.docker.internal:8787), but isValidHost rejects any string containing :, so credentials are never registered for the proxy host. The feature appears to work (no error) but the proxy injects nothing. AddAllowedHost on line 704 already correctly uses rewrittenURL.Hostname() — the same fix is needed on line 697. The unit tests don't catch this because the mock bypasses isValidHost. Fix this →

Two defensive gaps:

  • If claude.base_url is set but no anthropic/claude grant is configured, anthropicCred is nil and the block is skipped silently — ANTHROPIC_BASE_URL is never set and nothing warns the user.
  • The parseErr from the second url.Parse call is discarded. Since config validation already parsed the URL this won't fail in practice, but it should at least log.Warn rather than silently do nothing.

One minor omission: IPv6 loopback ::1 is not rewritten alongside localhost and 127.0.0.1.

Everything else looks good: the config validation, the AddAllowedHost API, the test coverage for nil/empty-host edge cases, and the docs are accurate and match the code.

@dpup dpup force-pushed the feat/claude-base-url branch from 7736002 to f5d4d3a Compare February 24, 2026 18:25
@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 41s —— View job


Code Review

  • Gather context
  • Read changed files
  • Post inline comments
  • Final summary

Summary

The feature is well-structured: config validation, proxy credential mirroring, and network policy wiring all follow existing patterns cleanly. One real bug and a few smaller issues.

Bug — silent credential injection failure for base URLs with a port (internal/run/manager.go:697)

rewrittenURL.Host includes the port (host.docker.internal:8787). The real Proxy.SetCredentialWithGrant calls isValidHost, which rejects any string containing :, so the credential is never registered and injection silently fails. The three lines below already use rewrittenURL.Hostname() for AddAllowedHost; the fix is a one-word change to match:

// line 697 — change .Host to .Hostname()
claude.ConfigureBaseURLProxy(p, anthropicCred, rewrittenURL.Hostname())

The existing tests don't catch this because mockProxyConfigurer bypasses isValidHost. This affects the common case (http://localhost:8787), which is the example port shown in the docs and example yaml.

Smaller issues:

  • manager.go:681 — If claude.base_url is set but no anthropic/claude grant is active, the setting is silently ignored. A ui.Warn here would save debugging time.
  • manager.go:683 — The parseErr branch is silently swallowed. Unreachable in practice (URL was already validated), but a log.Warn keeps surprising failures debuggable.
  • examples/llm-proxy/agent.yaml:34 — The prerequisite comment says moat grant anthropic but the grants field uses claude; the guide uses grants: [anthropic]. One of these should be corrected for consistency.

Comment thread internal/run/manager.go Outdated
Comment thread internal/run/manager.go
Comment thread internal/run/manager.go Outdated
Comment thread internal/run/manager.go Outdated
Comment thread internal/providers/claude/provider_test.go
Comment thread internal/run/manager.go Outdated
Comment thread internal/run/manager.go Outdated
Comment thread examples/llm-proxy/agent.yaml
Comment thread internal/run/manager.go
Comment thread internal/providers/claude/provider_test.go
@dpup dpup force-pushed the feat/claude-base-url branch from f5d4d3a to 9e2a0fa Compare February 24, 2026 18:39
@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 5m 11s —— View job


Code Review

  • Gather context
  • Read changed files
  • Check previous review issues are resolved
  • Post inline comments
  • Post summary

Summary

The relay architecture (new in this revision) is a good design — routing ANTHROPIC_BASE_URL through a /relay/anthropic endpoint on the Moat proxy elegantly avoids the NO_PROXY issue. However, the previous review's critical bug wasn't fixed and a second bug was introduced by the relay implementation.

Two bugs, same root cause (isValidHost rejects host:port strings):

  1. internal/run/manager.go:688ConfigureBaseURLProxy is called with baseURL.Host (localhost:8787). isValidHost rejects strings containing :, so SetCredentialWithGrant silently drops the credential. Nothing is stored in p.credentials. Fix →

  2. internal/proxy/relay.go:87,98 — The relay handler looks up credentials and extra headers with p.credentials[targetURL.Host] and p.extraHeaders[targetURL.Host] (direct map access using host:port as key). Even after fixing feat(routing): add hostname-based service routing for agents #1, credentials stored by bare hostname won't be found. The regular proxy path uses getCredential(), which has a net.SplitHostPort fallback — the relay should do the same. Fix →

Other issues:

  • internal/proxy/relay.go:136io.Copy doesn't flush between chunks. For SSE/streaming LLM responses, each token will buffer until the connection closes. A flushing copy loop is needed for real-time streaming.

  • internal/run/manager.go:683 — If claude.base_url is set but no Anthropic/Claude grant is active, the setting is silently ignored. A ui.Warn would save debugging time.

  • docs/content/guides/01-claude-code.md:246 — Says "Rewrites localhost to the container-reachable host address" but the relay approach doesn't rewrite anything. The docs describe the old design.

  • examples/llm-proxy/agent.yaml:35grants: [claude] (OAuth) contradicts the prerequisite comment that says moat grant anthropic (API key), and disagrees with the guide which shows grants: [anthropic].

  • internal/providers/claude/provider_test.go:106,149 — Tests pass "localhost:8080" / "proxy.internal:3000" to ConfigureBaseURLProxy, but the real proxy rejects these (: in host). The mock bypasses isValidHost so the tests pass while the real integration is broken. Update to use bare hostnames after fixing the call site.

Add claude.base_url field to agent.yaml that redirects Claude Code API
traffic through a host-side LLM proxy (e.g., Headroom). This enables
caching, logging, and telemetry for Anthropic API calls.

The implementation handles four problems that prevented using LLM proxies:
- Sets ANTHROPIC_BASE_URL inside the container
- Rewrites localhost to container-reachable host address
- Injects credentials for both api.anthropic.com and the proxy host
- Allows the proxy host under strict network policy
@dpup dpup force-pushed the feat/claude-base-url branch from 9e2a0fa to 60f29ba Compare February 24, 2026 18:43
@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 7m 27s —— View job


Code Review

  • Gather context
  • Read changed files
  • Check previous review issues are resolved
  • Post inline comments
  • Post summary

Summary

The PR has been significantly redesigned since the last review — the relay pattern (/relay/anthropic) is cleaner than the old localhost-rewrite approach and avoids the NO_PROXY problem correctly. However, two of the previously-flagged bugs survived the redesign (in different form), and the new relay introduces a new security gap and an OAuth correctness issue.

Two bugs that will cause silent failure for the primary use case (http://localhost:8787):

  1. relay.go:87,98 — The relay handler looks up p.credentials[targetURL.Host] where targetURL.Host is "localhost:8787". But p.credentials is keyed by bare hostname — isValidHost rejects : so "localhost:8787" is never stored. There's no SplitHostPort fallback here (unlike getCredential). Fix: use targetURL.Hostname() for both lookups.

  2. manager.go:688baseURL.Host (e.g., "localhost:8787") is passed to ConfigureBaseURLProxy, which calls isValidHost. It's rejected and the credential is silently dropped. Fix: baseURL.Hostname(). Both bugs must be fixed together; each is necessary but not sufficient.

Security concern:

  1. proxy.go:527-534 — The relay handler is checked before authToken validation. This is intentional (direct HTTP requests don't carry Proxy-Authorization) but creates a hole in the Apple container security model: when the proxy binds to 0.0.0.0, any process on the LAN can reach the relay and have user credentials injected into forwarded requests. The auth token was designed to close exactly this exposure.

OAuth correctness:

  1. relay.go:75-107getRemoveHeaders is never called. For OAuth credentials, ConfigureBaseURLProxy registers x-api-key for removal. Claude Code sends this header (with a placeholder) and the relay forwards it unstripped to the LLM proxy, where it can conflict with the injected Authorization: Bearer header.

Smaller issues (some carried over from previous review):

  • manager.go:683 — No ui.Warn when claude.base_url is set but no credential grant is active. The setting silently does nothing.
  • manager.go:684-685parseErr still discarded silently.
  • provider_test.go:106 — Tests still use host:port strings that bypass isValidHost in the mock, masking the bug. No tests exist for relay.go at all; an integration test against a real *Proxy would catch all the above.
  • 01-claude-code.md:246,249 — Two bullet points describe the old localhost-rewrite behavior, not the new relay behavior. Docs are inaccurate.
  • examples/llm-proxy/agent.yaml:17,35 — Prerequisite comment says moat grant anthropic but grants: uses claude. The guide uses anthropic. Pick one.

Comment thread internal/run/manager.go
Comment thread internal/proxy/relay.go Outdated
Comment thread internal/proxy/relay.go Outdated
Comment thread internal/run/manager.go
Comment thread internal/proxy/relay.go Outdated
Comment thread docs/content/guides/01-claude-code.md Outdated
Comment thread examples/llm-proxy/agent.yaml
Comment thread internal/providers/claude/provider_test.go
Without checking r.URL.Host == "", a proxied request like
GET http://anything.com/relay/foo would match the relay path prefix
and bypass proxy authentication. Now only direct HTTP requests
(r.URL.Host empty) can reach relay endpoints.
@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 5m 30s —— View job


Code Review

  • Gather context
  • Read changed files
  • Check previous review issues are resolved
  • Post inline comments
  • Post summary

Summary

The relay architecture is the right approach — routing ANTHROPIC_BASE_URL through a /relay/anthropic endpoint sidesteps the NO_PROXY problem cleanly. The overall design is sound.

However, the three bugs flagged in every previous review iteration are still present in this push.

Two bugs that cause the feature to silently fail (critical, unfixed):

  1. internal/run/manager.go:688baseURL.Host is "localhost:8787". isValidHost rejects strings containing :, so ConfigureBaseURLProxy stores no credential. Nothing will be injected by the relay. One-word fix: .Host.Hostname().

  2. internal/proxy/relay.go:87,98 — The relay handler looks up credentials and extra headers with p.credentials[targetURL.Host] / p.extraHeaders[targetURL.Host], using "localhost:8787" as the key. Even after fixing Bug 1 (credentials stored under "localhost"), the lookup still misses. getCredential() and getExtraHeaders() both have net.SplitHostPort fallbacks for this reason, but the relay bypasses them via direct map access. Fix: use targetURL.Hostname() for both map key lookups.

The tests don't catch either bug because mockProxyConfigurer bypasses isValidHost and stores whatever key is given.

One streaming bug (unfixed):

  1. internal/proxy/relay.go:136io.Copy buffers between chunks. For SSE or streaming LLM responses, each token needs to be flushed immediately. A flushing copy loop is needed (see inline comment).

Smaller issues:

  • internal/run/manager.go:683 — Silent failure when claude.base_url is set but no grant is active. A ui.Warn prevents a confusing debugging session.
  • docs/content/guides/01-claude-code.md:246 — "Rewrites localhost" is inaccurate; the relay approach doesn't rewrite anything.
  • examples/llm-proxy/agent.yaml:35grants: [claude] (OAuth) contradicts the prerequisite comment (moat grant anthropic, API key) and the guide (grants: [anthropic]). These need to agree.
  • internal/providers/claude/provider_test.go:106,149 — Test hostnames use host:port format; after fixing the call site they should use bare hostnames to match what manager.go will actually pass.

Comment thread internal/proxy/relay.go Outdated
Comment thread internal/proxy/relay.go Outdated
Comment thread internal/run/manager.go
Comment thread internal/proxy/proxy.go
Comment thread internal/proxy/relay.go Outdated
Comment thread internal/run/manager.go
Comment thread internal/providers/claude/provider_test.go
Comment thread docs/content/guides/01-claude-code.md Outdated
Comment thread examples/llm-proxy/agent.yaml Outdated
Comment thread internal/run/manager.go
Comment thread internal/proxy/relay.go Outdated
Comment thread internal/proxy/relay.go Outdated
Comment thread internal/proxy/relay.go Outdated
Comment thread internal/run/manager.go
Comment thread docs/content/guides/01-claude-code.md Outdated
Comment thread examples/llm-proxy/agent.yaml
Comment thread internal/providers/claude/provider_test.go
ConfigureBaseURLProxy passed host:port (e.g., localhost:8787) to
SetCredentialWithGrant, but isValidHost rejects colons, silently
dropping the credential. The relay then forwarded Claude Code's
placeholder token instead of the real one, causing 401 errors.

Fix: strip port in ConfigureBaseURLProxy before registering, and
use getCredential/getExtraHeaders/getRemoveHeaders helpers in the
relay handler (which handle host:port fallback) instead of raw map
lookups.
@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 46s —— View job


Code Review

  • Gather context
  • Read changed files
  • Check which previous issues are resolved
  • Post inline comments
  • Post summary

Summary

The two most recent commits (fix(proxy): restrict relay matching to direct requests, fix(proxy): fix credential injection for relay hosts with ports) fixed the critical bugs flagged in all previous review iterations. The feature should now work correctly for the primary use case.

What was fixed:

  • ConfigureBaseURLProxy (provider.go:132-135) now strips the port with net.SplitHostPort before calling SetCredentialWithGrant — credentials are registered correctly even when the base URL includes a port.
  • The relay handler (relay.go:90-104) now uses p.getCredential(), p.getExtraHeaders(), p.getRemoveHeaders() instead of direct map access — all three have SplitHostPort fallbacks so host:port lookups work correctly.
  • RemoveRequestHeader is now applied in the relay — x-api-key stripping for OAuth credentials works correctly.
  • The r.URL.Host == "" guard prevents proxied requests with /relay/ in the path from matching the relay handler.

Still open:

  1. Streaming not flushed per-chunk (relay.go:133) — io.Copy doesn't flush between chunks. For SSE or streaming LLM responses, tokens will be buffered until io.Copy's internal buffer fills or the body closes, causing visible latency. A flushing copy loop is needed. This was flagged in every previous review.

  2. Silent failure when no grant active (manager.go:683) — claude.base_url set without an anthropic or claude grant silently does nothing. ANTHROPIC_BASE_URL is never set, no message is shown. A ui.Warn would save a debugging session. This was flagged in every previous review.

  3. Doc inaccuracy (docs/content/guides/01-claude-code.md:246) — Line 246 still says "Rewrites localhost to the container-reachable host address" which was true of the old approach but not the relay approach. Line 249 ("Allows the proxy host under network.policy: strict") is also inaccurate — AddAllowedHost is not called and the container never connects to localhost:8787 directly. Same stale description in examples/llm-proxy/agent.yaml lines 11-12. This was flagged in previous reviews.

  4. Grant inconsistency (examples/llm-proxy/agent.yaml:35) — grants: [claude] (OAuth) contradicts the prerequisite comment (moat grant anthropic, API key) and the guide which shows grants: [anthropic]. Still not resolved.

  5. Security note worth adding (proxy.go:534) — The existing comment explains auth is skipped for the relay but doesn't mention that on Apple containers (where 0.0.0.0 binding is used) this means the relay endpoint is accessible from any LAN host without the auth token. The credential injection is still scoped to pre-configured URLs, so this is low-severity, but worth documenting.

Comment thread internal/proxy/relay.go Outdated
Comment thread internal/run/manager.go
Comment thread docs/content/guides/01-claude-code.md Outdated
Comment thread examples/llm-proxy/agent.yaml
Comment thread internal/proxy/proxy.go
Documentation:
- Fix inaccurate "localhost rewriting" claims in docs, example, and
  code comments — the implementation uses a relay pattern, not host
  rewriting
- Remove incorrect claim about strict network policy auto-allowing
  the base URL host (not needed with relay pattern)

Security & robustness:
- Add input validation to AddRelay (reject empty names, slashes,
  invalid URLs)
- Add ResponseHeaderTimeout to relayClient to prevent goroutine leaks
- Remove internal host/error details from relay error responses
- Log warning on url.Parse failure in manager instead of silent skip
- Handle AddRelay error return in manager

Testing:
- Add 12 relay handler tests: forwarding, credential injection,
  header removal, unknown relay 404, proxy header filtering, response
  header copying, unreachable target 502, empty path, body streaming,
  proxied-request auth bypass guard, and AddRelay validation
- Add ConfigureBaseURLProxy test for host without port
@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

📦 Previous Review (superseded by newer review)

Claude finished @dpup's task in 3m 22s —— View job


Code Review

  • Gather context
  • Read changed files
  • Check which previous issues are resolved
  • Post inline comments
  • Post summary

Summary

Good progress since the last iteration — the two critical bugs are now fixed and the test coverage is solid. Three issues remain open from previous reviews.

What was fixed:

  • ConfigureBaseURLProxy now internally strips the port via net.SplitHostPort before registering credentials — the isValidHost rejection for host:port strings is handled correctly even though manager.go:692 still passes baseURL.Host
  • The relay handler now uses getCredential(), getExtraHeaders(), getRemoveHeaders() with their SplitHostPort fallbacks instead of direct map access — host:port key lookups work correctly
  • getRemoveHeaders is called in the relay — x-api-key is stripped for OAuth credentials
  • relay_test.go has comprehensive coverage (forwarding, credential injection, header removal, unknown name, proxied-request bypass guard, streaming, validation)
  • Docs updated to describe the relay approach accurately

Three remaining open issues:

1. Streaming not flushed per-chunk (relay.go:146)

io.Copy writes to w in its own buffer cadence and never calls Flush. The single flusher.Flush() on lines 143–145 flushes the status line only. For SSE or chunked LLM responses, each token will be held in the HTTP response buffer until io.Copy's internal 32KB buffer fills or the body closes. This was flagged in every previous review and is still unresolved.

TestRelay_StreamsBody doesn't catch this: the backend echoes the full request body at once (not a streaming backend), and httptest.ResponseRecorder doesn't implement http.Flusher so the type assertion always yields canFlush = false.

Fix — replace the single copy with a flushing loop:

buf := make([]byte, 4096)
flusher, canFlush := w.(http.Flusher)
for {
    n, err := resp.Body.Read(buf)
    if n > 0 {
        _, _ = w.Write(buf[:n])
        if canFlush {
            flusher.Flush()
        }
    }
    if err != nil {
        break
    }
}

2. Silent failure when no grant is active (manager.go:683)

When claude.base_url is set but anthropicCred == nil (user omitted the anthropic or claude grant), the entire block is silently skipped — ANTHROPIC_BASE_URL is never set, no relay is registered, and nothing tells the user why. This was flagged in every previous review. Given the claude/anthropic inconsistency in the example file (see below), this is an easy misconfiguration to make.

if opts.Config != nil && opts.Config.Claude.BaseURL != "" {
    if anthropicCred == nil {
        ui.Warn("claude.base_url is set but no anthropic or claude grant is active — ANTHROPIC_BASE_URL will not be set")
    } else if baseURL, parseErr := url.Parse(opts.Config.Claude.BaseURL); parseErr == nil {
        // ... existing relay setup
    }
}

3. Grant inconsistency in example file (examples/llm-proxy/agent.yaml)

The prerequisite comment on line 17 says moat grant anthropic (API key), but the grants: field on line 35 uses claude (OAuth). These are different credential types. The guide (01-claude-code.md) also uses grants: [claude], so the example and guide agree on the grants field — but the prerequisite comment in the example file contradicts it. Either update the comment to moat grant claude or flip both the comment and the grants to use anthropic. Flagged in previous reviews.

Minor: security note incomplete (proxy.go:533)

The comment notes that relays skip auth because direct HTTP requests don't carry Proxy-Authorization, and that relays only forward to pre-configured URLs. What's not documented is the consequence on Apple containers (where the proxy binds to 0.0.0.0): any process on the same LAN can reach the relay endpoint without the auth token. The pre-configured-URL constraint limits the impact, but this is the same exposure the authToken was introduced to prevent for other endpoints. Worth a one-line note so future readers understand the trade-off.

- Add ui.Warn when claude.base_url is set but no anthropic/claude
  grant is active (silent misconfiguration)
- Add incremental flushing for SSE/streaming responses in relay
  handler (flush after each read instead of buffering in io.Copy)
- Fix example prerequisite comment to match claude grant
@claude
Copy link
Copy Markdown

claude Bot commented Feb 24, 2026

Claude finished @dpup's task in 3m 46s —— View job


Code Review

  • Gather context
  • Read changed files
  • Check which previous issues are resolved
  • Post inline comments
  • Post summary

Summary

This is a significant improvement over the previous iterations. All critical bugs and most of the smaller issues flagged across six prior review cycles are now resolved. The PR is close to merge-ready.

What's fixed:

  • ConfigureBaseURLProxy (provider.go:129-135) now internally strips the port via net.SplitHostPort before calling isValidHost-guarded methods — credentials are registered correctly even when baseURL.Host includes a port.
  • The relay handler (relay.go:103-117) uses getCredential(), getExtraHeaders(), and getRemoveHeaders() — all three have SplitHostPort fallbacks, so host:port lookups work correctly. Direct map access bug is gone.
  • getRemoveHeaders is called in the relay — x-api-key stripping for OAuth works.
  • Streaming flushed per-chunk via the loop at relay.go:149-161.
  • ui.Warn added at manager.go:683-685 when claude.base_url is set but no grant is active.
  • parseErr is now logged at manager.go:688-691 rather than silently swallowed.
  • Docs accurately describe the relay approach at 01-claude-code.md:246-248.
  • Grant inconsistency resolved: examples/llm-proxy/agent.yaml and the guide both use claude (OAuth) with consistent moat grant claude prerequisite.

Two remaining issues:

1. Security comment incomplete (proxy.go:533)

The comment explains why auth is skipped (direct requests don't carry Proxy-Authorization) and adds a safety note ("relays only forward to pre-configured URLs"). What's still missing: when the proxy binds to 0.0.0.0 on Apple containers, any process on the same LAN can reach the relay endpoint without the per-run auth token. The auth token was introduced specifically to harden the 0.0.0.0 binding — the relay creates a deliberate gap in that model. The package docstring (lines 16-28) documents the security model but doesn't mention this relay exception. A one-sentence note in the relay dispatch block would close the gap:

// Limitation: on Apple containers (0.0.0.0 binding), the relay endpoint is
// reachable from the LAN without the auth token. Relays only forward to
// pre-configured URLs, which limits impact, but is worth documenting.

2. Test coverage gap for the host:port lookup path (relay_test.go)

TestRelay_InjectsCredentials registers credentials directly via p.SetCredentialWithGrant(host, ...) where host is already stripped to a bare hostname. It doesn't test the path where credentials are registered with a host:port string (as ConfigureBaseURLProxy receives from manager.go) and the relay must use getCredential's SplitHostPort fallback to find them. This exact gap allowed the bug to survive earlier iterations — the mock bypassed isValidHost, and the relay test bypassed the port-stripping path. A single additional sub-test in TestRelay_InjectsCredentials registering with host:port would cover it:

// Also test that host:port registration works (port is stripped by SetCredentialWithGrant
// via isValidHost, so getCredential's SplitHostPort fallback must find it)
p.SetCredentialWithGrant(backendHost, "Authorization", "Bearer sk-real-token", "claude")
// ...same request, same expectation

Minor:

mcp.go:228 uses io.Copy without per-chunk flushing, unlike the new relay which has the correct flushing loop. Out of scope for this PR, but worth a follow-up.

@dpup dpup merged commit d5dac91 into main Feb 24, 2026
64 checks passed
@dpup dpup deleted the feat/claude-base-url branch February 24, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant