diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index 12708379d..32f831557 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -100,20 +100,36 @@ public struct GrokStatusProbe: Sendable { let localSummary = GrokLocalSessionScanner.summarize(env: env) let cliVersion = Self.detectVersion(env: env) + // Grok session tokens expire after ~7 days. An expired record on disk + // must be treated like a missing credential when deciding whether to + // mask the RPC auth error: otherwise we render a snapshot with stale + // identity and no `grok login` hint while billing silently 401s. + let activeCredentials = credentials.flatMap { $0.isExpired ? nil : $0 } + // `localSummary` is *not* currently projected into a visible RateWindow or // identity field, so a stale `~/.grok/sessions/` directory must not // suppress the auth-required hint. Only swallow the RPC error when we - // actually have something rendrable for the user — credentials or a - // billing response. - if billing == nil, credentials == nil { + // actually have something renderable for the user — fresh credentials + // or a billing response. + if billing == nil, activeCredentials == nil { throw rpcError ?? GrokRPCError.notAuthenticated } return GrokUsageSnapshot( billing: billing, - credentials: credentials, + credentials: Self.credentialsForSnapshot(credentials: credentials, billing: billing), localSummary: localSummary, cliVersion: cliVersion, updatedAt: Date()) } + + static func credentialsForSnapshot( + credentials: GrokCredentials?, + billing: GrokBillingResponse?) -> GrokCredentials? + { + // If billing succeeded, the CLI accepted/refreshed auth and the local + // identity is still useful even when the persisted expires_at is stale. + if billing != nil { return credentials } + return credentials.flatMap { $0.isExpired ? nil : $0 } + } } diff --git a/Tests/CodexBarTests/GrokAuthTests.swift b/Tests/CodexBarTests/GrokAuthTests.swift index c9d23a1d5..367b9140a 100644 --- a/Tests/CodexBarTests/GrokAuthTests.swift +++ b/Tests/CodexBarTests/GrokAuthTests.swift @@ -71,6 +71,65 @@ struct GrokAuthTests { } } + @Test + func `isExpired reflects past expires_at`() throws { + // Past expiry + let pastJson = #""" + { + "https://auth.x.ai::client": { + "key": "stale-token", + "expires_at": "2020-01-01T00:00:00Z" + } + } + """# + let past = try GrokCredentialsStore.parse(data: Data(pastJson.utf8)) + #expect(past.isExpired == true) + + // Future expiry + let futureJson = #""" + { + "https://auth.x.ai::client": { + "key": "fresh-token", + "expires_at": "2099-01-01T00:00:00Z" + } + } + """# + let future = try GrokCredentialsStore.parse(data: Data(futureJson.utf8)) + #expect(future.isExpired == false) + + // Missing expires_at — treated as non-expired so we never spuriously lock + // out clients whose auth.json shape predates this field. + let noExpiryJson = #""" + { + "https://auth.x.ai::client": { + "key": "ageless-token" + } + } + """# + let noExpiry = try GrokCredentialsStore.parse(data: Data(noExpiryJson.utf8)) + #expect(noExpiry.isExpired == false) + } + + @Test + func `expired credentials are preserved when billing succeeds`() throws { + let pastJson = #""" + { + "https://auth.x.ai::client": { + "key": "stale-token", + "email": "grok@example.com", + "team_id": "team_123", + "expires_at": "2020-01-01T00:00:00Z" + } + } + """# + let expired = try GrokCredentialsStore.parse(data: Data(pastJson.utf8)) + let billing = try JSONDecoder().decode(GrokBillingResponse.self, from: Data(#"{}"#.utf8)) + + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: nil) == nil) + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: billing)? + .email == "grok@example.com") + } + @Test func `falls back to legacy when OIDC entry has no key`() throws { // A stale/partial OIDC record must not shadow a healthy legacy session. diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index f1551aad5..a6bd97f58 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1200,6 +1200,7 @@ struct SettingsStoreTests { .commandcode, .stepfun, .bedrock, + .grok, ]) // Move one provider; ensure it's persisted across instances.