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+
108200func (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
163262func (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