From 458004ea6c4bc2d30055b4b5e31941eb9c27b6cc Mon Sep 17 00:00:00 2001 From: enieuwy <121954036+enieuwy@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:34:38 +0800 Subject: [PATCH 1/2] Muse Code: read omitted quotas from dev.meta.ai with an opt-in browser session --- CHANGELOG.md | 1 + .../Muse/MuseProviderImplementation.swift | 47 ++++- .../Providers/Muse/MuseSettingsStore.swift | 18 ++ .../SettingsStore+MenuObservation.swift | 2 + Sources/CodexBar/UsageStore+Logging.swift | 1 + .../Muse/MuseProviderDescriptor.swift | 40 +++- .../Providers/Muse/MuseProviderSettings.swift | 22 ++ .../CodexBarCore/Resources/Plugins/muse.js | 146 +++++++++++--- .../CodexBarCore/Resources/Plugins/muse.ts | 94 ++++++++- Tests/CodexBarTests/MusePluginTests.swift | 189 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 4 +- docs/muse.md | 14 +- docs/providers.md | 2 +- 13 files changed, 539 insertions(+), 41 deletions(-) create mode 100644 Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 190a79efa5..3e0924e9b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - OpenCode Go: include recorded local token counts in daily and per-model history without inventing costs or treating missing counts as zero (#3995). Thanks @Yuxin-Qiao! - Security: preserve browser-cookie denial across restarts and CLI configuration, and stage credential writes privately before atomic replacement (reported in #3986). Thanks @bo-vavrik! - Provider plugins: update bundled QuickJS-NG to 0.17.0 with upstream memory-safety and numeric-correctness fixes (#3987). Thanks @bo-vavrik! +- Muse Code: when the login response omits subscription quotas (Meta leaves them out while the 5-hour window is idle), read the 5-hour and weekly quotas from the `dev.meta.ai` usage page with an opt-in browser session for the same account (Off by default; Automatic reads Chrome or Firefox). - OpenRouter: explain the required API key field instead of reporting no available fetch strategy, and clarify where regular and Management keys belong (#3966, #3969). Thanks @harjothkhara! - Antigravity: let menu-bar layouts pin Gemini and Claude/GPT weekly percentages separately when each allowance is available (#3394). Thanks @ksuchoi216! - Antigravity: preserve decoded local history as a marked lower bound when later databases exhaust the schema budget, while retaining hard scan limits (#3957). Thanks @Niclassslua! diff --git a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift index 5916f240ee..acc3af7fe9 100644 --- a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift @@ -12,13 +12,56 @@ struct MuseProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_: SettingsStore) {} + /// The shared cookie snapshot defaults to Automatic; Muse reads a browser session only after an explicit choice. + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + ProviderSettingsSnapshotContribution( + MuseProviderSettings( + cookieSource: context.settings.museCookieSource, + manualCookieHeader: context.settings.museCookieHeader), + for: MuseProviderSettingsKey.self) + } + @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { MuseCredentials.hasLogin(environment: context.environment) } + /// The dev.meta.ai session only fills quotas that the Muse login response leaves out. + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [ + ProviderCookieSourceUI.picker( + id: "muse-cookie-source", + context: context, + source: \.museCookieSource, + allowsOff: true, + subtitles: { + .init( + auto: L("Automatically imports browser cookies."), + manual: L("Paste a Cookie header or cURL capture from %@.", "dev.meta.ai"), + off: L("%@ cookies are disabled.", "Muse Code")) + }), + ] + } + @MainActor - func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [] + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "muse-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: llama_dev_sess=...", + binding: context.binding(\.museCookieHeader), + actions: [ + ProviderSettingsActionDescriptor.openURL( + id: "muse-open-usage", + title: "Open dev.meta.ai", + url: URL(string: "https://dev.meta.ai/usage")), + ], + isVisible: { context.settings.museCookieSource == .manual }), + ] } } diff --git a/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift new file mode 100644 index 0000000000..8f394c9268 --- /dev/null +++ b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift @@ -0,0 +1,18 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var museCookieHeader: String { + get { self[providerConfig: .muse, field: .cookieHeader] } + set { self[providerConfig: .muse, field: .cookieHeader] = newValue } + } + + var museCookieSource: ProviderCookieSource { + // Browser sessions are opt-in; a pasted header without an explicit source means Manual, as in the CLI. + get { + let header = self.providerConfig(for: .muse)?.sanitizedCookieHeader + return self.resolvedCookieSource(provider: .muse, fallback: header == nil ? .off : .manual) + } + set { self.setCookieSource(newValue, provider: .muse) } + } +} diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index b04e442c8d..cebcaaa5ee 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -98,6 +98,7 @@ extension SettingsStore { _ = self.augmentCookieSource _ = self.ampCookieSource _ = self.t3ChatCookieSource + _ = self.museCookieSource _ = self.zoomMateCookieSource _ = self.ollamaCookieSource _ = self.mergeIcons @@ -124,6 +125,7 @@ extension SettingsStore { _ = self.augmentCookieHeader _ = self.ampCookieHeader _ = self.t3ChatCookieHeader + _ = self.museCookieHeader _ = self.zoomMateCookieHeader _ = self.ollamaCookieHeader _ = self.copilotAPIToken diff --git a/Sources/CodexBar/UsageStore+Logging.swift b/Sources/CodexBar/UsageStore+Logging.swift index 87e801fbd6..2a3464d5e4 100644 --- a/Sources/CodexBar/UsageStore+Logging.swift +++ b/Sources/CodexBar/UsageStore+Logging.swift @@ -17,6 +17,7 @@ extension UsageStore { "augmentCookieSource": self.settings.augmentCookieSource.rawValue, "ampCookieSource": self.settings.ampCookieSource.rawValue, "t3ChatCookieSource": self.settings.t3ChatCookieSource.rawValue, + "museCookieSource": self.settings.museCookieSource.rawValue, "ollamaCookieSource": self.settings.ollamaCookieSource.rawValue, "openAIWebAccess": self.settings.openAIWebAccessEnabled ? "1" : "0", "openAIWebBatterySaver": self.settings.openAIWebBatterySaverEnabled ? "1" : "0", diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift index a2ea7d97f6..f980d86a93 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -15,9 +15,30 @@ public enum MuseProviderDescriptor { "Muse Code login not found. Run `muse login`, then refresh CodexBar." }) + /// Chrome needs a no-UI Safe Storage grant and Firefox needs none; Safari's store can require Full Disk Access. + private static var browserCookieOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome, .firefox] + #else + nil + #endif + } + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .muse, + settingsSection: .init( + MuseProviderSettingsKey.self, + cookieSettings: { settings in + .init(cookieSource: settings.cookieSource, manualCookieHeader: settings.manualCookieHeader) + }, + credentialSettings: { context in + // Browser sessions are opt-in for Muse: without an explicit source or pasted header, stay Off. + let header = context.config?.sanitizedCookieHeader + return MuseProviderSettings( + cookieSource: context.config?.cookieSource ?? (header == nil ? .off : .manual), + manualCookieHeader: header) + }), credentials: self.credentials, metadata: ProviderMetadata( id: .muse, @@ -32,6 +53,7 @@ public enum MuseProviderDescriptor { cliName: "muse", defaultEnabled: false, widgetSelectable: false, + browserCookieOrder: self.browserCookieOrder, dashboardURL: "https://dev.meta.ai", subscriptionDashboardURL: "https://dev.meta.ai", statusPageURL: nil), @@ -75,9 +97,21 @@ struct MuseOAuthFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let token = try MuseCredentials.accessToken(environment: context.env) - let runtime = try ProviderPluginRuntime(bundledPlugin: "muse") - let snapshot = try await runtime.fetchUsage(secrets: ["MUSE_DEVICE_TOKEN": token]) - return self.makeResult(usage: snapshot, sourceLabel: "oauth") + // The key request (15 s) and the bounded dev.meta.ai fallback (4 × 8 s) fit one 60 s deadline. + let runtime = try ProviderPluginRuntime(bundledPlugin: "muse", timeout: 60) + let cookies = ProviderPluginCookieBroker( + provider: .muse, domains: runtime.manifest.cookieDomains, context: context) + // Reading the browser session is opt-in: an unconfigured Muse provider keeps its CLI-token-only behavior. + let cookieSource = context.settings?[MuseProviderSettingsKey.self]?.cookieSource ?? .off + let result = try await runtime.fetchResult( + secrets: ["MUSE_DEVICE_TOKEN": token], + sourceMode: context.sourceMode, + cookieSource: cookieSource, + cookieInvalidator: { cookies.rejectCookie(domain: $0) }, + cookieSessionResolver: { try cookies.nextSession(domain: $0, cachedOnly: $1) }, + cookieSessionInvalidator: { cookies.rejectCookie(domain: $0, id: $1) }, + cookieResolver: { _, domain in try cookies.cookieHeader(domain: domain) }) + return self.makeResult(usage: result.usage, sourceLabel: result.sourceLabel ?? "oauth") } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift new file mode 100644 index 0000000000..416184a1bd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct MuseProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } +} + +public enum MuseProviderSettingsKey: ProviderSettingsSectionKey { + public static let providerID = ProviderInstanceID.muse + public typealias Section = MuseProviderSettings +} + +extension ProviderSettingsSnapshot { + public static func make(muse: MuseProviderSettings?) -> Self { + self.make(muse, for: MuseProviderSettingsKey.self) + } +} diff --git a/Sources/CodexBarCore/Resources/Plugins/muse.js b/Sources/CodexBarCore/Resources/Plugins/muse.js index 9947e68f3c..13d3e221a2 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.js +++ b/Sources/CodexBarCore/Resources/Plugins/muse.js @@ -26,34 +26,43 @@ function _optionalChain(ops) { } return value; } +async function _asyncOptionalChain(ops) { + let lastAccessLHS = undefined; + let value = ops[0]; + let i = 1; + while (i < ops.length) { + const op = ops[i]; + const fn = ops[i + 1]; + i += 2; + if ((op === "optionalAccess" || op === "optionalCall") && value == null) { + return undefined; + } + if (op === "access" || op === "optionalAccess") { + lastAccessLHS = value; + value = await fn(value); + } else if (op === "call" || op === "optionalCall") { + value = await fn((...args) => value.call(lastAccessLHS, ...args)); + lastAccessLHS = undefined; + } + } + return value; +} defineProvider({ id: "muse", name: "Muse Code", - endpoints: ["https://api.meta.ai"], - auth: { type: "bearer", secret: "MUSE_DEVICE_TOKEN" }, + endpoints: ["https://api.meta.ai", "https://dev.meta.ai"], settings: [{ key: "MUSE_DEVICE_TOKEN", title: "Muse login", type: "secure" }], - capabilities: ["http-status"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["dev.meta.ai"], async fetchUsage(ctx) { - if ( - !_optionalChain([ - ctx, - "access", - (_) => _.settings, - "access", - (_2) => _2.getSecret, - "call", - (_3) => _3("MUSE_DEVICE_TOKEN"), - "optionalAccess", - (_4) => _4.startsWith, - "call", - (_5) => _5("dca:"), - ]) - ) { + const token = ctx.settings.getSecret("MUSE_DEVICE_TOKEN"); + if (!_optionalChain([token, "optionalAccess", (_) => _.startsWith, "call", (_2) => _2("dca:")])) { throw ctx.fail.authenticationExpired("Muse Code requires a device-code login. Run `muse login` again."); } + // Keep the device credential off dev.meta.ai requests, which authenticate with the browser session. const response = await ctx.http.post("https://api.meta.ai/muse-code/key", { body: {}, - headers: { "x-api-version": "1.0.0", "User-Agent": "CodexBar" }, + headers: { Authorization: `Bearer ${token}`, "x-api-version": "1.0.0", "User-Agent": "CodexBar" }, timeoutSeconds: 15, }); if (response.status === 401 || response.status === 403) { @@ -110,9 +119,100 @@ defineProvider({ identity: { email: text(root.user_email, "user_email"), loginMethod: _nullishCoalesce(plan, () => "Muse login") }, dataConfidence: "unknown", }; + const percentLabel = (value) => `${ctx.format.number(value, { maximumFractionDigits: 0 })}%`; + async function webQuota() { + if (ctx.browser.availability("dev.meta.ai") === "off") return undefined; + try { + const headers = { Cookie: await ctx.browser.cookieHeader("dev.meta.ai"), "User-Agent": "CodexBar" }; + const get = async (path) => { + const response = await ctx.http.get(`https://dev.meta.ai${path}`, { headers, timeoutSeconds: 8 }); + if (response.status === 401 || response.status === 403) { + ctx.browser.rejectCookie("dev.meta.ai"); + return undefined; + } + if (response.status !== 200) return undefined; + const value = JSON.parse(response.bodyText); + return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; + }; + // Only merge quotas when the browser session belongs to the same Meta account as the CLI login. + const loginEmail = _optionalChain([ + text, + "call", + (_3) => _3(root.user_email, "user_email"), + "optionalAccess", + (_4) => _4.toLowerCase, + "call", + (_5) => _5(), + ]); + const me = await get("/api/auth/me"); + const webEmail = + typeof _optionalChain([me, "optionalAccess", (_6) => _6.email]) === "string" + ? me.email.trim().toLowerCase() + : undefined; + if (!loginEmail || !webEmail || loginEmail !== webEmail) return undefined; + const teams = await _asyncOptionalChain([ + await get("/api/portal/teams"), + "optionalAccess", + async (_7) => _7.teams, + ]); + if (!Array.isArray(teams)) return undefined; + for (const team of teams.slice(0, 2)) { + const id = _optionalChain([team, "optionalAccess", (_8) => _8.team_id]); + const teamID = typeof id === "string" ? id : Number.isSafeInteger(id) ? String(id) : ""; + if (!/^[0-9]+$/.test(teamID)) continue; + const quota = await _asyncOptionalChain([ + await get(`/api/portal/teams/${teamID}/subscription-quota`), + "optionalAccess", + async (_9) => _9.subscription_quota, + ]); + const parsed = quota && typeof quota === "object" ? parseWebQuota(quota) : null; + if (parsed) return parsed; + } + } catch (error) { + void error; + } + return undefined; + } + function parseWebQuota(quota) { + // Limits and usage are weighted token counts encoded as decimal strings. + const amount = (value) => { + const parsed = typeof value === "string" && /^[0-9]+$/.test(value) ? Number(value) : value; + return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : null; + }; + const percent = (used, limit) => { + const u = amount(used); + const l = amount(limit); + return u === null || l === null || l <= 0 ? null : Math.min(100, (u / l) * 100); + }; + const primaryPercent = percent(quota.window_weighted_used, quota.window_weighted_limit); + const weeklyPercent = percent(quota.weekly_weighted_used, quota.weekly_weighted_limit); + const seconds = amount(quota.window_duration_secs); + if (primaryPercent === null || weeklyPercent === null || seconds === null || seconds < 60) return null; + const resetAt = (value) => { + const parsed = amount(value); + return parsed === null || parsed <= 0 || parsed > 64092211200 ? undefined : ctx.date.unixSeconds(parsed); + }; + return { + // An idle 5-hour window has no reset time until the next request starts it. + primary: { + usedPercent: primaryPercent, + windowMinutes: Math.round(seconds / 60), + resetsAt: resetAt(quota.window_resets_at), + }, + secondary: { usedPercent: weeklyPercent, windowMinutes: 10080, resetsAt: resetAt(quota.weekly_resets_at) }, + }; + } if (root.subs_usage === undefined || root.subs_usage === null) { - rows.push({ label: "Quota", value: "Not included in this login response" }); - return snapshot; + // The login response omits quotas while the 5-hour window is idle, even when the weekly limit has usage. + // The dev.meta.ai usage page reads the same subscription quota with the browser session. + const web = await webQuota(); + if (!web) { + rows.push({ label: "Quota", value: "Not included in this login response" }); + return snapshot; + } + rows.push({ label: "5 hours", value: percentLabel(web.primary.usedPercent) }); + rows.push({ label: "Weekly", value: percentLabel(web.secondary.usedPercent) }); + return { usage: { ...snapshot, ...web, dataConfidence: "exact" }, sourceLabel: "oauth+web" }; } const usage = object(root.subs_usage, "subs_usage"); const window = object(usage.window, "missing subscription window"); @@ -121,8 +221,8 @@ defineProvider({ if (!Number.isSafeInteger(minutes) || minutes <= 0) return fail("window_duration_mins"); const primaryPercent = Math.min(100, Math.max(0, number(window.used_percent, "window.used_percent"))); const weeklyPercent = Math.min(100, Math.max(0, number(weekly.used_percent, "weekly.used_percent"))); - rows.push({ label: "5 hours", value: `${ctx.format.number(primaryPercent, { maximumFractionDigits: 0 })}%` }); - rows.push({ label: "Weekly", value: `${ctx.format.number(weeklyPercent, { maximumFractionDigits: 0 })}%` }); + rows.push({ label: "5 hours", value: percentLabel(primaryPercent) }); + rows.push({ label: "Weekly", value: percentLabel(weeklyPercent) }); return { ...snapshot, primary: { usedPercent: primaryPercent, windowMinutes: minutes, resetsAt: reset(window.resets_at) }, diff --git a/Sources/CodexBarCore/Resources/Plugins/muse.ts b/Sources/CodexBarCore/Resources/Plugins/muse.ts index fb74b48d83..21fb69aec6 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.ts +++ b/Sources/CodexBarCore/Resources/Plugins/muse.ts @@ -1,17 +1,19 @@ defineProvider({ id: "muse", name: "Muse Code", - endpoints: ["https://api.meta.ai"], - auth: { type: "bearer", secret: "MUSE_DEVICE_TOKEN" }, + endpoints: ["https://api.meta.ai", "https://dev.meta.ai"], settings: [{ key: "MUSE_DEVICE_TOKEN", title: "Muse login", type: "secure" }], - capabilities: ["http-status"], + capabilities: ["browser-cookies", "http-status"], + cookieDomains: ["dev.meta.ai"], async fetchUsage(ctx) { - if (!ctx.settings.getSecret("MUSE_DEVICE_TOKEN")?.startsWith("dca:")) { + const token = ctx.settings.getSecret("MUSE_DEVICE_TOKEN"); + if (!token?.startsWith("dca:")) { throw ctx.fail.authenticationExpired("Muse Code requires a device-code login. Run `muse login` again."); } + // Keep the device credential off dev.meta.ai requests, which authenticate with the browser session. const response = await ctx.http.post("https://api.meta.ai/muse-code/key", { body: {}, - headers: { "x-api-version": "1.0.0", "User-Agent": "CodexBar" }, + headers: { Authorization: `Bearer ${token}`, "x-api-version": "1.0.0", "User-Agent": "CodexBar" }, timeoutSeconds: 15, }); if (response.status === 401 || response.status === 403) { @@ -68,9 +70,83 @@ defineProvider({ identity: { email: text(root.user_email, "user_email"), loginMethod: plan ?? "Muse login" }, dataConfidence: "unknown", }; + const percentLabel = (value: number) => `${ctx.format.number(value, { maximumFractionDigits: 0 })}%`; + async function webQuota() { + if (ctx.browser.availability("dev.meta.ai") === "off") return undefined; + try { + const headers = { Cookie: await ctx.browser.cookieHeader("dev.meta.ai"), "User-Agent": "CodexBar" }; + const get = async (path: string): Promise | undefined> => { + const response = await ctx.http.get(`https://dev.meta.ai${path}`, { headers, timeoutSeconds: 8 }); + if (response.status === 401 || response.status === 403) { + ctx.browser.rejectCookie("dev.meta.ai"); + return undefined; + } + if (response.status !== 200) return undefined; + const value: unknown = JSON.parse(response.bodyText); + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; + }; + // Only merge quotas when the browser session belongs to the same Meta account as the CLI login. + const loginEmail = text(root.user_email, "user_email")?.toLowerCase(); + const me = await get("/api/auth/me"); + const webEmail = typeof me?.email === "string" ? me.email.trim().toLowerCase() : undefined; + if (!loginEmail || !webEmail || loginEmail !== webEmail) return undefined; + const teams = (await get("/api/portal/teams"))?.teams; + if (!Array.isArray(teams)) return undefined; + for (const team of teams.slice(0, 2)) { + const id = (team as Record | null)?.team_id; + const teamID = typeof id === "string" ? id : Number.isSafeInteger(id) ? String(id) : ""; + if (!/^[0-9]+$/.test(teamID)) continue; + const quota = (await get(`/api/portal/teams/${teamID}/subscription-quota`))?.subscription_quota; + const parsed = quota && typeof quota === "object" ? parseWebQuota(quota as Record) : null; + if (parsed) return parsed; + } + } catch (error) { + void error; + } + return undefined; + } + function parseWebQuota(quota: Record) { + // Limits and usage are weighted token counts encoded as decimal strings. + const amount = (value: unknown) => { + const parsed = typeof value === "string" && /^[0-9]+$/.test(value) ? Number(value) : value; + return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : null; + }; + const percent = (used: unknown, limit: unknown) => { + const u = amount(used); + const l = amount(limit); + return u === null || l === null || l <= 0 ? null : Math.min(100, (u / l) * 100); + }; + const primaryPercent = percent(quota.window_weighted_used, quota.window_weighted_limit); + const weeklyPercent = percent(quota.weekly_weighted_used, quota.weekly_weighted_limit); + const seconds = amount(quota.window_duration_secs); + if (primaryPercent === null || weeklyPercent === null || seconds === null || seconds < 60) return null; + const resetAt = (value: unknown) => { + const parsed = amount(value); + return parsed === null || parsed <= 0 || parsed > 64092211200 ? undefined : ctx.date.unixSeconds(parsed); + }; + return { + // An idle 5-hour window has no reset time until the next request starts it. + primary: { + usedPercent: primaryPercent, + windowMinutes: Math.round(seconds / 60), + resetsAt: resetAt(quota.window_resets_at), + }, + secondary: { usedPercent: weeklyPercent, windowMinutes: 10080, resetsAt: resetAt(quota.weekly_resets_at) }, + }; + } if (root.subs_usage === undefined || root.subs_usage === null) { - rows.push({ label: "Quota", value: "Not included in this login response" }); - return snapshot; + // The login response omits quotas while the 5-hour window is idle, even when the weekly limit has usage. + // The dev.meta.ai usage page reads the same subscription quota with the browser session. + const web = await webQuota(); + if (!web) { + rows.push({ label: "Quota", value: "Not included in this login response" }); + return snapshot; + } + rows.push({ label: "5 hours", value: percentLabel(web.primary.usedPercent) }); + rows.push({ label: "Weekly", value: percentLabel(web.secondary.usedPercent) }); + return { usage: { ...snapshot, ...web, dataConfidence: "exact" }, sourceLabel: "oauth+web" }; } const usage = object(root.subs_usage, "subs_usage"); const window = object(usage.window, "missing subscription window"); @@ -79,8 +155,8 @@ defineProvider({ if (!Number.isSafeInteger(minutes) || minutes <= 0) return fail("window_duration_mins"); const primaryPercent = Math.min(100, Math.max(0, number(window.used_percent, "window.used_percent"))); const weeklyPercent = Math.min(100, Math.max(0, number(weekly.used_percent, "weekly.used_percent"))); - rows.push({ label: "5 hours", value: `${ctx.format.number(primaryPercent, { maximumFractionDigits: 0 })}%` }); - rows.push({ label: "Weekly", value: `${ctx.format.number(weeklyPercent, { maximumFractionDigits: 0 })}%` }); + rows.push({ label: "5 hours", value: percentLabel(primaryPercent) }); + rows.push({ label: "Weekly", value: percentLabel(weeklyPercent) }); return { ...snapshot, primary: { usedPercent: primaryPercent, windowMinutes: minutes, resetsAt: reset(window.resets_at) }, diff --git a/Tests/CodexBarTests/MusePluginTests.swift b/Tests/CodexBarTests/MusePluginTests.swift index 1c2b2392b2..5a5aede11f 100644 --- a/Tests/CodexBarTests/MusePluginTests.swift +++ b/Tests/CodexBarTests/MusePluginTests.swift @@ -142,6 +142,195 @@ struct MusePluginTests { #expect(snapshot.secondary?.resetsAt != nil) } + static let teams = #"{"teams":[{"team_id":906954075295332,"team_name":"My Team"}]}"# + static let me = #"{"userId":"1","email":"Ada@Example.com","accountType":"META_ACCOUNT"}"# + + static let idleWindowQuota = #""" + {"subscription_quota":{"tier_id":"1","tier":"Muse Code Everyday Usage","as_of":1790341873, + "window_weighted_limit":"20000000000","window_duration_secs":18000, + "weekly_weighted_limit":"60000000000","weekly_resets_at":1790553600, + "window_weighted_used":"0","weekly_weighted_used":"9043782620"}} + """# + + @Test(arguments: BundledPluginTestSupport.engines) + func `omitted login quotas fall back to the dev meta ai session`(engine: ProviderPluginEngineKind) async throws { + let requests = RequestLog() + let result = try await Self.fetchWithWeb(engine: engine, requests: requests) { path in + switch path { + case "/api/auth/me": (Self.me, 200) + case "/api/portal/teams": (Self.teams, 200) + case "/api/portal/teams/906954075295332/subscription-quota": (Self.idleWindowQuota, 200) + default: ("{}", 404) + } + } + let snapshot = result.usage + #expect(result.sourceLabel == "oauth+web") + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.primary?.windowMinutes == 300) + #expect(snapshot.primary?.resetsAt == nil) + let weekly = try #require(snapshot.secondary) + #expect(abs(weekly.usedPercent - 15.07297103) < 0.0001) + #expect(weekly.windowMinutes == 10080) + #expect(weekly.resetsAt == Date(timeIntervalSince1970: 1_790_553_600)) + #expect(snapshot.dataConfidence == .exact) + #expect(snapshot.identity?.loginMethod == "Muse Code Power Usage") + let rows = snapshot.details.flatMap(\.rows) + #expect(rows.contains { $0.label == "Weekly" && $0.value == "15%" }) + #expect(!rows.contains { $0.label == "Quota" }) + let web = requests.all.filter { $0.url?.host == "dev.meta.ai" } + #expect(web.count == 3) + #expect(web.allSatisfy { + $0.value(forHTTPHeaderField: "Cookie") == "llama_dev_sess=fixture" + && $0.value(forHTTPHeaderField: "Authorization") == nil + }) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `reported login quotas never read the browser session`(engine: ProviderPluginEngineKind) async throws { + let requests = RequestLog() + let result = try await Self.fetchWithWeb(engine: engine, account: Self.account, requests: requests) { _ in + (Self.idleWindowQuota, 200) + } + #expect(result.sourceLabel == nil) + #expect(result.usage.primary?.usedPercent == 96) + #expect(!requests.all.contains { $0.url?.host == "dev.meta.ai" }) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `teams without a subscription are skipped`(engine: ProviderPluginEngineKind) async throws { + let result = try await Self.fetchWithWeb(engine: engine) { path in + switch path { + case "/api/auth/me": (Self.me, 200) + case "/api/portal/teams": (#"{"teams":[{"team_id":"11"},{"team_id":"22"}]}"#, 200) + case "/api/portal/teams/11/subscription-quota": (#"{"subscription_quota":null}"#, 200) + case "/api/portal/teams/22/subscription-quota": (Self.idleWindowQuota, 200) + default: ("{}", 404) + } + } + #expect(result.sourceLabel == "oauth+web") + #expect(result.usage.secondary != nil) + } + + @Test(arguments: [ + (#"{"error":"Not authenticated"}"#, 401), + (#"{"subscription_quota":{"window_weighted_limit":"0","window_weighted_used":"0"}}"#, 200), + ("", 200), + ], BundledPluginTestSupport.engines) + func `unusable web quotas keep the login response result`( + quota: (body: String, status: Int), + engine: ProviderPluginEngineKind) async throws + { + let rejected = RequestLog() + let result = try await Self.fetchWithWeb(engine: engine, rejected: rejected) { path in + switch path { + case "/api/auth/me": (Self.me, 200) + case "/api/portal/teams": (Self.teams, 200) + default: quota + } + } + #expect(result.sourceLabel == nil) + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.identity?.loginMethod == "Muse Code Power Usage") + #expect(result.usage.details.flatMap(\.rows).contains { $0.label == "Quota" }) + #expect(rejected.domains == (quota.status == 401 ? ["dev.meta.ai"] : [])) + } + + @Test(arguments: [#"{"email":"bob@example.com"}"#, #"{"userId":"1"}"#], BundledPluginTestSupport.engines) + func `a browser session for another account never supplies quotas`( + me: String, + engine: ProviderPluginEngineKind) async throws + { + let requests = RequestLog() + let result = try await Self.fetchWithWeb(engine: engine, requests: requests) { path in + switch path { + case "/api/auth/me": (me, 200) + case "/api/portal/teams": (Self.teams, 200) + default: (Self.idleWindowQuota, 200) + } + } + #expect(result.usage.secondary == nil) + #expect(result.usage.identity?.accountEmail == "ada@example.com") + #expect(!requests.all.contains { $0.url?.path.hasPrefix("/api/portal") == true }) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `disabled browser cookies never contact dev meta ai`(engine: ProviderPluginEngineKind) async throws { + let requests = RequestLog() + let result = try await Self.fetchWithWeb(engine: engine, cookieSource: .off, requests: requests) { _ in + (Self.idleWindowQuota, 200) + } + #expect(result.usage.secondary == nil) + #expect(!requests.all.contains { $0.url?.host == "dev.meta.ai" }) + } + + @Test(arguments: [ + (ProviderConfig?.none, ProviderCookieSource.off), + (ProviderConfig(id: .muse), .off), + (ProviderConfig(id: .muse, cookieHeader: "llama_dev_sess=fixture"), .manual), + (ProviderConfig(id: .muse, cookieSource: .auto), .auto), + ]) + func `browser session access stays off until configured`( + config: ProviderConfig?, + expected: ProviderCookieSource) throws + { + let contribution = try #require(MuseProviderDescriptor.descriptor.settingsSection + .credentialContribution(context: ProviderCredentialSettingsContext(config: config, account: nil))) + let settings = ProviderSettingsSnapshot(contributions: [contribution]) + #expect(settings[MuseProviderSettingsKey.self]?.cookieSource == expected) + } + + private final class RequestLog: @unchecked Sendable { + private let lock = NSLock() + private var requests: [URLRequest] = [] + private var rejectedDomains: [String] = [] + var all: [URLRequest] { + self.lock.withLock { self.requests } + } + + var domains: [String] { + self.lock.withLock { self.rejectedDomains } + } + + func append(_ request: URLRequest) { + self.lock.withLock { self.requests.append(request) } + } + + func reject(_ domain: String) { + self.lock.withLock { self.rejectedDomains.append(domain) } + } + } + + private static func fetchWithWeb( + engine: ProviderPluginEngineKind, + account: String = Self.activeWithoutWindows, + cookieSource: ProviderCookieSource = .auto, + requests: RequestLog = RequestLog(), + rejected: RequestLog = RequestLog(), + web: @escaping @Sendable (String) -> (String, Int)) async throws -> ProviderPluginResult + { + let runtime = try BundledPluginTestSupport.runtime( + "muse", + engine: engine, + transport: ProviderHTTPTransportHandler { request in + requests.append(request) + guard request.url?.host == "dev.meta.ai" else { + return try Self.response(request, body: account) + } + let (body, status) = web(request.url?.path ?? "") + return try Self.response(request, body: body, status: status) + }) + return try await runtime.fetchResult( + secrets: ["MUSE_DEVICE_TOKEN": "dca:fixture-token"], + now: Date(timeIntervalSince1970: 1_790_341_873), + cookieSource: cookieSource, + cookieInvalidator: { rejected.reject($0) }, + cookieResolver: { _, domain in + #expect(domain == "dev.meta.ai") + return "llama_dev_sess=fixture" + }) + } + static func fetch( _ body: String, engine: ProviderPluginEngineKind, diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index ce254586eb..5e910c1d31 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -983,13 +983,13 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact provider-owned construct passes a fixed identity to shared infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SettingsStore+MenuObservation.swift", - line: 111, + line: 112, anchor: "_ = self[providerConfig: .synthetic, field: .apiKey]", expectedProviderIDs: ["synthetic"], reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SettingsStore+MenuObservation.swift", - line: 130, + line: 132, anchor: "_ = self[providerConfig: .warp, field: .apiKey]", expectedProviderIDs: ["warp"], reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), diff --git a/docs/muse.md b/docs/muse.md index c29f03c17c..d140afbfde 100644 --- a/docs/muse.md +++ b/docs/muse.md @@ -39,7 +39,19 @@ Reset timestamps outside the supported date range are omitted without discarding Pay-as-you-go accounts without `is_subs_active` are reported as having no subscription rather than a fake 0% bar. Accounts that still need a payment method are reported as billing-incomplete. -An active subscription whose mint response omits `subs_usage` or returns it as `null` keeps its plan and identity, with **Quota: Not included in this login response** and no quota bars. Malformed quota objects still fail parsing; missing windows never become invented 0% usage. +An active subscription whose mint response omits `subs_usage` or returns it as `null` keeps its plan and identity. Meta omits `subs_usage` while the 5-hour window is idle, even when the weekly limit has usage. + +## Web quota fallback + +When `subs_usage` is missing, CodexBar reads the same subscription quota that the `dev.meta.ai/usage` page shows, using your browser session for `dev.meta.ai` (the `llama_dev_sess` cookie): + +1. `GET https://dev.meta.ai/api/auth/me`. The session email must match the CLI login email, or CodexBar ignores the web quota. +2. `GET https://dev.meta.ai/api/portal/teams` +3. `GET https://dev.meta.ai/api/portal/teams/{team_id}/subscription-quota` for up to two teams, using the first team that returns a quota. + +Usage is `used / limit` for the 5-hour and weekly weighted limits. An idle 5-hour window shows 0% with no reset time, because the window starts with the next request. The source label becomes `oauth+web`. The device-code token is sent only to `api.meta.ai`; `dev.meta.ai` requests carry only the browser cookie. + +**Settings → Providers → Muse Code → Cookie source** controls the fallback. It is **Off** by default, so CodexBar reads no browser data until you choose a source. **Automatic** imports the cookie from Chrome or Firefox, **Manual** uses a pasted Cookie header or cURL capture from `dev.meta.ai`. If the fallback is off, has no session, belongs to another account, is rejected, times out, or returns an unexpected shape, the card keeps **Quota: Not included in this login response** and no quota bars. Malformed mint quota objects still fail parsing; missing windows never become invented 0% usage. ## Local token history diff --git a/docs/providers.md b/docs/providers.md index 276c593f57..552cf0b926 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -94,7 +94,7 @@ complete when the available scan window covers fewer days. | Warp | API token (config/env) → GraphQL request limits (`api`). | | ElevenLabs | API key from config/env → subscription usage API (`api`). | | [Nous Portal](nous.md) | Read-only Hermes login or explicit access token → bundled plugin for monthly credits and top-up balances (`api`). | -| [Muse Code](muse.md) | Existing CLI device-code login → bundled plugin for reported five-hour and weekly subscription quotas (`oauth`). | +| [Muse Code](muse.md) | Existing CLI device-code login → bundled plugin for reported five-hour and weekly subscription quotas (`oauth`); `dev.meta.ai` browser-cookie fallback when the login response omits them (`oauth+web`). | | [CodeRabbit](coderabbit.md) | One bounded local CLI usage report for review counts and billing state (`cli`); no quota or balance is inferred. | | [Replicate](replicate.md) | Native Chrome cookie candidates or a manual header → bundled plugin for monthly spend and optional prepaid credits (`web`). | | [TypeSafe](typesafe.md) | Chrome cookies or a manual header → bundled plugin for billing spend and credit balance (`web`). | From 6f2a24cd52b7513394f37b5bba1db2e25a7d93ce Mon Sep 17 00:00:00 2001 From: enieuwy <121954036+enieuwy@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:39:28 +0800 Subject: [PATCH 2/2] Add Muse web quota fallback proof --- .github/pr-proof/muse-web-quota-fallback.log | 59 ++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/pr-proof/muse-web-quota-fallback.log diff --git a/.github/pr-proof/muse-web-quota-fallback.log b/.github/pr-proof/muse-web-quota-fallback.log new file mode 100644 index 0000000000..941eb56ac5 --- /dev/null +++ b/.github/pr-proof/muse-web-quota-fallback.log @@ -0,0 +1,59 @@ +# Muse Code web quota fallback — production-path proof +# Date: 2026-09-26 03:22–03:39 AWST (UTC+8), macOS arm64, real Muse Code Everyday Usage account. +# Redactions: device token, llama_dev_sess cookie value, email address, tier_id. Nothing else edited. +# +# Setup (no CodexBar Keychain read, no browser automation): +# - The dca: device token was read once with /usr/bin/security (user clicked Allow) into a +# temporary 0600 auth.json in the CLI's file format (storage "file"), selected with MUSE_AUTH_PATH. +# - The llama_dev_sess cookie was copied from the Firefox cookies.sqlite into a temporary +# CODEXBAR_CONFIG with cookieSource "manual". Both files were deleted after the run. +# - Brew build: codexbar 0.66.0 (/opt/homebrew/bin/codexbar). +# - Branch build: swift build of commit 74130071c (.build/debug/CodexBarCLI). + +## 1. The 5-hour window is idle (cookie only; 03:22:13) +$ curl -H "Cookie: llama_dev_sess=" -H "User-Agent: CodexBar" \ + https://dev.meta.ai/api/portal/teams//subscription-quota +{"subscription_quota":{"tier_id":"","tier":"Muse Code Everyday Usage","as_of":1790364133, + "window_weighted_limit":"20000000000","window_duration_secs":18000, + "weekly_weighted_limit":"60000000000","weekly_resets_at":1790553600, + "window_weighted_used":"0","weekly_weighted_used":"13550839000"}} +# No window_resets_at while idle. Weekly: 13550839000 / 60000000000 = 22.58%. +# The Muse CLI `/usage` shows "Currently unavailable" in this state. + +## 2. Same account, same config, both builds (03:38:44) +$ CODEXBAR_CONFIG=/config.json MUSE_AUTH_PATH=/auth.json codexbar usage --provider muse --format json +== codexbar 0.66.0 (Brew) +{ + "source": "oauth", + "primary": null, + "secondary": null, + "dataConfidence": null, + "rows": [ + {"label": "Plan", "value": "Muse Code Everyday Usage"}, + {"label": "Quota", "value": "Not included in this login response"} + ], + "error": null +} +== branch 74130071c +{ + "source": "oauth+web", + "primary": {"windowMinutes": 300, "usedPercent": 0}, + "secondary": {"windowMinutes": 10080, "usedPercent": 22.584731666666666, "resetsAt": "2026-09-28T00:00:00Z"}, + "dataConfidence": "exact", + "rows": [ + {"label": "Plan", "value": "Muse Code Everyday Usage"}, + {"label": "5 hours", "value": "0%"}, + {"label": "Weekly", "value": "23%"} + ], + "error": null +} +# Branch weekly value equals step 1 (22.58%); reset equals weekly_resets_at (1790553600). +# Idle 5-hour window: 0% with no invented reset time. + +## 3. Default (no cookieSource configured) on the branch stays Off +$ CODEXBAR_CONFIG=/off.json MUSE_AUTH_PATH=/auth.json CodexBarCLI usage --provider muse --format json +oauth None ['Muse Code Everyday Usage', 'Not included in this login response'] +# Unconfigured installs make no dev.meta.ai request and read no browser cookies. + +## 4. Active window (earlier, 2026-09-25 22:45 AWST): subs_usage present, fallback not used +# Brew and branch both returned source "oauth", 5 hours 6%, Weekly 17% — identical output.