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
2 changes: 1 addition & 1 deletion docs/content/reference/04-grants.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Stored as `anthropic.enc`.

The proxy injects credentials for requests to `api.anthropic.com`:

- **`claude` grant**: `Authorization: Bearer <token>` with OAuth beta flag. Container receives `CLAUDE_CODE_OAUTH_TOKEN` placeholder.
- **`claude` grant**: `Authorization: Bearer <token>` with OAuth beta flag. Container receives a `.credentials.json` with an OAuth placeholder token and subscription metadata.
- **`anthropic` grant**: `x-api-key: <key>`. Container receives `ANTHROPIC_API_KEY` placeholder.

### Refresh behavior
Expand Down
9 changes: 9 additions & 0 deletions internal/credential/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions internal/daemon/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/runcontext.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions internal/daemon/transformers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 172 additions & 6 deletions internal/providers/claude/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
20 changes: 17 additions & 3 deletions internal/providers/claude/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"fmt"
"os"
"path/filepath"
"time"

"github.com/majorcontext/moat/internal/credential"
"github.com/majorcontext/moat/internal/provider"
)

Expand Down Expand Up @@ -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"],
},
}

Expand Down
Loading
Loading