Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions claude-proxy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,39 @@ while [[ $# -gt 0 ]]; do
esac
done

# Build settings JSON that overrides the global settings.json env block.
# --settings MERGES with the global config, so we must explicitly override
# every env var that interferes:
# - ANTHROPIC_AUTH_TOKEN: global sets "dummy"; interactive (cli) mode uses
# this for auth, so it must be the real proxy token.
# - ANTHROPIC_CUSTOM_HEADERS: global injects Cloudflare gateway headers;
# cleared so they don't leak to the local proxy.
# - ANTHROPIC_BASE_URL: global points at Cloudflare gateway.
SETTINGS=$(cat <<EOF
# Build settings JSON safely using python3 to avoid JSON injection if the
# token contains quotes or backslashes. Falls back to heredoc if python3
# is unavailable.
if command -v python3 &>/dev/null; then
SETTINGS=$(python3 -c '
import json, sys
token = sys.argv[1]
model = sys.argv[2]
haiku = sys.argv[3]
print(json.dumps({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:3050",
"ANTHROPIC_API_KEY": token,
"ANTHROPIC_AUTH_TOKEN": token,
"ANTHROPIC_CUSTOM_HEADERS": "",
"ANTHROPIC_MODEL": model,
"ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku,
"ANTHROPIC_DEFAULT_SONNET_MODEL": model,
"ANTHROPIC_DEFAULT_OPUS_MODEL": model
},
"model": model
}))
' "$PROXY_TOKEN" "$MODEL" "$HAIKU")
else
# Fallback: escape token for JSON (handles " and \)
ESCAPED_TOKEN="${PROXY_TOKEN//\\/\\\\}"
ESCAPED_TOKEN="${ESCAPED_TOKEN//\"/\\\"}"
SETTINGS=$(cat <<EOF
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:3050",
"ANTHROPIC_API_KEY": "${PROXY_TOKEN}",
"ANTHROPIC_AUTH_TOKEN": "${PROXY_TOKEN}",
"ANTHROPIC_API_KEY": "${ESCAPED_TOKEN}",
"ANTHROPIC_AUTH_TOKEN": "${ESCAPED_TOKEN}",
"ANTHROPIC_CUSTOM_HEADERS": "",
"ANTHROPIC_MODEL": "${MODEL}",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "${HAIKU}",
Expand All @@ -76,6 +95,7 @@ SETTINGS=$(cat <<EOF
"model": "${MODEL}"
}
EOF
)
)
fi

exec claude --settings "$SETTINGS" "${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"}"
53 changes: 35 additions & 18 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,18 @@ type Client struct {

// New creates a new CommandCode API client
func New(apiBase string, projectSlug string, logger *logging.Logger) *Client {
// Use a custom transport with connection pooling tuned for both
// short API calls and long-running streaming responses. The overall
// timeout is handled per-request via context, not the http.Client,
// so streaming responses are not killed prematurely.
transport := &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
Comment on lines +35 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Creating a raw &http.Transport{} from scratch without copying http.DefaultTransport or configuring a custom DialContext means you lose critical default settings. Specifically, you lose the default 30-second dial timeout, keep-alive settings, TLS handshake timeouts, and system proxy support. This can cause connection attempts to hang indefinitely under poor network conditions.

Instead, clone http.DefaultTransport and customize only the connection pooling fields.

	transport := http.DefaultTransport.(*http.Transport).Clone()
	transport.MaxIdleConns = 20
	transport.MaxIdleConnsPerHost = 10
	transport.IdleConnTimeout = 90 * time.Second

return &Client{
httpClient: &http.Client{
Timeout: 90 * time.Second,
Transport: transport,
},
apiBase: apiBase,
projectSlug: projectSlug,
Expand All @@ -39,7 +48,7 @@ func New(apiBase string, projectSlug string, logger *logging.Logger) *Client {
}

// Forward forwards a request to the CommandCode API
func (c *Client) Forward(ctx context.Context, body []byte, cc_apiKey string, headers http.Header, sessionID string, fp *config.Fingerprint) (*http.Response, error) {
func (c *Client) Forward(ctx context.Context, body []byte, ccAPIKey string, headers http.Header, sessionID string, fp *config.Fingerprint) (*http.Response, error) {
url := fmt.Sprintf("%s/alpha/generate", c.apiBase)

req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
Expand All @@ -51,7 +60,7 @@ func (c *Client) Forward(ctx context.Context, body []byte, cc_apiKey string, hea
}

// Set headers
req.Header.Set("Authorization", "Bearer "+cc_apiKey)
req.Header.Set("Authorization", "Bearer "+ccAPIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-cli-environment", "production")
req.Header.Set("x-command-code-version", version.GetCommandCodeVersion())
Expand Down Expand Up @@ -80,9 +89,10 @@ func (c *Client) Forward(ctx context.Context, body []byte, cc_apiKey string, hea
req.Header.Set("x-collector-version", fmt.Sprintf("%d", fp.Components.CollectorVersion))
}

// Copy safe custom headers without leaking local proxy credentials upstream.
// Copy only safe client headers upstream — use an allowlist rather than a
// blocklist to prevent accidental credential leakage.
for key, values := range headers {
if isForwardBlockedHeader(key) {
if !isForwardAllowedHeader(key) {
continue
}
for _, value := range values {
Expand Down Expand Up @@ -110,7 +120,7 @@ func (c *Client) Forward(ctx context.Context, body []byte, cc_apiKey string, hea
}

// ForwardAnthropicCountTokens forwards an Anthropic token count request to the upstream API.
func (c *Client) ForwardAnthropicCountTokens(ctx context.Context, body []byte, cc_apiKey string, headers http.Header, sessionID string, fp *config.Fingerprint, rawQuery string) (*http.Response, error) {
func (c *Client) ForwardAnthropicCountTokens(ctx context.Context, body []byte, ccAPIKey string, headers http.Header, sessionID string, fp *config.Fingerprint, rawQuery string) (*http.Response, error) {
url := fmt.Sprintf("%s/v1/messages/count_tokens", c.apiBase)
if rawQuery != "" {
url += "?" + rawQuery
Expand All @@ -124,7 +134,7 @@ func (c *Client) ForwardAnthropicCountTokens(ctx context.Context, body []byte, c
return nil, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+cc_apiKey)
req.Header.Set("Authorization", "Bearer "+ccAPIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-cli-environment", "production")
req.Header.Set("x-command-code-version", version.GetCommandCodeVersion())
Expand Down Expand Up @@ -153,7 +163,7 @@ func (c *Client) ForwardAnthropicCountTokens(ctx context.Context, body []byte, c
}

for key, values := range headers {
if isForwardBlockedHeader(key) {
if !isForwardAllowedHeader(key) {
continue
}
for _, value := range values {
Expand Down Expand Up @@ -193,10 +203,17 @@ func (c *Client) projectSlugForSession(sessionID string) string {
return ProjectSlug(c.projectSlug, sessionID)
}

// isForwardBlockedHeader reports whether an inbound header must stay local to the proxy.
func isForwardBlockedHeader(key string) bool {
switch strings.ToLower(key) {
case "authorization", "x-proxy-token", "x-cli-environment", "x-command-code-version", "x-project-slug", "x-session-id", "x-co-flag", "x-taste-learning", "traceparent", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length":
// isForwardAllowedHeader reports whether an inbound header is safe to forward
// upstream. Uses an allowlist of anthropic/SDK headers — everything else
// (including Authorization, proxy tokens, hop-by-hop headers) is blocked.
func isForwardAllowedHeader(key string) bool {
lk := strings.ToLower(key)
// Allow Anthropic SDK headers that the upstream API expects.
if strings.HasPrefix(lk, "anthropic-") {
return true
}
switch lk {
case "accept", "accept-encoding", "user-agent":
return true
default:
return false
Expand All @@ -217,7 +234,7 @@ func generateTraceparent() string {
}

// FetchModels fetches the list of available models from the Provider API
func (c *Client) FetchModels(ctx context.Context, cc_apiKey string) ([]Model, error) {
func (c *Client) FetchModels(ctx context.Context, ccAPIKey string) ([]Model, error) {
url := fmt.Sprintf("%s/v1/models", c.apiBase)

req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
Expand All @@ -228,7 +245,7 @@ func (c *Client) FetchModels(ctx context.Context, cc_apiKey string) ([]Model, er
return nil, fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+cc_apiKey)
req.Header.Set("Authorization", "Bearer "+ccAPIKey)
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
Expand Down Expand Up @@ -264,7 +281,7 @@ func (c *Client) FetchModels(ctx context.Context, cc_apiKey string) ([]Model, er
}

// SendFingerprint sends a fingerprint to the CommandCode API
func (c *Client) SendFingerprint(ctx context.Context, fp *fingerprint.Fingerprint, cc_apiKey string) error {
func (c *Client) SendFingerprint(ctx context.Context, fp *fingerprint.Fingerprint, ccAPIKey string) error {
url := fmt.Sprintf("%s/v1/fingerprint", c.apiBase)

body, err := json.Marshal(fp)
Expand All @@ -283,7 +300,7 @@ func (c *Client) SendFingerprint(ctx context.Context, fp *fingerprint.Fingerprin
return fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+cc_apiKey)
req.Header.Set("Authorization", "Bearer "+ccAPIKey)
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
Expand All @@ -307,7 +324,7 @@ func (c *Client) SendFingerprint(ctx context.Context, fp *fingerprint.Fingerprin
}

// SendLifecycleEvent sends a lifecycle event to the CommandCode API
func (c *Client) SendLifecycleEvent(ctx context.Context, cc_apiKey string, eventType string) error {
func (c *Client) SendLifecycleEvent(ctx context.Context, ccAPIKey string, eventType string) error {
url := fmt.Sprintf("%s/v1/lifecycle", c.apiBase)

event := map[string]string{
Expand All @@ -330,7 +347,7 @@ func (c *Client) SendLifecycleEvent(ctx context.Context, cc_apiKey string, event
return fmt.Errorf("failed to create request: %w", err)
}

req.Header.Set("Authorization", "Bearer "+cc_apiKey)
req.Header.Set("Authorization", "Bearer "+ccAPIKey)
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
Expand Down
4 changes: 2 additions & 2 deletions internal/client/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func NewInitManager(apiBase string, projectSlug string, logger *logging.Logger)
}

// EnsureInitialized ensures initialization requests are sent if needed
func (m *InitManager) EnsureInitialized(ctx context.Context, cc_apiKey string, fp *config.Fingerprint) error {
func (m *InitManager) EnsureInitialized(ctx context.Context, ccAPIKey string, fp *config.Fingerprint) error {
m.mu.Lock()
now := time.Now()
if now.Before(m.nextInitAt) {
Expand All @@ -55,7 +55,7 @@ func (m *InitManager) EnsureInitialized(ctx context.Context, cc_apiKey string, f
headers := map[string]string{
"Content-Type": "application/json",
"x-cli-environment": "production",
"Authorization": "Bearer " + cc_apiKey,
"Authorization": "Bearer " + ccAPIKey,
"x-command-code-version": version.GetCommandCodeVersion(),
}

Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type Config struct {
LogFile string `json:"logFile"`
LogLevel string `json:"logLevel"`
UseProviderModels bool `json:"useProviderModels"`
ModelRefreshInterval time.Duration `json:"modelRefreshIntervalMs"`
ModelRefreshInterval time.Duration `json:"modelRefreshInterval,omitempty"`
Fingerprint *Fingerprint `json:"fingerprint,omitempty"`
}

Expand Down
129 changes: 129 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package config

import (
"os"
"path/filepath"
"testing"
"time"
)

func TestLoadFallsBackToDefaultsWhenFileMissing(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "nonexistent-config-test.json"))
if err != nil {
t.Fatalf("Load() error = %v, want nil for missing file", err)
}
if cfg.Port != 3000 {
t.Fatalf("Port = %d, want default 3000", cfg.Port)
}
if cfg.APIBase != "https://api.commandcode.ai" {
t.Fatalf("APIBase = %q, want default", cfg.APIBase)
}
if cfg.UseProviderModels != true {
t.Fatalf("UseProviderModels = %v, want true", cfg.UseProviderModels)
}
}

func TestLoadFailsOnPermissionDenied(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"port":3050}`), 0644); err != nil {
t.Fatalf("setup: %v", err)
}
if err := os.Chmod(path, 0000); err != nil {
t.Fatalf("chmod: %v", err)
}
t.Cleanup(func() { os.Chmod(path, 0644) })

// Skip if running as root (root bypasses file permissions).
if os.Getuid() == 0 {
t.Skip("running as root, permission test not applicable")
}

_, err := Load(path)
if err == nil {
t.Fatal("Load() error = nil, want error for unreadable file")
}
}
Comment on lines +26 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

os.Getuid() is not supported on Windows, which will cause this test file to fail compilation on Windows platforms. Additionally, os.Chmod(path, 0000) does not reliably make files unreadable on Windows.

To test the read failure path in a fully cross-platform and robust manner without needing root/administrator checks, you can pass a directory path (like t.TempDir()) to Load(). Attempting to read a directory as a file will fail with a read error on all operating systems.

func TestLoadFailsOnReadError(t *testing.T) {
	dir := t.TempDir()
	_, err := Load(dir)
	if err == nil {
		t.Fatal("Load() error = nil, want error when path is a directory")
	}
}


func TestLoadParsesValidJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
data := `{"port":8080,"host":"127.0.0.1","apiBase":"https://custom.api","cc_apiKey":"user_abc123","proxy_token":"tok"}`
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
t.Fatalf("setup: %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Port != 8080 {
t.Fatalf("Port = %d, want 8080", cfg.Port)
}
if cfg.Host != "127.0.0.1" {
t.Fatalf("Host = %q, want 127.0.0.1", cfg.Host)
}
if cfg.APIBase != "https://custom.api" {
t.Fatalf("APIBase = %q, want https://custom.api", cfg.APIBase)
}
if cfg.CCAPIKey != "user_abc123" {
t.Fatalf("CCAPIKey = %q, want user_abc123", cfg.CCAPIKey)
}
if cfg.ProxyToken != "tok" {
t.Fatalf("ProxyToken = %q, want tok", cfg.ProxyToken)
}
}

func TestLoadFailsOnInvalidJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{invalid json`), 0644); err != nil {
t.Fatalf("setup: %v", err)
}

_, err := Load(path)
if err == nil {
t.Fatal("Load() error = nil, want error for invalid JSON")
}
}

func TestLoadEnvOverrides(t *testing.T) {
t.Setenv("PORT", "9999")
t.Setenv("HOST", "0.0.0.0")
t.Setenv("COMMANDCODE_PROXY_TOKEN", "envtoken")
t.Setenv("LOG_LEVEL", "debug")

cfg, err := Load(filepath.Join(t.TempDir(), "nonexistent-config-test.json"))
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Port != 9999 {
t.Fatalf("Port = %d, want 9999 from env", cfg.Port)
}
if cfg.ProxyToken != "envtoken" {
t.Fatalf("ProxyToken = %q, want envtoken from env", cfg.ProxyToken)
}
if cfg.LogLevel != "debug" {
t.Fatalf("LogLevel = %q, want debug from env", cfg.LogLevel)
}
}

func TestValidateRejectsInvalidPort(t *testing.T) {
cfg := &Config{Port: 0, Host: "0.0.0.0", APIBase: "https://api.test"}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want error for port 0")
}
}

func TestValidateRejectsEmptyHost(t *testing.T) {
cfg := &Config{Port: 3050, Host: "", APIBase: "https://api.test"}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate() error = nil, want error for empty host")
}
}

func TestDefaultModelRefreshInterval(t *testing.T) {
if defaults.ModelRefreshInterval != 5*time.Minute {
t.Fatalf("default ModelRefreshInterval = %v, want 5m", defaults.ModelRefreshInterval)
}
}
Loading