diff --git a/docs/content/reference/04-grants.md b/docs/content/reference/04-grants.md index 5a46a90a..e62714d6 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 `sk-ant-oat01-*` placeholder token; no `CLAUDE_CODE_OAUTH_TOKEN` env var is set. - **`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..292f9522 100644 --- a/internal/credential/provider.go +++ b/internal/credential/provider.go @@ -41,12 +41,18 @@ const GitHubTokenPlaceholder = "ghp_moatProxyInjectedPlaceholder000000000000" // Some tools validate the API key format locally before making requests. // Using a valid-looking placeholder bypasses these checks while still allowing // the proxy to inject the real key at the network layer. -// -// Note: Anthropic API keys start with `sk-ant-api` while OAuth tokens start -// with `sk-ant-oat` - we may discover we need an OAuth placeholder and smarter -// logic later. const AnthropicAPIKeyPlaceholder = "sk-ant-api03-moatProxyInjectedPlaceholder0000000000000000000000000000000000000000000000000000000000000000000" +// ClaudeOAuthPlaceholder is a placeholder that looks like a valid Claude Code +// OAuth token. Claude Code checks the sk-ant-oat prefix to decide whether the +// session is OAuth-authenticated. Without this prefix, it may skip OAuth-specific +// code paths that determine account capabilities (for example, 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" + // GeminiAPIKeyPlaceholder is a placeholder that looks like a valid Gemini API // key. // Some tools validate the API key format locally before making requests. diff --git a/internal/providers/claude/agent.go b/internal/providers/claude/agent.go index 594b2b7b..c4e416f8 100644 --- a/internal/providers/claude/agent.go +++ b/internal/providers/claude/agent.go @@ -134,16 +134,19 @@ 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 — the real credential is +// injected by the proxy at the network layer. OAuth credentials (provider +// "claude") rely on .credentials.json instead of an environment variable: a +// non-OAuth-looking value in CLAUDE_CODE_OAUTH_TOKEN can cause Claude Code to +// skip OAuth code paths that determine account capabilities such as the 1M +// context window. func containerEnvForCredential(cred *provider.Credential) []string { if cred == nil { return nil } if cred.Provider == "claude" { - return []string{"CLAUDE_CODE_OAUTH_TOKEN=" + ProxyInjectedPlaceholder} + return nil } return []string{"ANTHROPIC_API_KEY=" + ProxyInjectedPlaceholder} } diff --git a/internal/providers/claude/config.go b/internal/providers/claude/config.go index 223929e3..c6dc9671 100644 --- a/internal/providers/claude/config.go +++ b/internal/providers/claude/config.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" ) @@ -159,6 +160,10 @@ func WriteCredentialsFile(cred *provider.Credential, stagingDir string) error { // with valid structure to function, but the actual authentication is // handled transparently by the TLS-intercepting proxy. // + // The placeholder uses the sk-ant-oat01-* prefix so Claude Code recognizes + // the session as OAuth-authenticated and takes the OAuth code path that + // determines account capabilities. + // // ExpiresAt handling: Setup-token grants are long-lived and don't carry // an expiry, so cred.ExpiresAt is the zero time.Time. UnixMilli() on the // zero value returns -62135596800000 (year 0001), which Claude Code reads @@ -170,7 +175,7 @@ func WriteCredentialsFile(cred *provider.Credential, stagingDir string) error { } creds := oauthCredentials{ ClaudeAiOauth: &oauthToken{ - AccessToken: ProxyInjectedPlaceholder, + AccessToken: credential.ClaudeOAuthPlaceholder, ExpiresAt: expiresAtMs, Scopes: cred.Scopes, }, diff --git a/internal/providers/claude/provider.go b/internal/providers/claude/provider.go index 3d4a3bd3..be478555 100644 --- a/internal/providers/claude/provider.go +++ b/internal/providers/claude/provider.go @@ -59,11 +59,10 @@ func (p *OAuthProvider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *pro } // ContainerEnv returns environment variables for OAuth token injection. +// OAuth credentials rely on .credentials.json instead of an env var. See +// containerEnvForCredential in agent.go for the reason. 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} + return nil } // ContainerMounts returns mounts needed for Claude Code. diff --git a/internal/providers/claude/provider_test.go b/internal/providers/claude/provider_test.go index b0ceb023..6b8c98e3 100644 --- a/internal/providers/claude/provider_test.go +++ b/internal/providers/claude/provider_test.go @@ -215,13 +215,9 @@ 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. + if len(env) != 0 { + t.Errorf("ContainerEnv() for OAuth returned %d vars, want 0", len(env)) } } @@ -249,11 +245,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 +647,8 @@ 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) } }) diff --git a/internal/run/edge_cases_test.go b/internal/run/edge_cases_test.go index 902965b5..5496379a 100644 --- a/internal/run/edge_cases_test.go +++ b/internal/run/edge_cases_test.go @@ -280,6 +280,18 @@ func TestStartNoFirewallWhenNotEnabled(t *testing.T) { t.Fatal(err) } + // Stop the background monitor before t.TempDir() cleanup removes the + // store directory. captureLogs in monitorContainerExit opens + // /logs.jsonl, and without this the goroutine wakes after + // the directory has started being removed, causing flaky + // "directory not empty" / "no such file or directory" failures. + // t.Cleanup is LIFO, and this is registered after the t.TempDir() above, + // so it runs before the temp dir removal. + t.Cleanup(func() { + m.monitorCancel() + m.monitorWg.Wait() + }) + r := &Run{ ID: "run_no_fw", Name: "no-fw",