diff --git a/internal/providers/claude/config.go b/internal/providers/claude/config.go index c9474fb8..223929e3 100644 --- a/internal/providers/claude/config.go +++ b/internal/providers/claude/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "time" "github.com/majorcontext/moat/internal/provider" ) @@ -157,10 +158,20 @@ 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-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 + // as an expired credential — the status line shows "not logged in" and + // "API Usage Billing". Substitute a far-future expiry in that case. + 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(), + ExpiresAt: expiresAtMs, Scopes: cred.Scopes, }, } diff --git a/internal/providers/claude/provider_test.go b/internal/providers/claude/provider_test.go index 0bf855fc..b0ceb023 100644 --- a/internal/providers/claude/provider_test.go +++ b/internal/providers/claude/provider_test.go @@ -656,6 +656,37 @@ func TestWriteCredentialsFile(t *testing.T) { } }) + 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") + } + if creds.ClaudeAiOauth.ExpiresAt <= time.Now().UnixMilli() { + t.Errorf("ExpiresAt = %d, want future timestamp (got past/zero)", creds.ClaudeAiOauth.ExpiresAt) + } + }) + t.Run("anthropic provider does not create file", func(t *testing.T) { stagingDir := t.TempDir() cred := &provider.Credential{