Skip to content
Merged
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 `sk-ant-oat01-*` placeholder token; no `CLAUDE_CODE_OAUTH_TOKEN` env var is set.
- **`anthropic` grant**: `x-api-key: <key>`. Container receives `ANTHROPIC_API_KEY` placeholder.

### Refresh behavior
Expand Down
14 changes: 10 additions & 4 deletions internal/credential/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions internal/providers/claude/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
7 changes: 6 additions & 1 deletion internal/providers/claude/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"time"

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

Expand Down Expand Up @@ -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
Expand All @@ -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,
},
Expand Down
7 changes: 3 additions & 4 deletions internal/providers/claude/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 8 additions & 12 deletions internal/providers/claude/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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)
}
})

Expand Down Expand Up @@ -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)
}
})

Expand Down
12 changes: 12 additions & 0 deletions internal/run/edge_cases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <store>/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",
Expand Down
Loading