diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e55c0c115..f5798d4de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed - Keychain: limit repeated cache ACL validation and memory growth while preserving recovery after temporary failures or external repairs (#3300, #3301). Thanks @IgorKhramtsov! - Grok: restore 0% usage for a validated active billing period with an omitted usage scalar, preserving unknown usage for incomplete responses (#3261, #3325). Thanks @sf-jin-ku and @olddonkey! +- Grok: keep malformed billing responses from turning unknown usage into 0%, while safely ignoring unknown byte fields (#3357). - Ollama: restore usage bars for monthly included credits and show matching history tabs while preserving legacy quota parsing and saved history (#3346). Thanks @haixing23! - Menu bar: keep status components and website links scoped to their provider when switching cached tabs, preventing Claude status from appearing under Grok or Codex (#3320). Thanks @gianpaj! - Usage & Spend: keep stalled or failed Codex catch-up paused until explicit Refresh, preventing background synchronization from restarting CPU-heavy scans (partial fix for #3316). Thanks @heyajulia! diff --git a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift index c244d14b21..8c0d4582ae 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokWebBillingFetcher.swift @@ -465,7 +465,7 @@ public enum GrokWebBillingFetcher { while index < bytes.count { let fieldStart = index - guard let key = Self.readVarint(bytes, index: &index), key != 0 else { + guard let key = Self.readVarint(bytes, index: &index), key >> 3 > 0, key >> 3 <= 536_870_911 else { scan.isComplete = false index = fieldStart + 1 continue @@ -498,7 +498,7 @@ public enum GrokWebBillingFetcher { } let start = index let end = index + Int(length) - if depth < 4 { + if depth < 4, Self.isKnownBillingMessage(path: fieldPath) { let nested = Self.scanProtobuf( Data(bytes[start.. Bool { + // The billing descriptor declares these messages; other length-delimited fields may be opaque bytes. + switch path { + case [1], + [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 12], + [1, 6, 1], [1, 6, 2], [1, 6, 3], [1, 8, 2], [1, 8, 3], + [1, 6, 3, 2], [1, 6, 3, 3]: + true + default: + false + } + } + private static func readVarint(_ bytes: [UInt8], index: inout Int) -> UInt64? { var value: UInt64 = 0 var shift: UInt64 = 0 while index < bytes.count, shift < 64 { let byte = bytes[index] index += 1 + if shift == 63, byte > 1 { return nil } value |= UInt64(byte & 0x7F) << shift if byte & 0x80 == 0 { return value diff --git a/Tests/CodexBarTests/GrokZeroUsageTests.swift b/Tests/CodexBarTests/GrokZeroUsageTests.swift index e13939ddf1..2c6573f0cc 100644 --- a/Tests/CodexBarTests/GrokZeroUsageTests.swift +++ b/Tests/CodexBarTests/GrokZeroUsageTests.swift @@ -45,19 +45,61 @@ struct GrokZeroUsageTests { #expect(try await Self.resolve(parsed).snapshot.usedPercent == 0) } + @Test(arguments: Self.malformedSuffixes) + func `malformed frames cannot turn unknown usage into zero`(suffix: Data) async throws { + let parsed = try GrokWebBillingFetcher.parseGRPCWebResponse(Self.payload(suffix: suffix), now: Self.now) + + #expect(!parsed.usedPercentIsImplicitZero) + #expect(try await Self.resolve(parsed).snapshot.usedPercent == nil) + } + @Test(arguments: [ - Data([0x00]), // Invalid field key. - Data([0x0D, 0x00]), // Truncated percentage. - Data([0x72, 0x04, 0x08]), // Truncated nested message. - Data([0x70, 0x80]), // Truncated varint. + Self.fixed64Field(tag: [0xF9, 0xFF, 0xFF, 0xFF, 0x0F]), // Highest valid field number. + Data([0x70]) + Self.varint(.max), // A valid UInt64.max scalar. ]) - func `malformed frames cannot turn unknown usage into zero`(suffix: Data) async throws { + func `valid unknown fields preserve implicit zero`(suffix: Data) async throws { let parsed = try GrokWebBillingFetcher.parseGRPCWebResponse(Self.payload(suffix: suffix), now: Self.now) + #expect(parsed.usedPercentIsImplicitZero) + #expect(try await Self.resolve(parsed).snapshot.usedPercent == 0) + } + + @Test(arguments: [false, true], Self.opaquePayloads) + func `unknown byte fields cannot invalidate or invent billing values`( + atRoot: Bool, bytes: Data) async throws + { + let opaqueField = Self.message(path: [14], contents: bytes) + let payload = atRoot ? Self.payload() + opaqueField : Self.payload(suffix: opaqueField) + let parsed = try GrokWebBillingFetcher.parseGRPCWebResponse(payload, now: Self.now) + + #expect(parsed.usedPercent == 0) + #expect(parsed.usedPercentIsImplicitZero) + #expect(!parsed.usedPercentIsWirePublished) + #expect(parsed.resetsAt == Date(timeIntervalSince1970: 1_789_000_000)) + #expect(try await Self.resolve(parsed).snapshot.usedPercent == 0) + } + + @Test(arguments: Self.billingMessagePaths) + func `malformed known messages still prevent implicit zero`(path: [UInt64]) async throws { + let malformedMessage = Self.message(path: path, contents: Self.fixed64Field(tag: [0x01])) + let parsed = try GrokWebBillingFetcher.parseGRPCWebResponse( + Self.payload() + malformedMessage, now: Self.now) + #expect(!parsed.usedPercentIsImplicitZero) #expect(try await Self.resolve(parsed).snapshot.usedPercent == nil) } + @Test + func `historical period timestamps remain readable without becoming current usage`() throws { + let timestamp = Data([0x08]) + Self.varint(1_789_000_000) + let payload = Self.message(path: [1, 6, 3, 3], contents: timestamp) + let parsed = try GrokWebBillingFetcher.parseGRPCWebResponse(payload, now: Self.now) + + #expect(parsed.resetsAt == Date(timeIntervalSince1970: 1_789_000_000)) + #expect(!parsed.usedPercentIsImplicitZero) + #expect(!parsed.usedPercentIsWirePublished) + } + @Test(arguments: [0, 3]) func `unknown period types cannot supply implicit zero`(periodType: UInt8) throws { #expect(throws: GrokWebBillingError.self) { @@ -81,6 +123,41 @@ struct GrokZeroUsageTests { } } + private static let malformedSuffixes: [Data] = [ + Data([0x00]), // Invalid field key. + Self.fixed64Field(tag: [0x01]), // Invalid field zero with a complete fixed64 value. + Data([0x02, 0x00]), // Invalid field zero with an empty length-delimited value. + Self.fixed64Field(tag: [0x81, 0x80, 0x80, 0x80, 0x10]), // Field number exceeds 29 bits. + // Overflowing varint must not truncate to a valid fixed64 tag. + Self.fixed64Field(tag: [0x89, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x02]), + Data([0x0D, 0x00]), // Truncated percentage. + Data([0x72, 0x04, 0x08]), // Truncated unknown length-delimited field. + Data([0x70, 0x80]), // Truncated varint. + ] + + private static let opaquePayloads: [Data] = [ + Self.fixed64Field(tag: [0x01]), // Valid opaque bytes, not a valid protobuf message. + Data([0x0D, 0x00, 0x00, 0x14, 0x42]), // Looks like a published 37% usage field. + Data([0x08]) + Self.varint(1_788_500_000), // Looks like an earlier future reset. + ] + + private static let billingMessagePaths: [[UInt64]] = [ + [1], + [1, 2], [1, 3], [1, 4], [1, 5], [1, 6], [1, 7], [1, 8], [1, 12], + [1, 6, 1], [1, 6, 2], [1, 6, 3], [1, 8, 2], [1, 8, 3], + [1, 6, 3, 2], [1, 6, 3, 3], + ] + + private static func message(path: [UInt64], contents: Data) -> Data { + path.reversed().reduce(contents) { payload, field in + Self.varint((field << 3) | 2) + Self.varint(UInt64(payload.count)) + payload + } + } + + private static func fixed64Field(tag: [UInt8]) -> Data { + Data(tag + [UInt8](repeating: 0, count: 8)) + } + private static let now = Date(timeIntervalSince1970: 1_788_000_000) private static let proxyReset = Date(timeIntervalSince1970: 1_900_000_000) private static let credentials = GrokCredentials( diff --git a/docs/grok.md b/docs/grok.md index 401234e0c4..6d4b6dcf34 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -73,6 +73,10 @@ The grok.com billing gRPC-web endpoint remains a best-effort fallback. from wire-published percentages; a bare inferred zero, historical-only period, or malformed response cannot replace unknown proxy usage. Proxy reset and plan metadata remain authoritative when the zero is adopted. + Invalid protobuf field numbers and overflowing varints prevent that response + from qualifying as complete. Only schema-declared messages are recursively + decoded; valid unknown length-delimited fields are skipped as opaque data, + so their contents cannot invalidate the response or invent usage/reset values. Grok's public web client also reads its omitted proto3 scalar as zero, and its [billing descriptor](https://cdn.grok.com/_next/static/chunks/32g78bk5hhe1q.js) declares `credit_usage_percent` as an implicit-presence float (checked