Skip to content

Commit c27d4dc

Browse files
authored
feat(claude): route Claude Code through proxy via <model>@<base_url> (#652)
## Summary - Add `<model>@<base_url>` spec parsing for the `claude-code` agent; when a proxy URL is present, pin all tier aliases (Opus/Sonnet/Haiku/subagent) to the given model via env. - Strip inherited `ANTHROPIC_*` / `CLAUDECODE` / `CLAUDE_CODE_SUBAGENT_MODEL` from the child env so roborev owns routing; forward configured `ANTHROPIC_API_KEY` as `ANTHROPIC_AUTH_TOKEN` in proxy mode (fallback placeholder otherwise). - Reject proxy-only specs (`@http://...`) with a clear error; README documents the feature and the credentials-in-URL caveat. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Chris K Wensel <cwensel@users.noreply.github.com>
1 parent ad18aef commit c27d4dc

4 files changed

Lines changed: 565 additions & 4 deletions

File tree

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,46 @@ See [configuration guide](https://roborev.io/configuration/) for all options.
187187

188188
roborev auto-detects installed agents.
189189

190+
### Routing Claude Code to a proxy (Ollama, LiteLLM, etc.)
191+
192+
The `claude-code` agent accepts a model spec of the form `<model>@<base_url>`.
193+
When `<base_url>` starts with `http(s)://`, roborev points Claude Code at
194+
that endpoint and pins all tier aliases (Opus/Sonnet/Haiku/subagent) to the
195+
given model.
196+
197+
```toml
198+
# .roborev.toml — local Ollama for reviews, real Anthropic for fixes
199+
agent = "claude-code"
200+
review_model = "glm-5.1:cloud@http://127.0.0.1:11434"
201+
fix_model = "sonnet"
202+
```
203+
204+
Or via CLI: `roborev review --model 'glm-5.1:cloud@http://127.0.0.1:11434'`.
205+
206+
**Proxy auth.** Set `ROBOREV_CLAUDE_PROXY_TOKEN` to forward a bearer token
207+
to the proxy as `ANTHROPIC_AUTH_TOKEN`. If unset, roborev sends a placeholder
208+
(sufficient for gateways that don't check the header, such as Ollama).
209+
roborev does *not* forward `ANTHROPIC_API_KEY` to proxy endpoints — that
210+
would leak a real Anthropic credential to arbitrary third parties.
211+
212+
**URL restrictions.** Proxy URLs must not embed `user:pass@` credentials
213+
(use `ROBOREV_CLAUDE_PROXY_TOKEN`); `http://` is only accepted for loopback
214+
hosts (`127.0.0.1`, `::1`, `localhost`) so plaintext endpoints can't receive
215+
tokens over the wire. Use `https://` for remote proxies. The full URL
216+
(including any path or query string) is forwarded as-is to
217+
`ANTHROPIC_BASE_URL`, so include the path your gateway expects (e.g.
218+
LiteLLM may want a trailing `/v1`; Ollama wants no path).
219+
220+
**Environment behavior (breaking change in this release).** When the
221+
`claude-code` agent runs, roborev always strips inherited `ANTHROPIC_API_KEY`,
222+
`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`,
223+
`ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU}_MODEL`, and `CLAUDE_CODE_SUBAGENT_MODEL`
224+
from the child environment. If you were previously routing Claude Code by
225+
exporting these vars in your shell, switch to the `<model>@<base_url>` spec
226+
instead. For native (non-proxy) mode, configure `ANTHROPIC_API_KEY` via
227+
roborev's config (it is re-injected from roborev's stored key, not inherited
228+
from the operator's shell).
229+
190230
## Security Model
191231

192232
roborev delegates code review and fix tasks to AI coding agents that

internal/agent/agent_test_cli_helpers_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type MockCLIOpts struct {
8585
ExitCode int
8686
CaptureArgs bool
8787
CaptureStdin bool
88+
CaptureEnv bool
8889
StdoutLines []string
8990
StderrLines []string
9091
}
@@ -94,6 +95,7 @@ type MockCLIResult struct {
9495
CmdPath string
9596
ArgsFile string
9697
StdinFile string
98+
EnvFile string
9799
}
98100

99101
// readMockArgs reads the captured arguments from a mock CLI's ArgsFile and splits them into a slice.
@@ -106,6 +108,21 @@ func readMockArgs(t *testing.T, path string) []string {
106108
return strings.Split(strings.TrimSpace(string(content)), " ")
107109
}
108110

111+
// readMockEnv reads the captured env from a mock CLI's EnvFile.
112+
// Returns each env var as a "KEY=VALUE" string.
113+
func readMockEnv(t *testing.T, path string) []string {
114+
t.Helper()
115+
content, err := os.ReadFile(path)
116+
if err != nil {
117+
t.Fatalf("failed to read env file %s: %v", path, err)
118+
}
119+
trimmed := strings.TrimRight(string(content), "\n")
120+
if trimmed == "" {
121+
return nil
122+
}
123+
return strings.Split(trimmed, "\n")
124+
}
125+
109126
// assertContainsArg checks that args contains target, failing with a descriptive message.
110127
func assertContainsArg(t *testing.T, args []string, target string) {
111128
t.Helper()
@@ -152,6 +169,11 @@ func mockAgentCLI(t *testing.T, opts MockCLIOpts) *MockCLIResult {
152169
fmt.Fprintf(&script, "cat > %q\n", result.StdinFile)
153170
}
154171

172+
if opts.CaptureEnv {
173+
result.EnvFile = filepath.Join(tmpDir, "env.txt")
174+
fmt.Fprintf(&script, "env > %q\n", result.EnvFile)
175+
}
176+
155177
if len(opts.StdoutLines) > 0 {
156178
stdoutFile := filepath.Join(tmpDir, "stdout.txt")
157179
content := strings.Join(opts.StdoutLines, "\n")

internal/agent/claude.go

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import (
55
"encoding/json"
66
"fmt"
77
"io"
8+
"net"
9+
"net/url"
10+
"os"
811
"os/exec"
912
"slices"
1013
"strings"
@@ -105,14 +108,110 @@ func (a *ClaudeAgent) CommandLine() string {
105108
return a.Command + " " + strings.Join(args, " ")
106109
}
107110

111+
// parseModel splits a model spec of the form "<model>@<base_url>" into its
112+
// components. The split happens at the first "@" whose suffix starts with
113+
// http:// or https:// (so proxy URLs embedded after the model are recognized
114+
// while leaving the rest of the spec intact).
115+
//
116+
// Rejections (return error):
117+
// - Proxy URL containing userinfo (user:pass@host) — these leak into
118+
// child-process env, /proc, and error messages. Operators must use
119+
// ROBOREV_CLAUDE_PROXY_TOKEN for proxy auth.
120+
// - Trailing bare "@" with no URL suffix (e.g. "sonnet@") — malformed
121+
// input that previously fell through to native routing silently.
122+
// - Leading "@http(s)://" with no model — proxy mode must pin tier
123+
// aliases to a concrete model name.
124+
// - Bare "http(s)://" URL with no "<model>@" prefix — same reason.
125+
// - Leading "@" with non-URL suffix (e.g. "@foo") — would pass through to
126+
// Claude as `--model @foo` and produce a confusing downstream error.
127+
func parseModel(spec string) (model, baseURL string, err error) {
128+
if strings.HasPrefix(spec, "@http://") || strings.HasPrefix(spec, "@https://") {
129+
return "", "", fmt.Errorf("model spec %q has proxy URL but no model; use '<model>@%s'", spec, spec[1:])
130+
}
131+
if strings.HasPrefix(spec, "http://") || strings.HasPrefix(spec, "https://") {
132+
return "", "", fmt.Errorf("model spec %q is a bare proxy URL; use '<model>@%s'", spec, spec)
133+
}
134+
if strings.HasPrefix(spec, "@") {
135+
return "", "", fmt.Errorf("model spec %q starts with '@'; model name must come before the '@<base_url>' suffix", spec)
136+
}
137+
for i := 1; i < len(spec); i++ {
138+
if spec[i] != '@' {
139+
continue
140+
}
141+
suffix := spec[i+1:]
142+
if strings.HasPrefix(suffix, "http://") || strings.HasPrefix(suffix, "https://") {
143+
if err := validateProxyURL(suffix); err != nil {
144+
return "", "", err
145+
}
146+
model := spec[:i]
147+
// Reject specs like "foo@httpx://bar@http://host" where the model
148+
// component itself contains a URL-like substring — these indicate
149+
// a malformed multi-URL spec that would otherwise silently pass
150+
// a nonsensical --model argument to Claude.
151+
if strings.Contains(model, "://") {
152+
return "", "", fmt.Errorf("model spec %q has URL-like substring in model component %q; only one '@<base_url>' suffix is allowed", spec, model)
153+
}
154+
return model, suffix, nil
155+
}
156+
}
157+
if strings.HasSuffix(spec, "@") {
158+
return "", "", fmt.Errorf("model spec %q has trailing '@' — remove it or append a proxy URL as '<model>@http(s)://<host>'", spec)
159+
}
160+
return spec, "", nil
161+
}
162+
163+
// validateProxyURL rejects proxy URLs that would leak credentials via the
164+
// child-process environment. The parsed URL must not contain userinfo; http://
165+
// is only permitted for loopback hosts so real credentials aren't forwarded
166+
// over plaintext to remote endpoints.
167+
func validateProxyURL(raw string) error {
168+
u, err := url.ParseRequestURI(raw)
169+
if err != nil {
170+
return fmt.Errorf("invalid proxy URL %q: %w", raw, err)
171+
}
172+
if u.Host == "" {
173+
return fmt.Errorf("proxy URL %q has no host", raw)
174+
}
175+
if u.User != nil {
176+
return fmt.Errorf("proxy URL must not embed credentials; set ROBOREV_CLAUDE_PROXY_TOKEN instead")
177+
}
178+
// Reject fragments — they have no server-side meaning for HTTP requests
179+
// and indicate operator error. Belt-and-suspenders: also scan the raw
180+
// string in case ParseRequestURI's fragment handling changes.
181+
if u.Fragment != "" || strings.Contains(raw, "#") {
182+
return fmt.Errorf("proxy URL %q must not contain a fragment", raw)
183+
}
184+
if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
185+
return fmt.Errorf("proxy URL %q uses http:// with a non-loopback host; use https:// or a loopback address", raw)
186+
}
187+
return nil
188+
}
189+
190+
func isLoopbackHost(host string) bool {
191+
if host == "localhost" {
192+
return true
193+
}
194+
if ip := net.ParseIP(host); ip != nil {
195+
return ip.IsLoopback()
196+
}
197+
return false
198+
}
199+
108200
func (a *ClaudeAgent) buildArgs(agenticMode, includeEffort bool) []string {
109201
sessionID := sanitizedResumeSessionID(a.SessionID)
110202
// Always use stdin piping + stream-json for non-interactive execution
111203
// (following claude-code-action pattern from Anthropic)
112204
args := []string{"-p", "--verbose", "--output-format", "stream-json"}
113205

114-
if a.Model != "" {
115-
args = append(args, "--model", a.Model)
206+
// buildArgs is also called from CommandLine() for display; on parse error
207+
// fall back to the raw configured model so operators see what they typed
208+
// in logs. Review() re-parses and surfaces the error before execution.
209+
model, _, parseErr := parseModel(a.Model)
210+
if parseErr != nil {
211+
model = a.Model
212+
}
213+
if model != "" {
214+
args = append(args, "--model", model)
116215
}
117216
if sessionID != "" {
118217
args = append(args, "--resume", sessionID)
@@ -161,6 +260,11 @@ func claudeSupportsEffortFlag(ctx context.Context, command string) bool {
161260
}
162261

163262
func (a *ClaudeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) {
263+
model, baseURL, err := parseModel(a.Model)
264+
if err != nil {
265+
return "", err
266+
}
267+
164268
// Use agentic mode if either per-job setting or global setting enables it
165269
agenticMode := a.Agentic || AllowUnsafeAgents()
166270

@@ -185,12 +289,57 @@ func (a *ClaudeAgent) Review(ctx context.Context, repoPath, commitSHA, prompt st
185289
// Use cmd.Environ() (not os.Environ()) so PWD=<cmd.Dir> is
186290
// synthesized correctly. Set env before configureSubprocess so
187291
// GIT_OPTIONAL_LOCKS=0 is appended to the final environment.
188-
stripKeys := []string{"ANTHROPIC_API_KEY", "CLAUDECODE"}
292+
// Always strip inherited Anthropic routing env so roborev owns the
293+
// routing decision: native mode uses Anthropic defaults, proxy mode
294+
// injects its own block below. This prevents silent misrouting when
295+
// the user has exported these vars in their shell.
296+
stripKeys := []string{
297+
"ANTHROPIC_API_KEY",
298+
"CLAUDECODE",
299+
"ANTHROPIC_BASE_URL",
300+
"ANTHROPIC_AUTH_TOKEN",
301+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
302+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
303+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
304+
"CLAUDE_CODE_SUBAGENT_MODEL",
305+
}
189306
cmd := exec.CommandContext(ctx, a.Command, args...)
190307
cmd.Dir = repoPath
191308
baseEnv := cmd.Environ()
192309
env := filterEnv(baseEnv, stripKeys...)
193-
if apiKey := AnthropicAPIKey(); apiKey != "" {
310+
if baseURL != "" {
311+
// Route Claude Code to an OpenAI/Anthropic-compatible proxy (Ollama,
312+
// LiteLLM, etc.). Pin all tier aliases to the same model so Claude's
313+
// internal tier-switching stays on the proxy target.
314+
//
315+
// Proxy auth is opt-in via ROBOREV_CLAUDE_PROXY_TOKEN, read from the
316+
// roborev process environment (not the agent's). We deliberately do
317+
// NOT reuse ANTHROPIC_API_KEY: forwarding a real Anthropic credential
318+
// to an arbitrary third-party proxy is an exfiltration risk. If the
319+
// env var is unset, send a placeholder — gateways that don't check
320+
// the header (Ollama, most local dev proxies) accept it, and
321+
// gateways that do check will reject with a clear 401.
322+
// Trim whitespace so a token pasted from a config file with a
323+
// trailing newline doesn't fail auth at the proxy with a confusing
324+
// 401. Reject embedded control characters (newline, carriage return,
325+
// NUL) that survive trimming — these would either be rejected by
326+
// exec (NUL) or produce malformed HTTP headers at the proxy.
327+
authToken := strings.TrimSpace(os.Getenv("ROBOREV_CLAUDE_PROXY_TOKEN"))
328+
if strings.ContainsAny(authToken, "\n\r\x00") {
329+
return "", fmt.Errorf("ROBOREV_CLAUDE_PROXY_TOKEN must not contain control characters (newline, carriage return, or NUL)")
330+
}
331+
if authToken == "" {
332+
authToken = "proxy"
333+
}
334+
env = append(env,
335+
"ANTHROPIC_BASE_URL="+baseURL,
336+
"ANTHROPIC_AUTH_TOKEN="+authToken,
337+
"ANTHROPIC_DEFAULT_OPUS_MODEL="+model,
338+
"ANTHROPIC_DEFAULT_SONNET_MODEL="+model,
339+
"ANTHROPIC_DEFAULT_HAIKU_MODEL="+model,
340+
"CLAUDE_CODE_SUBAGENT_MODEL="+model,
341+
)
342+
} else if apiKey := AnthropicAPIKey(); apiKey != "" {
194343
env = append(env, "ANTHROPIC_API_KEY="+apiKey)
195344
}
196345
env = append(env, "CLAUDE_NO_SOUND=1")

0 commit comments

Comments
 (0)