diff --git a/docs/content/reference/04-grants.md b/docs/content/reference/04-grants.md index 5a46a90a..69febc90 100644 --- a/docs/content/reference/04-grants.md +++ b/docs/content/reference/04-grants.md @@ -124,7 +124,7 @@ Stored as `anthropic.enc`. The proxy injects credentials for requests to `api.anthropic.com`: -- **`claude` grant**: `Authorization: Bearer ` with OAuth beta flag. Container receives `CLAUDE_CODE_OAUTH_TOKEN` placeholder. +- **`claude` grant**: `Authorization: Bearer ` with OAuth beta flag. Container receives a `.credentials.json` with an OAuth placeholder token and subscription metadata. - **`anthropic` grant**: `x-api-key: `. Container receives `ANTHROPIC_API_KEY` placeholder. ### Refresh behavior diff --git a/internal/credential/provider.go b/internal/credential/provider.go index be2cac80..afd69e1e 100644 --- a/internal/credential/provider.go +++ b/internal/credential/provider.go @@ -36,6 +36,15 @@ const OpenAIAPIKeyPlaceholder = "sk-moat-proxy-injected-placeholder-000000000000 // Authorization headers, so this placeholder never reaches GitHub's servers. const GitHubTokenPlaceholder = "ghp_moatProxyInjectedPlaceholder000000000000" +// ClaudeOAuthPlaceholder is a placeholder that looks like a valid Claude Code +// OAuth token. Claude Code checks the sk-ant-oat prefix to determine if the +// session is OAuth-authenticated. Without this prefix, it may skip OAuth-specific +// code paths that determine account capabilities like 1M context window access. +// +// The proxy intercepts all Anthropic HTTPS traffic and injects the real token +// via Authorization headers, so this placeholder never reaches Anthropic's servers. +const ClaudeOAuthPlaceholder = "sk-ant-oat01-moat-proxy-injected-placeholder-not-a-real-token" + // AnthropicAPIKeyPlaceholder is a placeholder that looks like a valid Anthropic // API key. // Some tools validate the API key format locally before making requests. diff --git a/internal/daemon/api.go b/internal/daemon/api.go index 20e4a935..a799a8f1 100644 --- a/internal/daemon/api.go +++ b/internal/daemon/api.go @@ -56,8 +56,9 @@ type RemoveHeaderSpec struct { // Since transformers are Go functions (not serializable), this spec allows // the daemon to reconstruct them from well-known kinds. type TransformerSpec struct { - Host string `json:"host"` - Kind string `json:"kind"` // "oauth-endpoint-workaround" or "response-scrub" + Host string `json:"host"` + Kind string `json:"kind"` // "oauth-endpoint-workaround" or "response-scrub" + Metadata map[string]string `json:"metadata,omitempty"` // Optional metadata for transformer reconstruction } // RegisterRequest is sent to POST /v1/runs. diff --git a/internal/daemon/runcontext.go b/internal/daemon/runcontext.go index 61a9c91f..1201bfe2 100644 --- a/internal/daemon/runcontext.go +++ b/internal/daemon/runcontext.go @@ -342,7 +342,7 @@ func (rc *RunContext) ToProxyContextData() *proxy.RunContextData { var tf credential.ResponseTransformer switch spec.Kind { case "oauth-endpoint-workaround": - tf = newOAuthEndpointTransformer() + tf = newOAuthEndpointTransformer(spec.Metadata) case "response-scrub": ts, ok := rc.TokenSubstitutions[spec.Host] if !ok { diff --git a/internal/daemon/transformers.go b/internal/daemon/transformers.go index 2ca40405..48290088 100644 --- a/internal/daemon/transformers.go +++ b/internal/daemon/transformers.go @@ -19,8 +19,8 @@ const maxScrubBodySize = 512 * 1024 // // Delegates to providers/claude.CreateOAuthEndpointTransformer to avoid duplicating // the endpoint list and response logic. -func newOAuthEndpointTransformer() func(req, resp interface{}) (interface{}, bool) { - return claude.CreateOAuthEndpointTransformer() +func newOAuthEndpointTransformer(meta map[string]string) func(req, resp interface{}) (interface{}, bool) { + return claude.CreateOAuthEndpointTransformerWithMeta(meta) } // newResponseScrubber creates a response transformer that replaces real tokens diff --git a/internal/providers/claude/agent.go b/internal/providers/claude/agent.go index 594b2b7b..060861ca 100644 --- a/internal/providers/claude/agent.go +++ b/internal/providers/claude/agent.go @@ -2,10 +2,15 @@ package claude import ( "context" + "encoding/json" "fmt" + "io" + "net/http" "os" "path/filepath" + "time" + "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/log" "github.com/majorcontext/moat/internal/provider" ) @@ -37,8 +42,11 @@ func (p *OAuthProvider) PrepareContainer(ctx context.Context, opts provider.Prep } }() - // Write credentials file for OAuth tokens + // Write credentials file for OAuth tokens. + // Enrich with subscription info from the host's Claude Code credentials + // so Claude Code in the container knows the account tier (e.g. Teams/Max for 1M context). if opts.Credential != nil { + enrichCredentialFromHost(opts.Credential) if err := WriteCredentialsFile(opts.Credential, tmpDir); err != nil { return nil, fmt.Errorf("writing credentials file: %w", err) } @@ -134,16 +142,174 @@ func (p *OAuthProvider) PrepareContainer(ctx context.Context, opts provider.Prep } // containerEnvForCredential returns the correct environment variable based on -// the credential's provider identity. OAuth credentials (provider "claude") get -// CLAUDE_CODE_OAUTH_TOKEN, API key credentials (provider "anthropic") get -// ANTHROPIC_API_KEY. Both use placeholder values — real credentials are injected -// by the proxy at the network layer. +// the credential's provider identity. API key credentials (provider "anthropic") +// get ANTHROPIC_API_KEY with a placeholder value — real credentials are injected +// by the proxy at the network layer. OAuth credentials (provider "claude") rely +// on .credentials.json instead of an environment variable. func containerEnvForCredential(cred *provider.Credential) []string { if cred == nil { return nil } if cred.Provider == "claude" { - return []string{"CLAUDE_CODE_OAUTH_TOKEN=" + ProxyInjectedPlaceholder} + // NOTE: We intentionally do NOT set CLAUDE_CODE_OAUTH_TOKEN here. + // When that env var is set with a non-OAuth-looking placeholder, + // Claude Code may not recognize the session as OAuth-authenticated, + // preventing features like 1M context window from working. + // + // Instead, we write a .credentials.json with the OAuth placeholder + // token (sk-ant-oat01-... prefix) and subscription metadata. Claude + // Code reads this file when the env var is absent. + return nil } return []string{"ANTHROPIC_API_KEY=" + ProxyInjectedPlaceholder} } + +// enrichCredentialFromHost populates subscription metadata and cached bootstrap +// on the credential if not already present. It tries two sources: +// +// 1. The credential's existing metadata (from grant-time caching — most reliable) +// 2. The host's ~/.claude/.credentials.json + live API call (fallback for older grants) +// +// Source 2 requires the host's short-lived OAuth token to be valid (~1hr lifetime). +// Source 1 is preferred because the bootstrap was cached at grant time when the +// host token was fresh. +func enrichCredentialFromHost(cred *provider.Credential) { + if cred.Provider != "claude" { + return + } + // Already has cached bootstrap from grant-time caching + if cred.Metadata != nil && cred.Metadata[metaKeyCachedBootstrap] != "" { + log.Debug("credential already has cached bootstrap", + "subsystem", "claude", + "bootstrap_len", len(cred.Metadata[metaKeyCachedBootstrap]), + ) + return + } + + cc := &credential.ClaudeCodeCredentials{} + hostToken, err := cc.GetClaudeCodeCredentials() + if err != nil { + log.Debug("could not read host Claude credentials for bootstrap cache", + "subsystem", "claude", + "error", err, + ) + return + } + + if hostToken.AccessToken == "" || hostToken.IsExpired() { + log.Debug("host Claude token unavailable or expired, skipping bootstrap cache", + "subsystem", "claude", + "has_token", hostToken.AccessToken != "", + "expired", hostToken.IsExpired(), + ) + return + } + + if cred.Metadata == nil { + cred.Metadata = make(map[string]string) + } + + // Fetch subscription info from profile (for .credentials.json) + if cred.Metadata["subscriptionType"] == "" { + subType, rateTier := fetchProfileSubscription(hostToken.AccessToken) + if subType != "" { + cred.Metadata["subscriptionType"] = subType + if rateTier != "" { + cred.Metadata["rateLimitTier"] = rateTier + } + } + } + + // Fetch full bootstrap response (for proxy to serve) + bootstrap := fetchBootstrapResponse(hostToken.AccessToken) + if bootstrap != "" { + cred.Metadata[metaKeyCachedBootstrap] = bootstrap + log.Debug("cached bootstrap response from host OAuth token", + "subsystem", "claude", + "bootstrap_len", len(bootstrap), + ) + } +} + +// fetchProfileSubscription calls /api/oauth/profile with the given OAuth token +// to retrieve subscription metadata. Returns empty strings on any failure. +func fetchProfileSubscription(accessToken string) (subscriptionType, rateLimitTier string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/api/oauth/profile", nil) + if err != nil { + return "", "" + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Debug("failed to fetch OAuth profile for subscription info", + "subsystem", "claude", + "error", err, + ) + return "", "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", "" + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if err != nil { + return "", "" + } + + var profile struct { + SubscriptionType string `json:"subscriptionType"` + RateLimitTier string `json:"rateLimitTier"` + } + if err := json.Unmarshal(body, &profile); err != nil { + return "", "" + } + + return profile.SubscriptionType, profile.RateLimitTier +} + +// fetchBootstrapResponse calls /api/bootstrap with the given OAuth token and +// returns the full response body as a string. Returns empty string on any failure. +func fetchBootstrapResponse(accessToken string) string { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/api/bootstrap", nil) + if err != nil { + return "" + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("anthropic-beta", "oauth-2025-04-20") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Debug("failed to fetch bootstrap for cache", + "subsystem", "claude", + "error", err, + ) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Debug("bootstrap returned non-200", + "subsystem", "claude", + "status", resp.StatusCode, + ) + return "" + } + + // Bootstrap responses are typically ~50-60KB + body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + if err != nil { + return "" + } + + return string(body) +} diff --git a/internal/providers/claude/config.go b/internal/providers/claude/config.go index c9474fb8..52ae6398 100644 --- a/internal/providers/claude/config.go +++ b/internal/providers/claude/config.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "path/filepath" + "time" + "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" ) @@ -157,11 +159,23 @@ func WriteCredentialsFile(cred *provider.Credential, stagingDir string) error { // the proxy at the network layer. Claude Code needs this file to exist // with valid structure to function, but the actual authentication is // handled transparently by the TLS-intercepting proxy. + // + // ExpiresAt handling: Setup-tokens are long-lived and don't carry an expiry, + // so cred.ExpiresAt is zero. The zero time.Time produces a large negative + // Unix millisecond value (-62135596800000, year 0001) which Claude Code + // interprets as an expired credential, causing "not logged in" and + // "API Usage Billing" in the status line. Use a far-future expiry instead. + expiresAtMs := cred.ExpiresAt.UnixMilli() + if cred.ExpiresAt.IsZero() { + expiresAtMs = time.Now().Add(365 * 24 * time.Hour).UnixMilli() + } creds := oauthCredentials{ ClaudeAiOauth: &oauthToken{ - AccessToken: ProxyInjectedPlaceholder, - ExpiresAt: cred.ExpiresAt.UnixMilli(), - Scopes: cred.Scopes, + AccessToken: credential.ClaudeOAuthPlaceholder, + ExpiresAt: expiresAtMs, + Scopes: cred.Scopes, + SubscriptionType: cred.Metadata["subscriptionType"], + RateLimitTier: cred.Metadata["rateLimitTier"], }, } diff --git a/internal/providers/claude/grant.go b/internal/providers/claude/grant.go index fb266565..642d0226 100644 --- a/internal/providers/claude/grant.go +++ b/internal/providers/claude/grant.go @@ -14,6 +14,7 @@ import ( "time" "github.com/creack/pty" + "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/log" "github.com/majorcontext/moat/internal/provider" ) @@ -223,6 +224,11 @@ func grantViaSetupToken(ctx context.Context) (*provider.Credential, error) { CreatedAt: time.Now(), } + // Cache bootstrap and subscription info while the host's OAuth token is + // fresh. The setup-token itself lacks scopes for bootstrap, but the host's + // short-lived token (from a recent Claude Code session) has them. + cacheBootstrapForCredential(cred) + fmt.Println("\nClaude credential acquired via setup-token.") fmt.Println("You can now run 'moat claude' to start Claude Code.") return cred, nil @@ -268,6 +274,8 @@ func grantViaExistingOAuthToken(ctx context.Context) (*provider.Credential, erro CreatedAt: time.Now(), } + cacheBootstrapForCredential(cred) + fmt.Println("\nClaude credential acquired.") fmt.Println("You can now run 'moat claude' to start Claude Code.") return cred, nil @@ -548,6 +556,19 @@ func grantViaExistingCreds(ctx context.Context) (*provider.Credential, error) { CreatedAt: time.Now(), } + // For imported credentials, the token itself has full scopes. + // Store subscription info directly from the host token, then cache bootstrap. + if token.SubscriptionType != "" || token.RateLimitTier != "" { + cred.Metadata = make(map[string]string) + if token.SubscriptionType != "" { + cred.Metadata["subscriptionType"] = token.SubscriptionType + } + if token.RateLimitTier != "" { + cred.Metadata["rateLimitTier"] = token.RateLimitTier + } + } + cacheBootstrapForCredential(cred) + fmt.Println("\nClaude Code credentials imported.") fmt.Println("You can now run 'moat claude' to start Claude Code.") if !expiresAt.IsZero() { @@ -561,6 +582,82 @@ func grantViaExistingCreds(ctx context.Context) (*provider.Credential, error) { return cred, nil } +// cacheBootstrapForCredential attempts to fetch and cache the /api/bootstrap +// response at grant time. This is the most reliable time to cache it because +// the host's short-lived OAuth token is likely fresh (the user just authenticated). +// +// The cached bootstrap is stored in credential metadata and persists across runs +// via the encrypted credential store. At run time, the proxy serves this cached +// response instead of the limited response that setup-tokens receive. +// +// Staleness: the cached bootstrap reflects account capabilities at grant time. +// If the user's subscription changes (e.g., downgrade), the cache becomes stale. +// Re-run "moat grant claude" to refresh. The proxy prefers the live response +// when it contains valid account data, so this only matters for setup-tokens +// where the live response always returns account:null. +func cacheBootstrapForCredential(cred *provider.Credential) { + if cred.Metadata == nil { + cred.Metadata = make(map[string]string) + } + + // Try to get a full-scope token for the bootstrap fetch. + // For imported credentials, the token itself has full scopes. + // For setup-tokens, we need the host's short-lived token. + var accessToken string + + // If the credential itself is a full OAuth token (not a setup-token), + // use it directly. Setup-tokens are long-lived and lack bootstrap scopes. + // Full OAuth tokens from "import existing creds" have the right scopes. + if credential.IsOAuthToken(cred.Token) && !cred.ExpiresAt.IsZero() && time.Until(cred.ExpiresAt) < 24*time.Hour { + // Short-lived token (< 24h) — likely a full OAuth token with scopes + accessToken = cred.Token + } + + // Fall back to host's credentials file (has full-scope short-lived token) + if accessToken == "" { + cc := &credential.ClaudeCodeCredentials{} + hostToken, err := cc.GetClaudeCodeCredentials() + if err == nil && hostToken.AccessToken != "" && !hostToken.IsExpired() { + accessToken = hostToken.AccessToken + // Also grab subscription info from host credentials + if hostToken.SubscriptionType != "" && cred.Metadata["subscriptionType"] == "" { + cred.Metadata["subscriptionType"] = hostToken.SubscriptionType + } + if hostToken.RateLimitTier != "" && cred.Metadata["rateLimitTier"] == "" { + cred.Metadata["rateLimitTier"] = hostToken.RateLimitTier + } + } + } + + if accessToken == "" { + log.Debug("no valid token available for bootstrap caching at grant time", + "subsystem", "claude", + ) + return + } + + // Fetch subscription info if not already present + if cred.Metadata["subscriptionType"] == "" { + subType, rateTier := fetchProfileSubscription(accessToken) + if subType != "" { + cred.Metadata["subscriptionType"] = subType + if rateTier != "" { + cred.Metadata["rateLimitTier"] = rateTier + } + } + } + + // Fetch full bootstrap response + bootstrap := fetchBootstrapResponse(accessToken) + if bootstrap != "" { + cred.Metadata[metaKeyCachedBootstrap] = bootstrap + log.Debug("cached bootstrap response at grant time", + "subsystem", "claude", + "bootstrap_len", len(bootstrap), + ) + } +} + // hasClaudeCodeCredentials checks if Claude Code credentials are available. func hasClaudeCodeCredentials() bool { _, err := getClaudeCodeCredentials() diff --git a/internal/providers/claude/oauth_workarounds.go b/internal/providers/claude/oauth_workarounds.go index c8d8e6b6..66b9b97d 100644 --- a/internal/providers/claude/oauth_workarounds.go +++ b/internal/providers/claude/oauth_workarounds.go @@ -2,12 +2,24 @@ package claude import ( "bytes" + "encoding/json" "io" "net/http" "github.com/majorcontext/moat/internal/log" ) +// oauthProfileResponse is the synthetic profile returned for 403'd OAuth +// profile requests. Subscription metadata is included when available so +// Claude Code can determine the account tier. +type oauthProfileResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + SubscriptionType string `json:"subscriptionType,omitempty"` + RateLimitTier string `json:"rateLimitTier,omitempty"` +} + // OAuthEndpointWorkarounds defines OAuth API endpoints that require response // transformation to work around scope limitations in long-lived tokens. // @@ -40,24 +52,25 @@ var OAuthEndpointWorkarounds = []string{ "/api/oauth/usage", // Usage statistics - for status line display } +// metaKeyCachedBootstrap is the metadata key for the cached /api/bootstrap response. +// This is pre-fetched using the host's full-scope OAuth token at grant or run setup. +const metaKeyCachedBootstrap = "cachedBootstrap" + // CreateOAuthEndpointTransformer creates a response transformer that handles // 403 errors on OAuth endpoints by returning empty success responses. -// -// The transformer: -// 1. Only acts on 403 status codes -// 2. Checks if the request path matches one of OAuthEndpointWorkarounds -// 3. Returns an empty but valid JSON response for that endpoint -// 4. Adds X-Moat-Transformed header for observability -// -// We don't check the response body because: -// - These are explicitly listed OAuth endpoints (not wildcards) -// - Any 403 on these endpoints is almost certainly a scope issue -// - Body checking requires handling gzip/compression which adds complexity -// - Transforming a non-scope 403 is harmless (returns empty data, no crash) -// -// Original 403 responses are still logged for debugging, but the client -// receives a success response to prevent crashes. func CreateOAuthEndpointTransformer() func(req, resp interface{}) (interface{}, bool) { + return CreateOAuthEndpointTransformerWithMeta(nil) +} + +// CreateOAuthEndpointTransformerWithMeta creates a response transformer that: +// 1. Returns a cached /api/bootstrap response (if available in metadata) +// 2. Handles 403 errors on OAuth profile/usage endpoints with synthetic responses +// +// The cached bootstrap is needed because setup-tokens lack the scopes required +// for /api/bootstrap to return account info and feature flags. Without proper +// bootstrap data, Claude Code cannot detect subscription capabilities like +// 1M context window. +func CreateOAuthEndpointTransformerWithMeta(meta map[string]string) func(req, resp interface{}) (interface{}, bool) { return func(reqInterface, respInterface interface{}) (interface{}, bool) { req, ok := reqInterface.(*http.Request) if !ok { @@ -69,6 +82,55 @@ func CreateOAuthEndpointTransformer() func(req, resp interface{}) (interface{}, return respInterface, false } + // Handle /api/bootstrap: prefer the real response when it contains + // account data (full-scope token), fall back to cached response when + // the real response is degraded (setup-token returns account:null). + if req.URL.Path == "/api/bootstrap" { + if cached, hasCached := meta[metaKeyCachedBootstrap]; hasCached && cached != "" { + if resp.StatusCode == http.StatusOK { + realBody, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + resp.Body.Close() + if err == nil && bootstrapHasAccount(realBody) { + log.Debug("bootstrap has account data, using real response", + "subsystem", "proxy") + //nolint:bodyclose // Response body will be closed by the HTTP handler + return &http.Response{ + StatusCode: resp.StatusCode, + Header: resp.Header, + Body: io.NopCloser(bytes.NewReader(realBody)), + ContentLength: int64(len(realBody)), + ProtoMajor: 1, + ProtoMinor: 1, + }, false + } + } else { + resp.Body.Close() + } + + // Real response is degraded (account:null or non-200) — use cache + log.Debug("response transformed", + "subsystem", "proxy", + "action", "transform", + "reason", "cached-bootstrap", + "original_status", resp.StatusCode, + "cached_len", len(cached)) + + body := []byte(cached) + //nolint:bodyclose // Response body will be closed by the HTTP handler + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Moat-Transformed": []string{"cached-bootstrap"}, + }, + Body: io.NopCloser(bytes.NewReader(body)), + ContentLength: int64(len(body)), + ProtoMajor: 1, + ProtoMinor: 1, + }, true + } + } + // Only transform 403 responses on OAuth endpoints if resp.StatusCode != http.StatusForbidden { return resp, false @@ -98,20 +160,26 @@ func CreateOAuthEndpointTransformer() func(req, resp interface{}) (interface{}, "endpoint", matchedEndpoint, "original_status", http.StatusForbidden) - // Return empty success response for this endpoint + // Return success response for this endpoint, including subscription metadata //nolint:bodyclose // Response body will be closed by the HTTP handler - return createEmptyOAuthResponse(matchedEndpoint), true + return createOAuthResponseWithMeta(matchedEndpoint, meta), true } } -// createEmptyOAuthResponse creates an empty but valid JSON response for an OAuth endpoint. -func createEmptyOAuthResponse(path string) *http.Response { +// createOAuthResponseWithMeta creates a JSON response for an OAuth endpoint, +// including subscription metadata when available. +func createOAuthResponseWithMeta(path string, meta map[string]string) *http.Response { var body []byte switch path { case "/api/oauth/profile": - // Empty profile - Claude Code will handle missing data gracefully - body = []byte(`{"id":"","email":"","name":""}`) + // Include subscription metadata so Claude Code can determine account tier. + profile := oauthProfileResponse{ID: "", Email: "", Name: ""} + if meta != nil { + profile.SubscriptionType = meta["subscriptionType"] + profile.RateLimitTier = meta["rateLimitTier"] + } + body, _ = json.Marshal(profile) case "/api/oauth/usage": // Empty usage - status line will show no usage data body = []byte(`{"usage":{}}`) @@ -132,3 +200,16 @@ func createEmptyOAuthResponse(path string) *http.Response { ProtoMinor: 1, } } + +// bootstrapHasAccount checks whether a bootstrap response body contains +// non-null account data. Setup-tokens return {"account":null,...} which +// means Claude Code can't detect subscription capabilities. +func bootstrapHasAccount(body []byte) bool { + var b struct { + Account json.RawMessage `json:"account"` + } + if err := json.Unmarshal(body, &b); err != nil { + return false + } + return len(b.Account) > 0 && string(b.Account) != "null" +} diff --git a/internal/providers/claude/provider.go b/internal/providers/claude/provider.go index 3d4a3bd3..ce42c71c 100644 --- a/internal/providers/claude/provider.go +++ b/internal/providers/claude/provider.go @@ -40,6 +40,12 @@ func (p *OAuthProvider) Name() string { // ConfigureProxy sets up proxy headers for OAuth tokens on the Anthropic API. func (p *OAuthProvider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *provider.Credential) { + // Enrich credential with cached bootstrap and subscription metadata from + // the host's OAuth token. This allows the proxy to return a full bootstrap + // response (with account info and feature flags) even when the setup-token + // can't fetch one itself. + enrichCredentialFromHost(cred) + // OAuth token - use Bearer auth with the real token proxy.SetCredentialWithGrant("api.anthropic.com", "Authorization", "Bearer "+cred.Token, "claude") @@ -53,17 +59,16 @@ func (p *OAuthProvider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *pro // not supported." proxy.AddExtraHeader("api.anthropic.com", "anthropic-beta", "oauth-2025-04-20") - // Register response transformer to handle 403s on OAuth endpoints - // that require scopes not available in long-lived tokens - proxy.AddResponseTransformer("api.anthropic.com", CreateOAuthEndpointTransformer()) + // Register response transformer to handle OAuth endpoint workarounds + // and cached bootstrap responses + proxy.AddResponseTransformer("api.anthropic.com", CreateOAuthEndpointTransformerWithMeta(cred.Metadata)) } // ContainerEnv returns environment variables for OAuth token injection. func (p *OAuthProvider) ContainerEnv(cred *provider.Credential) []string { - // Set CLAUDE_CODE_OAUTH_TOKEN with a placeholder. - // This tells Claude Code it's authenticated (skips login prompts). - // The real token is injected by the proxy at the network layer. - return []string{"CLAUDE_CODE_OAUTH_TOKEN=" + ProxyInjectedPlaceholder} + // OAuth credentials rely on .credentials.json instead of env var. + // See containerEnvForCredential() in agent.go for explanation. + return nil } // ContainerMounts returns mounts needed for Claude Code. @@ -136,10 +141,11 @@ func ConfigureBaseURLProxy(p provider.ProxyConfigurer, cred *provider.Credential switch cred.Provider { case "claude": + enrichCredentialFromHost(cred) p.SetCredentialWithGrant(host, "Authorization", "Bearer "+cred.Token, "claude") p.RemoveRequestHeader(host, "x-api-key") p.AddExtraHeader(host, "anthropic-beta", "oauth-2025-04-20") - p.AddResponseTransformer(host, CreateOAuthEndpointTransformer()) + p.AddResponseTransformer(host, CreateOAuthEndpointTransformerWithMeta(cred.Metadata)) default: // API key (anthropic or unknown provider) p.SetCredentialWithGrant(host, "x-api-key", cred.Token, "anthropic") diff --git a/internal/providers/claude/provider_test.go b/internal/providers/claude/provider_test.go index 0bf855fc..225302a0 100644 --- a/internal/providers/claude/provider_test.go +++ b/internal/providers/claude/provider_test.go @@ -1,8 +1,11 @@ package claude import ( + "bytes" "context" "encoding/json" + "io" + "net/http" "os" "path/filepath" "testing" @@ -215,13 +218,10 @@ func TestOAuthProvider_ContainerEnv(t *testing.T) { env := p.ContainerEnv(cred) - // OAuth should set CLAUDE_CODE_OAUTH_TOKEN with a placeholder - if len(env) != 1 { - t.Errorf("ContainerEnv() for OAuth returned %d vars, want 1", len(env)) - return - } - if env[0] != "CLAUDE_CODE_OAUTH_TOKEN="+ProxyInjectedPlaceholder { - t.Errorf("env[0] = %q, want %q", env[0], "CLAUDE_CODE_OAUTH_TOKEN="+ProxyInjectedPlaceholder) + // OAuth credentials rely on .credentials.json, not env vars. + // ContainerEnv should return nil for OAuth. + if len(env) != 0 { + t.Errorf("ContainerEnv() for OAuth returned %d vars, want 0", len(env)) } } @@ -249,11 +249,11 @@ func TestContainerEnvForCredential(t *testing.T) { } }) - t.Run("claude provider uses CLAUDE_CODE_OAUTH_TOKEN", func(t *testing.T) { + t.Run("claude provider returns nil (uses .credentials.json)", func(t *testing.T) { cred := &provider.Credential{Provider: "claude", Token: "sk-ant-oat01-abc123"} env := containerEnvForCredential(cred) - if len(env) != 1 || env[0] != "CLAUDE_CODE_OAUTH_TOKEN="+ProxyInjectedPlaceholder { - t.Errorf("env = %v, want CLAUDE_CODE_OAUTH_TOKEN placeholder", env) + if len(env) != 0 { + t.Errorf("env = %v, want empty (OAuth uses .credentials.json)", env) } }) @@ -651,8 +651,40 @@ func TestWriteCredentialsFile(t *testing.T) { if creds.ClaudeAiOauth == nil { t.Fatal("ClaudeAiOauth should be present") } - if creds.ClaudeAiOauth.AccessToken != ProxyInjectedPlaceholder { - t.Errorf("AccessToken = %q, want %q", creds.ClaudeAiOauth.AccessToken, ProxyInjectedPlaceholder) + if creds.ClaudeAiOauth.AccessToken != credential.ClaudeOAuthPlaceholder { + t.Errorf("AccessToken = %q, want %q", creds.ClaudeAiOauth.AccessToken, credential.ClaudeOAuthPlaceholder) + } + }) + + t.Run("zero ExpiresAt uses far-future expiry", func(t *testing.T) { + stagingDir := t.TempDir() + cred := &provider.Credential{ + Provider: "claude", + Token: "sk-ant-oat01-abc123", + // ExpiresAt intentionally zero — simulates setup-token grants + } + + err := WriteCredentialsFile(cred, stagingDir) + if err != nil { + t.Fatalf("WriteCredentialsFile() error = %v", err) + } + + data, err := os.ReadFile(filepath.Join(stagingDir, ".credentials.json")) + if err != nil { + t.Fatalf("failed to read credentials file: %v", err) + } + + var creds oauthCredentials + if err := json.Unmarshal(data, &creds); err != nil { + t.Fatalf("failed to parse credentials file: %v", err) + } + + if creds.ClaudeAiOauth == nil { + t.Fatal("ClaudeAiOauth should be present") + } + // expiresAt must be in the future, not the year 0001 + if creds.ClaudeAiOauth.ExpiresAt <= time.Now().UnixMilli() { + t.Errorf("ExpiresAt = %d, want future timestamp (got past/zero)", creds.ClaudeAiOauth.ExpiresAt) } }) @@ -1059,3 +1091,242 @@ func TestPrepareContainer_LocalMCPMinimalFields(t *testing.T) { } } } + +func TestBootstrapHasAccount(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {"null account", `{"account":null,"features":{}}`, false}, + {"missing account", `{"features":{}}`, false}, + {"empty object account", `{"account":{}}`, true}, + {"real account", `{"account":{"id":"abc","name":"Test"},"features":{}}`, true}, + {"invalid json", `not json`, false}, + {"empty body", ``, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := bootstrapHasAccount([]byte(tt.body)); got != tt.want { + t.Errorf("bootstrapHasAccount() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCreateOAuthEndpointTransformerWithMeta_Bootstrap(t *testing.T) { + cachedBootstrap := `{"account":{"id":"cached"},"features":{"max_context":1000000}}` + + makeReq := func(path string) *http.Request { + req, _ := http.NewRequest("GET", "https://api.anthropic.com"+path, nil) + return req + } + makeResp := func(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + ContentLength: int64(len(body)), + ProtoMajor: 1, + ProtoMinor: 1, + } + } + + t.Run("uses cached when real response has null account", func(t *testing.T) { + tf := CreateOAuthEndpointTransformerWithMeta(map[string]string{ + metaKeyCachedBootstrap: cachedBootstrap, + }) + inputResp := makeResp(200, `{"account":null}`) //nolint:bodyclose // test helper with NopCloser + resp, transformed := tf(makeReq("/api/bootstrap"), inputResp) + if !transformed { + t.Fatal("expected transformed=true for null-account response") + } + r := resp.(*http.Response) + body, _ := io.ReadAll(r.Body) + if string(body) != cachedBootstrap { + t.Errorf("body = %s, want cached bootstrap", body) + } + }) + + t.Run("prefers real response when it has account data", func(t *testing.T) { + realBody := `{"account":{"id":"real","plan":"max"},"features":{}}` + tf := CreateOAuthEndpointTransformerWithMeta(map[string]string{ + metaKeyCachedBootstrap: cachedBootstrap, + }) + inputResp := makeResp(200, realBody) //nolint:bodyclose // test helper with NopCloser + resp, transformed := tf(makeReq("/api/bootstrap"), inputResp) + if transformed { + t.Fatal("expected transformed=false when real response has account") + } + r := resp.(*http.Response) + body, _ := io.ReadAll(r.Body) + if string(body) != realBody { + t.Errorf("body = %s, want real response", body) + } + }) + + t.Run("uses cached when real response is non-200", func(t *testing.T) { + tf := CreateOAuthEndpointTransformerWithMeta(map[string]string{ + metaKeyCachedBootstrap: cachedBootstrap, + }) + inputResp := makeResp(403, `{"error":"forbidden"}`) //nolint:bodyclose // test helper with NopCloser + resp, transformed := tf(makeReq("/api/bootstrap"), inputResp) + if !transformed { + t.Fatal("expected transformed=true for non-200 response") + } + r := resp.(*http.Response) + body, _ := io.ReadAll(r.Body) + if string(body) != cachedBootstrap { + t.Errorf("body = %s, want cached bootstrap", body) + } + }) + + t.Run("no cache passes through unchanged", func(t *testing.T) { + tf := CreateOAuthEndpointTransformerWithMeta(nil) + realBody := `{"account":null}` + origResp := makeResp(200, realBody) //nolint:bodyclose // test helper with NopCloser + resp, transformed := tf(makeReq("/api/bootstrap"), origResp) + if transformed { + t.Fatal("expected transformed=false with no cached bootstrap") + } + if resp != origResp { + t.Error("expected original response to pass through") + } + }) +} + +func TestCreateOAuthResponseWithMeta(t *testing.T) { + readBody := func(r *http.Response) map[string]any { + t.Helper() + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("reading body: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("parsing body %q: %v", body, err) + } + return m + } + + t.Run("profile with subscription metadata", func(t *testing.T) { + resp := createOAuthResponseWithMeta("/api/oauth/profile", map[string]string{ //nolint:bodyclose // read by readBody + "subscriptionType": "claude_max", + "rateLimitTier": "tier4", + }) + m := readBody(resp) + if m["subscriptionType"] != "claude_max" { + t.Errorf("subscriptionType = %v, want claude_max", m["subscriptionType"]) + } + if m["rateLimitTier"] != "tier4" { + t.Errorf("rateLimitTier = %v, want tier4", m["rateLimitTier"]) + } + if resp.Header.Get("X-Moat-Transformed") != "oauth-scope-workaround" { + t.Error("missing X-Moat-Transformed header") + } + }) + + t.Run("profile without metadata omits subscription fields", func(t *testing.T) { + resp := createOAuthResponseWithMeta("/api/oauth/profile", nil) //nolint:bodyclose // read by readBody + m := readBody(resp) + if _, ok := m["subscriptionType"]; ok { + t.Error("subscriptionType should be omitted when empty") + } + if _, ok := m["rateLimitTier"]; ok { + t.Error("rateLimitTier should be omitted when empty") + } + // Core fields still present + if _, ok := m["id"]; !ok { + t.Error("id field should be present") + } + }) + + t.Run("usage endpoint", func(t *testing.T) { + resp := createOAuthResponseWithMeta("/api/oauth/usage", nil) //nolint:bodyclose // read by readBody + m := readBody(resp) + if _, ok := m["usage"]; !ok { + t.Error("usage field should be present") + } + }) + + t.Run("unknown endpoint", func(t *testing.T) { + resp := createOAuthResponseWithMeta("/api/unknown", nil) //nolint:bodyclose // read by readBody + m := readBody(resp) + if len(m) != 0 { + t.Errorf("expected empty object, got %v", m) + } + }) + + t.Run("profile JSON is valid with special characters", func(t *testing.T) { + resp := createOAuthResponseWithMeta("/api/oauth/profile", map[string]string{ //nolint:bodyclose // read by readBody + "subscriptionType": `type"with&chars`, + "rateLimitTier": "tier1", + }) + m := readBody(resp) + if m["subscriptionType"] != `type"with&chars` { + t.Errorf("special chars not preserved: %v", m["subscriptionType"]) + } + }) +} + +func TestOAuthEndpointTransformer_403Workarounds(t *testing.T) { + makeReq := func(path string) *http.Request { + req, _ := http.NewRequest("GET", "https://api.anthropic.com"+path, nil) + return req + } + makeResp := func(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader([]byte(body))), + ContentLength: int64(len(body)), + ProtoMajor: 1, + ProtoMinor: 1, + } + } + + t.Run("transforms 403 on profile endpoint", func(t *testing.T) { + tf := CreateOAuthEndpointTransformerWithMeta(map[string]string{ + "subscriptionType": "claude_teams", + }) + inputResp := makeResp(403, `{"error":"permission_error"}`) //nolint:bodyclose // test helper + resp, transformed := tf(makeReq("/api/oauth/profile"), inputResp) + if !transformed { + t.Fatal("expected transformed=true for 403 on profile") + } + r := resp.(*http.Response) + if r.StatusCode != 200 { + t.Errorf("status = %d, want 200", r.StatusCode) + } + body, _ := io.ReadAll(r.Body) + var m map[string]any + json.Unmarshal(body, &m) //nolint:errcheck + if m["subscriptionType"] != "claude_teams" { + t.Errorf("subscriptionType = %v, want claude_teams", m["subscriptionType"]) + } + }) + + t.Run("passes through non-403 on profile endpoint", func(t *testing.T) { + tf := CreateOAuthEndpointTransformerWithMeta(nil) + origResp := makeResp(200, `{"id":"user1"}`) //nolint:bodyclose // test helper + resp, transformed := tf(makeReq("/api/oauth/profile"), origResp) + if transformed { + t.Fatal("expected transformed=false for 200") + } + if resp != origResp { + t.Error("expected original response to pass through") + } + }) + + t.Run("passes through 403 on non-workaround endpoint", func(t *testing.T) { + tf := CreateOAuthEndpointTransformerWithMeta(nil) + origResp := makeResp(403, `{"error":"forbidden"}`) //nolint:bodyclose // test helper + resp, transformed := tf(makeReq("/api/messages"), origResp) + if transformed { + t.Fatal("expected transformed=false for non-workaround endpoint") + } + if resp != origResp { + t.Error("expected original response to pass through") + } + }) +} diff --git a/internal/run/manager.go b/internal/run/manager.go index 96f7b36c..012e6f82 100644 --- a/internal/run/manager.go +++ b/internal/run/manager.go @@ -821,8 +821,14 @@ func (m *Manager) Create(ctx context.Context, opts Options) (*Run, error) { hostAddr = m.runtime.GetHostAddress() runCtx.HostGateway = hostAddr - // Build RegisterRequest from the RunContext - regReq := buildRegisterRequest(runCtx, opts.Grants) + // Build RegisterRequest from the RunContext. + // Pass Anthropic credential metadata (e.g., cached bootstrap) so the + // daemon can include it in the transformer spec. + var anthropicMeta map[string]string + if anthropicCred != nil { + anthropicMeta = anthropicCred.Metadata + } + regReq := buildRegisterRequest(runCtx, opts.Grants, anthropicMeta) regReq.PolicyYAML = policyYAML regReq.PolicyRuleSets = policyRuleSets @@ -3741,7 +3747,7 @@ func ensureCACertOnlyDir(caDir, certOnlyDir string) error { // buildRegisterRequest converts a daemon.RunContext into a daemon.RegisterRequest // suitable for sending to the daemon API. -func buildRegisterRequest(rc *daemon.RunContext, grants []string) daemon.RegisterRequest { +func buildRegisterRequest(rc *daemon.RunContext, grants []string, anthropicMeta map[string]string) daemon.RegisterRequest { req := daemon.RegisterRequest{ RunID: rc.RunID, NetworkPolicy: rc.NetworkPolicy, @@ -3802,10 +3808,16 @@ func buildRegisterRequest(rc *daemon.RunContext, grants []string) daemon.Registe if _, hasTS := rc.TokenSubstitutions[host]; hasTS { kind = "response-scrub" } - req.ResponseTransformers = append(req.ResponseTransformers, daemon.TransformerSpec{ + spec := daemon.TransformerSpec{ Host: host, Kind: kind, - }) + } + // Include credential metadata (e.g., cached bootstrap response) so the + // daemon can reconstruct the transformer with full context. + if kind == "oauth-endpoint-workaround" && len(anthropicMeta) > 0 { + spec.Metadata = anthropicMeta + } + req.ResponseTransformers = append(req.ResponseTransformers, spec) } return req diff --git a/internal/run/manager_test.go b/internal/run/manager_test.go index 80d1dabd..81174af5 100644 --- a/internal/run/manager_test.go +++ b/internal/run/manager_test.go @@ -1372,7 +1372,7 @@ func TestBuildRegisterRequest_HostGateway(t *testing.T) { rc.HostGateway = "host.docker.internal" rc.AllowedHostPorts = []int{8288} - req := buildRegisterRequest(rc, nil) + req := buildRegisterRequest(rc, nil, nil) if req.HostGateway != "host.docker.internal" { t.Errorf("HostGateway = %q, want %q", req.HostGateway, "host.docker.internal") @@ -1385,7 +1385,7 @@ func TestBuildRegisterRequest_HostGateway(t *testing.T) { func TestBuildRegisterRequest_HostGatewayEmpty(t *testing.T) { rc := daemon.NewRunContext("run_test") - req := buildRegisterRequest(rc, nil) + req := buildRegisterRequest(rc, nil, nil) if req.HostGateway != "" { t.Errorf("HostGateway = %q, want empty", req.HostGateway)