From bfe9477e16309bcccc90cc14dfb320e248c96bfe Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Mon, 18 May 2026 15:38:50 +0000 Subject: [PATCH 1/2] fix(claude): use sk-ant-oat01 placeholder, drop CLAUDE_CODE_OAUTH_TOKEN env Claude Code checks the sk-ant-oat prefix to decide whether the session is OAuth-authenticated. With the previous CLAUDE_CODE_OAUTH_TOKEN=moat-proxy-injected env var, Claude Code did not recognize the session as OAuth and skipped the OAuth code paths that determine account capabilities (e.g. 1M context window availability for subscription accounts). Replace the env var with a .credentials.json containing an sk-ant-oat01-* placeholder. The real token is still injected by the proxy at the network layer; the placeholder never reaches Anthropic's servers. - Add credential.ClaudeOAuthPlaceholder constant - containerEnvForCredential and OAuthProvider.ContainerEnv return nil for the "claude" provider so Claude Code reads .credentials.json instead of the env var - WriteCredentialsFile writes the new placeholder --- docs/content/reference/04-grants.md | 2 +- internal/credential/provider.go | 14 ++++++++++---- internal/providers/claude/agent.go | 13 ++++++++----- internal/providers/claude/config.go | 7 ++++++- internal/providers/claude/provider.go | 7 +++---- internal/providers/claude/provider_test.go | 20 ++++++++------------ 6 files changed, 36 insertions(+), 27 deletions(-) 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) } }) From 7fb4f6714e2af7a40ae7e26c2cee71253e645dff Mon Sep 17 00:00:00 2001 From: Andrii Bezzub Date: Mon, 18 May 2026 16:10:58 +0000 Subject: [PATCH 2/2] test(run): wait for monitor goroutine before TempDir cleanup TestStartNoFirewallWhenNotEnabled was flaky on CI. The test installs a Store backed by t.TempDir() on the Run, but never stops the monitor goroutine that Start() spawns. captureLogs inside monitorContainerExit opens /logs.jsonl, racing with the testing framework's TempDir cleanup: depending on scheduling the test failed with either "directory not empty" (cleanup interleaved with the goroutine's file creation) or warning logs about "no such file or directory" (cleanup finished first). Register a t.Cleanup after the store t.TempDir() call. Because Cleanup runs LIFO, this fires before the temp dir is removed: monitorCancel unblocks WaitContainer, monitorWg.Wait blocks until captureLogs and cleanupResources finish, and only then does t.TempDir() proceed. Reproduced 6/20 failures without the fix; 100 race-enabled runs pass after it. --- internal/run/edge_cases_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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",