diff --git a/.github/pr-proof/muse-web-team-quota.log b/.github/pr-proof/muse-web-team-quota.log new file mode 100644 index 0000000000..de277af7b6 --- /dev/null +++ b/.github/pr-proof/muse-web-team-quota.log @@ -0,0 +1,44 @@ +# Muse Code browser-team quota - production-path proof + +Date: 2026-09-26 08:48 AWST (UTC+8), macOS arm64, real Muse Code Everyday Usage account. +Redactions: device token, llama_dev_sess cookie value, email, team ID (). Nothing else edited. + +## Setup (no CodexBar Keychain read) +- CLI device token copied once (user-approved `security` prompt) into a temporary + `auth.json` (mode 0600), selected through MUSE_AUTH_PATH. +- Firefox `llama_dev_sess` cookie pasted as Manual in a temporary config selected + through CODEXBAR_CONFIG. The "default" run uses a config without cookie settings. +- Both temporary files deleted right after the run. +- State at run time: 5-hour window idle, so POST /muse-code/key omits `subs_usage`. + The session sees one team. +- Command: `codexbar usage --provider muse --format json` (fields summarised). + +## Brew 0.66.0 baseline +source=oauth 5h=none weekly=none +[Muse Code subscription] Plan=Muse Code Everyday Usage, Quota=Not included in this login response + +## Branch, default settings (cookie source Off) +source=oauth 5h=none weekly=none +[Muse Code subscription] Plan=Muse Code Everyday Usage, Quota=Not included in this login response +-> no browser read. + +## Branch, cookie set, no team selected +source=oauth 5h=none weekly=none +[Muse Code subscription] Plan=..., Quota=Not included in this login response +[Browser teams] Status=Choose a browser team ID in Muse Code settings, My Team= +-> teams listed, no quota requested. + +## Branch, cookie set, team ID not visible to the session ("123") +source=oauth 5h=none weekly=none +[Browser teams] Status=The selected browser team is not visible to this session, My Team= +-> no quota requested. + +## Branch, cookie set, real team selected +source=oauth+web dataConfidence=estimated +5h=0% (no reset: window idle) weekly=22.58% resets 2026-09-28T00:00:00Z +[Muse Code subscription] Plan=Muse Code Everyday Usage +[Browser team quota (dev.meta.ai)] Team=My Team, 5 hours=0%, Weekly=23% + +Cross-check (same cookie, direct GET /api/portal/teams//subscription-quota, 07:29 AWST): +tier "Muse Code Everyday Usage" (equals login subs_tier_name), window_weighted_used 0, +weekly_weighted_used 13550839000 / 60000000000 = 22.58%. diff --git a/CHANGELOG.md b/CHANGELOG.md index f993b6d495..78d8882a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.67.1 — Unreleased +### Fixed + +- Muse Code: optionally show the explicitly selected dev.meta.ai browser team’s quota when the login omits quotas, with cookies Off by default and team choices in settings (#4011). Fixes #4002. Thanks @enieuwy! ### Added - Menu bar: add opt-in, bounded startup diagnostics for status-item creation and Control Center hosting investigations (#3377). diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index 37b11e7fa9..b90c12fcf5 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -325,6 +325,11 @@ extension UsageMenuCardView.Model { return [L("Quota estimated from local usage history")] + subscriptionNotes } + // Provider-specific by design: Muse browser-team quotas come from a user-selected dev.meta.ai team. + if input.provider == .muse, input.snapshot?.dataConfidence == .estimated { + return [L("Quota from the selected dev.meta.ai browser team")] + subscriptionNotes + } + if let notes = self.apiProviderUsageNotes(input: input) { return notes + subscriptionNotes } diff --git a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift index 5916f240ee..835b65149b 100644 --- a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift @@ -10,15 +10,80 @@ struct MuseProviderImplementation: ProviderImplementation { } @MainActor - func observeSettings(_: SettingsStore) {} + func observeSettings(_ settings: SettingsStore) { + _ = settings.museWebTeamID + } + + /// 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, + webTeamID: context.settings.museWebTeamID.isEmpty ? nil : context.settings.museWebTeamID), + 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] { + let rows = context.store.snapshot(for: .muse)?.details.first { $0.title == "Browser teams" }?.rows ?? [] + var options = [ProviderSettingsPickerOption(id: "", title: "Choose a team…")] + for row in rows where !row.value.isEmpty && row.value.allSatisfy(\.isNumber) { + guard !options.contains(where: { $0.id == row.value }) else { continue } + options.append(.init(id: row.value, title: "\(row.label) (\(row.value))")) + } + let selected = context.settings.museWebTeamID + if !selected.isEmpty, !options.contains(where: { $0.id == selected }) { + options.append(.init(id: selected, title: "\(selected) (unavailable)")) + } + return [ + 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")) + }), + ProviderSettingsPickerDescriptor( + id: "muse-web-team-id", + title: "Browser team", + subtitle: "Refresh Muse Code to load teams when the login omits quotas. " + + "Choose the web team's quota to display, then refresh.", + binding: context.binding(\.museWebTeamID), + options: options, + isVisible: { context.settings.museCookieSource != .off }, + onChange: nil), + ] + } + @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..6f457dae42 --- /dev/null +++ b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var museCookieHeader: String { + get { self[providerConfig: .muse, field: .cookieHeader] } + set { self[providerConfig: .muse, field: .cookieHeader] = newValue } + } + + var museWebTeamID: String { + get { self[providerConfig: .muse, field: .workspace] } + set { self[providerConfig: .muse, field: .workspace] = 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..5ab051f7ea 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -15,10 +15,34 @@ 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, + webTeamID: context.config?.sanitizedWorkspaceID) + }), credentials: self.credentials, + // `workspaceID` holds the user-selected dev.meta.ai team for the browser-team quota. + config: ProviderConfigCapabilities(workspaceIDValidationOrder: 8), metadata: ProviderMetadata( id: .muse, displayName: "Muse Code", @@ -32,6 +56,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 +100,23 @@ 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 (5 × 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 settings = context.settings?[MuseProviderSettingsKey.self] + let cookieSource = settings?.cookieSource ?? .off + let result = try await runtime.fetchResult( + settings: settings?.webTeamID.map { ["MUSE_WEB_TEAM_ID": $0] } ?? [:], + 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..fbfd081b53 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct MuseProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + /// The dev.meta.ai team whose quota fills omitted login quotas. Nil means no team was chosen. + public let webTeamID: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.init(cookieSource: cookieSource, manualCookieHeader: manualCookieHeader, webTeamID: nil) + } + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?, webTeamID: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + self.webTeamID = webTeamID + } +} + +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..84afa20a51 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.js +++ b/Sources/CodexBarCore/Resources/Plugins/muse.js @@ -26,34 +26,46 @@ 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" }, - settings: [{ key: "MUSE_DEVICE_TOKEN", title: "Muse login", type: "secure" }], - capabilities: ["http-status"], + endpoints: ["https://api.meta.ai", "https://dev.meta.ai"], + settings: [ + { key: "MUSE_DEVICE_TOKEN", title: "Muse login", type: "secure" }, + { key: "MUSE_WEB_TEAM_ID", title: "Browser team ID", type: "plain" }, + ], + 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 +122,181 @@ 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 })}%`; + + // The browser quota belongs to one dev.meta.ai team, which the user must choose explicitly: a session can + // see several teams, and list order says nothing about which one holds the CLI login's subscription. + async function webQuota() { + if (ctx.browser.availability("dev.meta.ai") === "off") return undefined; + try { + let requestsLeft = 5; + for await (const session of ctx.browser.sessions("dev.meta.ai")) { + if (requestsLeft <= 0) break; + let rejected = false; + const headers = { Cookie: session.header, "User-Agent": "CodexBar" }; + const get = async (path) => { + if (requestsLeft-- <= 0) throw new Error("Muse browser request budget exhausted"); + const response = await ctx.http.get(`https://dev.meta.ai${path}`, { headers, timeoutSeconds: 8 }); + if (response.status === 401 || response.status === 403) { + rejected = true; + ctx.browser.rejectCookie("dev.meta.ai", session); + return undefined; + } + if (response.status !== 200) return undefined; + const value = JSON.parse(response.bodyText); + return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; + }; + // The browser session must belong 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) continue; + const listed = await _asyncOptionalChain([ + await get("/api/portal/teams"), + "optionalAccess", + async (_7) => _7.teams, + ]); + if (rejected) continue; + if (!Array.isArray(listed)) return undefined; + const teams = []; + for (const entry of listed) { + const item = entry && typeof entry === "object" ? entry : {}; + const id = + typeof item.team_id === "string" + ? item.team_id + : Number.isSafeInteger(item.team_id) + ? String(item.team_id) + : ""; + if (!/^[0-9]+$/.test(id)) continue; + const name = typeof item.team_name === "string" && item.team_name.trim() ? item.team_name.trim() : id; + teams.push({ id, name }); + } + const selected = _nullishCoalesce( + _optionalChain([ + ctx, + "access", + (_8) => _8.settings, + "access", + (_9) => _9.get, + "call", + (_10) => _10("MUSE_WEB_TEAM_ID"), + "optionalAccess", + (_11) => _11.trim, + "call", + (_12) => _12(), + ]), + () => "", + ); + const team = teams.find((candidate) => candidate.id === selected); + if (!selected) return { teams, note: "Choose a browser team in Muse Code settings" }; + if (!team) return { teams, note: "The selected browser team is not visible to this session" }; + const quota = await _asyncOptionalChain([ + await get(`/api/portal/teams/${team.id}/subscription-quota`), + "optionalAccess", + async (_13) => _13.subscription_quota, + ]); + if (rejected) continue; + if (!quota || typeof quota !== "object") + return { teams, note: "No subscription quota for the selected team" }; + const record = quota; + // The team's quota must be for the same plan the CLI login reports. + if (!plan || typeof record.tier !== "string" || record.tier.trim() !== plan) { + return { teams, note: "The selected team's plan differs from the Muse login" }; + } + const parsed = parseWebQuota(record); + return parsed ? { teams, quota: { team, ...parsed } } : { teams }; + } + } 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 resetAt = (value) => { + const parsed = amount(value); + return parsed === null || parsed <= 0 || parsed > 64092211200 ? undefined : ctx.date.unixSeconds(parsed); + }; + const now = ctx.date.now().getTime(); + const weeklyReset = resetAt(quota.weekly_resets_at); + const weeklyPercent = percent(quota.weekly_weighted_used, quota.weekly_weighted_limit); + const seconds = amount(quota.window_duration_secs); + // A weekly quota without a future reset is stale; show nothing rather than an old reading. + if (weeklyPercent === null || !weeklyReset || weeklyReset.getTime() <= now) return null; + if (seconds === null || !Number.isSafeInteger(seconds) || seconds < 60) return null; + const windowReset = resetAt(quota.window_resets_at); + let primaryPercent = percent(quota.window_weighted_used, quota.window_weighted_limit); + if (primaryPercent === null) return null; + const noWindowReset = quota.window_resets_at === undefined || quota.window_resets_at === null; + if ((!noWindowReset && !windowReset) || (noWindowReset && primaryPercent !== 0)) return null; + // An idle 5-hour window has no reset time; one whose reset has passed carries no usage into the next window. + const windowActive = windowReset !== undefined && windowReset.getTime() > now; + if (!windowActive) primaryPercent = 0; + return { + primary: { + usedPercent: primaryPercent, + windowMinutes: Math.round(seconds / 60), + resetsAt: windowActive ? windowReset : undefined, + }, + secondary: { usedPercent: weeklyPercent, windowMinutes: 10080, resetsAt: weeklyReset }, + }; + } 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 (!_optionalChain([web, "optionalAccess", (_14) => _14.quota])) + rows.push({ label: "Quota", value: "Not included in this login response" }); + if (!web) return snapshot; + const teamRows = _nullishCoalesce(web.teams, () => []).map((team) => ({ label: team.name, value: team.id })); + if (web.note) teamRows.unshift({ label: "Status", value: web.note }); + _optionalChain([ + snapshot, + "access", + (_15) => _15.details, + "optionalAccess", + (_16) => _16.push, + "call", + (_17) => _17({ title: "Browser teams", rows: teamRows }), + ]); + if (!web.quota) return snapshot; + const { team, primary, secondary } = web.quota; + const webRows = [ + { label: "Team", value: team.name }, + { label: "5 hours", value: percentLabel(primary.usedPercent) }, + { label: "Weekly", value: percentLabel(secondary.usedPercent) }, + ]; + _optionalChain([ + snapshot, + "access", + (_18) => _18.details, + "optionalAccess", + (_19) => _19.push, + "call", + (_20) => _20({ title: "Browser team quota (dev.meta.ai)", rows: webRows }), + ]); + // Weighted usage reported for a user-selected team, not by the CLI login itself. + return { usage: { ...snapshot, primary, secondary, dataConfidence: "estimated" }, sourceLabel: "oauth+web" }; } const usage = object(root.subs_usage, "subs_usage"); const window = object(usage.window, "missing subscription window"); @@ -121,8 +305,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..1b3eabe9c6 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.ts +++ b/Sources/CodexBarCore/Resources/Plugins/muse.ts @@ -1,17 +1,22 @@ defineProvider({ id: "muse", name: "Muse Code", - endpoints: ["https://api.meta.ai"], - auth: { type: "bearer", secret: "MUSE_DEVICE_TOKEN" }, - settings: [{ key: "MUSE_DEVICE_TOKEN", title: "Muse login", type: "secure" }], - capabilities: ["http-status"], + endpoints: ["https://api.meta.ai", "https://dev.meta.ai"], + settings: [ + { key: "MUSE_DEVICE_TOKEN", title: "Muse login", type: "secure" }, + { key: "MUSE_WEB_TEAM_ID", title: "Browser team ID", type: "plain" }, + ], + 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 +73,137 @@ 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 })}%`; + type WebTeam = { id: string; name: string }; + type WebQuota = { + team: WebTeam; + primary: CodexBarRateWindow; + secondary: CodexBarRateWindow; + }; + // The browser quota belongs to one dev.meta.ai team, which the user must choose explicitly: a session can + // see several teams, and list order says nothing about which one holds the CLI login's subscription. + async function webQuota(): Promise<{ quota?: WebQuota; teams?: WebTeam[]; note?: string } | undefined> { + if (ctx.browser.availability("dev.meta.ai") === "off") return undefined; + try { + let requestsLeft = 5; + for await (const session of ctx.browser.sessions("dev.meta.ai")) { + if (requestsLeft <= 0) break; + let rejected = false; + const headers = { Cookie: session.header, "User-Agent": "CodexBar" }; + const get = async (path: string): Promise | undefined> => { + if (requestsLeft-- <= 0) throw new Error("Muse browser request budget exhausted"); + const response = await ctx.http.get(`https://dev.meta.ai${path}`, { headers, timeoutSeconds: 8 }); + if (response.status === 401 || response.status === 403) { + rejected = true; + ctx.browser.rejectCookie("dev.meta.ai", session); + 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; + }; + // The browser session must belong 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) continue; + const listed = (await get("/api/portal/teams"))?.teams; + if (rejected) continue; + if (!Array.isArray(listed)) return undefined; + const teams: WebTeam[] = []; + for (const entry of listed) { + const item = entry && typeof entry === "object" ? (entry as Record) : {}; + const id = + typeof item.team_id === "string" + ? item.team_id + : Number.isSafeInteger(item.team_id) + ? String(item.team_id) + : ""; + if (!/^[0-9]+$/.test(id)) continue; + const name = typeof item.team_name === "string" && item.team_name.trim() ? item.team_name.trim() : id; + teams.push({ id, name }); + } + const selected = ctx.settings.get("MUSE_WEB_TEAM_ID")?.trim() ?? ""; + const team = teams.find((candidate) => candidate.id === selected); + if (!selected) return { teams, note: "Choose a browser team in Muse Code settings" }; + if (!team) return { teams, note: "The selected browser team is not visible to this session" }; + const quota = (await get(`/api/portal/teams/${team.id}/subscription-quota`))?.subscription_quota; + if (rejected) continue; + if (!quota || typeof quota !== "object") + return { teams, note: "No subscription quota for the selected team" }; + const record = quota as Record; + // The team's quota must be for the same plan the CLI login reports. + if (!plan || typeof record.tier !== "string" || record.tier.trim() !== plan) { + return { teams, note: "The selected team's plan differs from the Muse login" }; + } + const parsed = parseWebQuota(record); + return parsed ? { teams, quota: { team, ...parsed } } : { teams }; + } + } 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 resetAt = (value: unknown) => { + const parsed = amount(value); + return parsed === null || parsed <= 0 || parsed > 64092211200 ? undefined : ctx.date.unixSeconds(parsed); + }; + const now = ctx.date.now().getTime(); + const weeklyReset = resetAt(quota.weekly_resets_at); + const weeklyPercent = percent(quota.weekly_weighted_used, quota.weekly_weighted_limit); + const seconds = amount(quota.window_duration_secs); + // A weekly quota without a future reset is stale; show nothing rather than an old reading. + if (weeklyPercent === null || !weeklyReset || weeklyReset.getTime() <= now) return null; + if (seconds === null || !Number.isSafeInteger(seconds) || seconds < 60) return null; + const windowReset = resetAt(quota.window_resets_at); + let primaryPercent = percent(quota.window_weighted_used, quota.window_weighted_limit); + if (primaryPercent === null) return null; + const noWindowReset = quota.window_resets_at === undefined || quota.window_resets_at === null; + if ((!noWindowReset && !windowReset) || (noWindowReset && primaryPercent !== 0)) return null; + // An idle 5-hour window has no reset time; one whose reset has passed carries no usage into the next window. + const windowActive = windowReset !== undefined && windowReset.getTime() > now; + if (!windowActive) primaryPercent = 0; + return { + primary: { + usedPercent: primaryPercent, + windowMinutes: Math.round(seconds / 60), + resetsAt: windowActive ? windowReset : undefined, + }, + secondary: { usedPercent: weeklyPercent, windowMinutes: 10080, resetsAt: weeklyReset }, + }; + } 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?.quota) rows.push({ label: "Quota", value: "Not included in this login response" }); + if (!web) return snapshot; + const teamRows: CodexBarDetailRow[] = (web.teams ?? []).map((team) => ({ label: team.name, value: team.id })); + if (web.note) teamRows.unshift({ label: "Status", value: web.note }); + snapshot.details?.push({ title: "Browser teams", rows: teamRows }); + if (!web.quota) return snapshot; + const { team, primary, secondary } = web.quota; + const webRows: CodexBarDetailRow[] = [ + { label: "Team", value: team.name }, + { label: "5 hours", value: percentLabel(primary.usedPercent) }, + { label: "Weekly", value: percentLabel(secondary.usedPercent) }, + ]; + snapshot.details?.push({ title: "Browser team quota (dev.meta.ai)", rows: webRows }); + // Weighted usage reported for a user-selected team, not by the CLI login itself. + return { usage: { ...snapshot, primary, secondary, dataConfidence: "estimated" }, sourceLabel: "oauth+web" }; } const usage = object(root.subs_usage, "subs_usage"); const window = object(usage.window, "missing subscription window"); @@ -79,8 +212,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/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 8c28cea26d..7de0b46b52 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -379,7 +379,7 @@ struct ConfigValidationTests { let issue = issues.first { $0.provider == .gemini && $0.code == "workspace_unused" } let expectedMessage = "workspaceID is set but only azureopenai, openai, opencode, opencodego, devin, deepgram, " + - "xai, and gitkraken support workspaceID." + "xai, gitkraken, and muse support workspaceID." #expect(issue?.message == expectedMessage) } diff --git a/Tests/CodexBarTests/MuseMenuCardModelTests.swift b/Tests/CodexBarTests/MuseMenuCardModelTests.swift new file mode 100644 index 0000000000..2ee1413dcb --- /dev/null +++ b/Tests/CodexBarTests/MuseMenuCardModelTests.swift @@ -0,0 +1,45 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MuseMenuCardModelTests { + @Test(arguments: [ + (UsageDataConfidence.estimated, [L("Quota from the selected dev.meta.ai browser team")]), + (.exact, []), + ]) + func `browser team quotas are disclosed under the usage bars`( + confidence: UsageDataConfidence, + expectedNotes: [String]) throws + { + let now = Date(timeIntervalSince1970: 1_790_341_873) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: now, + identity: nil, + dataConfidence: confidence) + let metadata = try #require(ProviderDefaults.metadata[.muse]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .muse, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.usageNotes == expectedNotes) + } +} diff --git a/Tests/CodexBarTests/MusePluginTests.swift b/Tests/CodexBarTests/MusePluginTests.swift index 1c2b2392b2..6677dfe159 100644 --- a/Tests/CodexBarTests/MusePluginTests.swift +++ b/Tests/CodexBarTests/MusePluginTests.swift @@ -142,6 +142,421 @@ struct MusePluginTests { #expect(snapshot.secondary?.resetsAt != nil) } + static let teamID = "424242424242" + static let teams = #"{"teams":[{"team_id":424242424242,"team_name":"My Team"}]}"# + static let me = #"{"userId":"1","email":"Ada@Example.com","accountType":"META_ACCOUNT"}"# + /// The fixture clock; reset times are relative to it. + static let now = 1_790_341_873 + + /// A dev.meta.ai team quota. The login fixture reports "Muse Code Power Usage". + static func quota( + tier: String = "Muse Code Power Usage", + windowUsed: String = "0", + windowResetsAt: Int? = nil, + weeklyUsed: String = "9043782620", + weeklyResetsAt: Int = 1_790_553_600) -> String + { + let window = windowResetsAt.map { #","window_resets_at":\#($0)"# } ?? "" + return #"{"subscription_quota":{"tier_id":"1","tier":"\#(tier)","as_of":\#(Self.now),"# + + #""window_weighted_limit":"20000000000","window_duration_secs":18000,"# + + #""weekly_weighted_limit":"60000000000","weekly_resets_at":\#(weeklyResetsAt),"# + + #""window_weighted_used":"\#(windowUsed)","weekly_weighted_used":"\#(weeklyUsed)"\#(window)}}"# + } + + static func web(quota: String) -> @Sendable (String) -> (String, Int) { + { path in + switch path { + case "/api/auth/me": (Self.me, 200) + case "/api/portal/teams": (Self.teams, 200) + case "/api/portal/teams/\(Self.teamID)/subscription-quota": (quota, 200) + default: ("{}", 404) + } + } + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `selected team quota fills omitted login quotas`(engine: ProviderPluginEngineKind) async throws { + let requests = RequestLog() + let quota = Self.quota(windowUsed: "4000000000", windowResetsAt: Self.now + 3600) + let result = try await Self.fetchWithWeb(engine: engine, requests: requests, web: Self.web(quota: quota)) + let snapshot = result.usage + #expect(result.sourceLabel == "oauth+web") + #expect(snapshot.primary?.usedPercent == 20) + #expect(snapshot.primary?.windowMinutes == 300) + #expect(snapshot.primary?.resetsAt == Date(timeIntervalSince1970: TimeInterval(Self.now + 3600))) + 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)) + // The team is user-selected, so the reading is not reported as the CLI login's own exact usage. + #expect(snapshot.dataConfidence == .estimated) + #expect(snapshot.identity?.loginMethod == "Muse Code Power Usage") + let browser = try #require(snapshot.details.first { $0.title == "Browser team quota (dev.meta.ai)" }) + #expect(browser.rows.contains { $0.label == "Team" && $0.value == "My Team" }) + #expect(browser.rows.contains { $0.label == "Weekly" && $0.value == "15%" }) + let login = try #require(snapshot.details.first { $0.title == "Muse Code subscription" }) + #expect(!login.rows.contains { $0.label == "Quota" }) + let web = requests.all.filter { $0.url?.host == "dev.meta.ai" } + #expect(web.map { $0.url?.path ?? "" } == [ + "/api/auth/me", + "/api/portal/teams", + "/api/portal/teams/\(Self.teamID)/subscription-quota", + ]) + #expect(web.allSatisfy { + $0.value(forHTTPHeaderField: "Cookie") == "llama_dev_sess=fixture" + && $0.value(forHTTPHeaderField: "Authorization") == nil + }) + } + + @Test(arguments: [ + (Self.quota(), "idle"), + (Self.quota(windowUsed: "4000000000", windowResetsAt: Self.now - 60), "expired"), + ], BundledPluginTestSupport.engines) + func `idle and expired five hour windows carry no usage or reset`( + fixture: (quota: String, name: String), + engine: ProviderPluginEngineKind) async throws + { + let result = try await Self.fetchWithWeb(engine: engine, web: Self.web(quota: fixture.quota)) + #expect(result.sourceLabel == "oauth+web") + #expect(result.usage.primary?.usedPercent == 0) + #expect(result.usage.primary?.resetsAt == nil) + #expect(result.usage.secondary != nil) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `a weekly quota past its reset withholds the whole browser reading`( + engine: ProviderPluginEngineKind) async throws + { + let quota = Self.quota(windowUsed: "4000000000", windowResetsAt: Self.now + 3600, weeklyResetsAt: Self.now - 60) + let result = try await Self.fetchWithWeb(engine: engine, web: Self.web(quota: quota)) + #expect(result.sourceLabel == nil) + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + } + + @Test(arguments: ["\"invalid\"", "1e30", "-1", "true", "null"], BundledPluginTestSupport.engines) + func `invalid five hour resets never invent an idle window`( + reset: String, + engine: ProviderPluginEngineKind) async throws + { + let quota = Self.quota(windowUsed: "4000000000", windowResetsAt: Self.now + 3600) + .replacingOccurrences(of: "\"window_resets_at\":\(Self.now + 3600)", with: "\"window_resets_at\":\(reset)") + let result = try await Self.fetchWithWeb(engine: engine, web: Self.web(quota: quota)) + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.dataConfidence == .unknown) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `selected quota retains the team list for settings`(engine: ProviderPluginEngineKind) async throws { + let result = try await Self.fetchWithWeb(engine: engine, web: Self.web(quota: Self.quota())) + let teams = try #require(result.usage.details.first { $0.title == "Browser teams" }) + #expect(teams.rows.contains { $0.label == "My Team" && $0.value == Self.teamID }) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `a session without teams reads no quota`(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": (#"{"teams":[]}"#, 200) + default: (Self.quota(), 200) + } + } + #expect(result.usage.secondary == nil) + #expect(!requests.all.contains { $0.url?.path.hasSuffix("/subscription-quota") == true }) + } + + @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, + web: Self.web(quota: Self.quota())) + #expect(result.sourceLabel == nil) + #expect(result.usage.primary?.usedPercent == 96) + #expect(!requests.all.contains { $0.url?.host == "dev.meta.ai" }) + } + + @Test(arguments: [String?.none, " "], BundledPluginTestSupport.engines) + func `without a selected team the visible teams are listed and no quota is read`( + teamID: String?, + engine: ProviderPluginEngineKind) async throws + { + let requests = RequestLog() + let result = try await Self.fetchWithWeb( + engine: engine, + teamID: teamID, + requests: requests, + web: Self.web(quota: Self.quota())) + #expect(result.sourceLabel == nil) + #expect(result.usage.secondary == nil) + #expect(!requests.all.contains { $0.url?.path.hasSuffix("/subscription-quota") == true }) + let teams = try #require(result.usage.details.first { $0.title == "Browser teams" }) + #expect(teams.rows.contains { $0.label == "My Team" && $0.value == Self.teamID }) + #expect(teams.rows.contains { $0.label == "Status" }) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `a team the session cannot see is never queried`(engine: ProviderPluginEngineKind) async throws { + let requests = RequestLog() + let result = try await Self.fetchWithWeb( + engine: engine, + teamID: "123", + requests: requests, + web: { path in + path == "/api/portal/teams/123/subscription-quota" + ? (Self.quota(), 200) + : Self.web(quota: Self.quota())(path) + }) + #expect(result.usage.secondary == nil) + #expect(!requests.all.contains { $0.url?.path.hasSuffix("/subscription-quota") == true }) + let teams = try #require(result.usage.details.first { $0.title == "Browser teams" }) + #expect(teams.rows.contains { $0.label == "My Team" }) + } + + @Test(arguments: [ + #"{"teams":[{"team_id":"11","team_name":"Alpha"},{"team_id":"22","team_name":"Beta"}]}"#, + #"{"teams":[{"team_id":"22","team_name":"Beta"},{"team_id":"11","team_name":"Alpha"}]}"#, + ], BundledPluginTestSupport.engines) + func `the selected team decides the quota regardless of list order`( + teams: String, + engine: ProviderPluginEngineKind) async throws + { + let result = try await Self.fetchWithWeb(engine: engine, teamID: "22") { path in + switch path { + case "/api/auth/me": (Self.me, 200) + case "/api/portal/teams": (teams, 200) + case "/api/portal/teams/11/subscription-quota": (Self.quota(weeklyUsed: "6000000000"), 200) + case "/api/portal/teams/22/subscription-quota": (Self.quota(weeklyUsed: "48000000000"), 200) + default: ("{}", 404) + } + } + #expect(result.usage.secondary?.usedPercent == 80) + let browser = try #require(result.usage.details.first { $0.title == "Browser team quota (dev.meta.ai)" }) + #expect(browser.rows.contains { $0.label == "Team" && $0.value == "Beta" }) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `a team quota for a different plan is not shown`(engine: ProviderPluginEngineKind) async throws { + let quota = Self.quota(tier: "Muse Code Everyday Usage") + let result = try await Self.fetchWithWeb(engine: engine, web: Self.web(quota: quota)) + #expect(result.sourceLabel == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.identity?.loginMethod == "Muse Code Power Usage") + } + + @Test(arguments: [ + (#"{"error":"Not authenticated"}"#, 401), + (#"{"subscription_quota":null}"#, 200), + ( + Self.quota().replacingOccurrences( + of: #""window_weighted_limit":"20000000000""#, + with: #""window_weighted_limit":"0""#), + 200), + ("", 200), + (Self.quota().replacingOccurrences(of: "18000", with: "1e30"), 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.quota(), 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, + web: Self.web(quota: Self.quota())) + #expect(result.usage.secondary == nil) + #expect(!requests.all.contains { $0.url?.host == "dev.meta.ai" }) + } + + @Test(arguments: [200, 401, 403], BundledPluginTestSupport.engines) + func `wrong account and rejected sessions advance to the matching account`( + firstStatus: Int, + engine: ProviderPluginEngineKind) async throws + { + let next = LockIsolated(0) + let rejected = LockIsolated<[String]>([]) + let runtime = try BundledPluginTestSupport.runtime( + "muse", engine: engine, transport: ProviderHTTPTransportHandler { request in + guard request.url?.host == "dev.meta.ai" else { + return try Self.response(request, body: Self.activeWithoutWindows) + } + if request.value(forHTTPHeaderField: "Cookie") == "session=first" { + #expect(request.url?.path == "/api/auth/me") + return try Self.response(request, body: #"{"email":"other@example.com"}"#, status: firstStatus) + } + let (body, code) = Self.web(quota: Self.quota())(request.url?.path ?? "") + return try Self.response(request, body: body, status: code) + }) + let result = try await runtime.fetchResult( + settings: ["MUSE_WEB_TEAM_ID": Self.teamID], + secrets: ["MUSE_DEVICE_TOKEN": "dca:fixture-token"], + now: Date(timeIntervalSince1970: TimeInterval(Self.now)), + cookieSource: .auto, + cookieSessionResolver: { domain, _ in + #expect(domain == "dev.meta.ai") + let index = next.value + next.setValue(index + 1) + guard index < 2 else { return nil } + let name = index == 0 ? "first" : "matching" + return ProviderPluginCookieSession( + header: "session=\(name)", source: "fixture", origin: "https://dev.meta.ai", id: name) + }, + cookieSessionInvalidator: { _, id in rejected.setValue(rejected.value + [id]) }, + cookieResolver: { _, _ in "session=first" }) + #expect(result.sourceLabel == "oauth+web") + #expect(result.usage.secondary != nil) + #expect(rejected.value == (firstStatus == 200 ? [] : ["first"])) + } + + @Test(arguments: BundledPluginTestSupport.engines) + func `browser session retries stay within the request budget`(engine: ProviderPluginEngineKind) async throws { + let requests = RequestLog() + let runtime = try BundledPluginTestSupport.runtime( + "muse", engine: engine, transport: ProviderHTTPTransportHandler { request in + requests.append(request) + return try Self.response( + request, + body: request.url?.host == "dev.meta.ai" + ? #"{"email":"other@example.com"}"# : Self.activeWithoutWindows) + }) + let result = try await runtime.fetchResult( + settings: ["MUSE_WEB_TEAM_ID": Self.teamID], + secrets: ["MUSE_DEVICE_TOKEN": "dca:fixture-token"], + cookieSource: .auto, + cookieSessionResolver: { _, _ in + ProviderPluginCookieSession(header: "session=fixture", source: "fixture", origin: "https://dev.meta.ai") + }) + #expect(requests.all.filter { $0.url?.host == "dev.meta.ai" }.count == 5) + #expect(result.usage.secondary == nil) + #expect(result.usage.dataConfidence == .unknown) + } + + @Test(arguments: [ + (ProviderConfig?.none, String?.none), + (ProviderConfig(id: .muse, cookieSource: .auto), nil), + (ProviderConfig(id: .muse, cookieSource: .auto, workspaceID: " 22 "), "22"), + ]) + func `the browser team comes only from the configured team ID`( + config: ProviderConfig?, + expected: String?) throws + { + let contribution = try #require(MuseProviderDescriptor.descriptor.settingsSection + .credentialContribution(context: ProviderCredentialSettingsContext(config: config, account: nil))) + let settings = ProviderSettingsSnapshot(contributions: [contribution]) + #expect(settings[MuseProviderSettingsKey.self]?.webTeamID == expected) + } + + @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) + } + + 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) } + } + } + + static func fetchWithWeb( + engine: ProviderPluginEngineKind, + account: String = Self.activeWithoutWindows, + cookieSource: ProviderCookieSource = .auto, + teamID: String? = Self.teamID, + 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( + settings: teamID.map { ["MUSE_WEB_TEAM_ID": $0] } ?? [:], + secrets: ["MUSE_DEVICE_TOKEN": "dca:fixture-token"], + now: Date(timeIntervalSince1970: TimeInterval(Self.now)), + 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/MuseScreenshotRenderTests.swift b/Tests/CodexBarTests/MuseScreenshotRenderTests.swift index 60fc08cbb1..113bedb968 100644 --- a/Tests/CodexBarTests/MuseScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/MuseScreenshotRenderTests.swift @@ -6,6 +6,46 @@ import XCTest @MainActor final class MuseScreenshotRenderTests: XCTestCase { + func test_renderBrowserTeamQuota() async throws { + guard let path = ProcessInfo.processInfo.environment["CODEXBAR_MUSE_TEAM_SCREENSHOT_DIR"] else { + throw XCTSkip("Set CODEXBAR_MUSE_TEAM_SCREENSHOT_DIR to render synthetic browser-team quotas.") + } + let directory = URL(fileURLWithPath: path, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let before = try await MusePluginTests.fetch(MusePluginTests.activeWithoutWindows, engine: .quickJS) + let after = try await MusePluginTests.fetchWithWeb( + engine: .quickJS, web: MusePluginTests.web(quota: MusePluginTests.quota())).usage + for (name, snapshot) in [("before", before), ("after", after)] { + let model = try UsageMenuCardView.Model.make(.init( + provider: .muse, + metadata: XCTUnwrap(ProviderDefaults.metadata[.muse]), + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: snapshot.loginMethod(for: .muse)), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .absolute, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: true, + usesLiveSubtitle: false, + now: snapshot.updatedAt)) + let hosting = NSHostingView(rootView: UsageMenuCardView(model: model, width: 380) + .environment(\.locale, Locale(identifier: "en")) + .environment(\.colorScheme, .light) + .environment(\.displayScale, 2) + .background(Color(nsColor: .windowBackgroundColor))) + hosting.appearance = NSAppearance(named: .aqua) + try XCTUnwrap(MenuLayoutScreenshotRenderTests.pngDataWithWindow(hosting: hosting)) + .write(to: directory.appendingPathComponent("muse-team-\(name).png")) + } + } + func test_renderTokenHistory() async throws { guard let path = ProcessInfo.processInfo.environment["CODEXBAR_MUSE_HISTORY_SCREENSHOT_DIR"] else { throw XCTSkip("Set CODEXBAR_MUSE_HISTORY_SCREENSHOT_DIR to render synthetic token history.") diff --git a/Tests/CodexBarTests/MuseSettingsDescriptorTests.swift b/Tests/CodexBarTests/MuseSettingsDescriptorTests.swift new file mode 100644 index 0000000000..4a7e9d6569 --- /dev/null +++ b/Tests/CodexBarTests/MuseSettingsDescriptorTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +extension ProviderSettingsDescriptorTests { + @Test + func `Muse browser team quota is opt in and the chosen team reaches the snapshot`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-muse") + let implementation = MuseProviderImplementation() + let context = fixture.settingsContext(provider: .muse) + let picker = try #require(implementation.settingsPickers(context: context).first) + let team = try #require(implementation.settingsPickers(context: context).first { $0.id == "muse-web-team-id" }) + let snapshotContext = ProviderSettingsSnapshotContext(settings: fixture.settings, tokenOverride: nil) + #expect(picker.binding.wrappedValue == "off") + #expect(team.isVisible?() == false) + let defaults = try ProviderSettingsSnapshot( + contributions: [#require(implementation.settingsSnapshot(context: snapshotContext))]) + #expect(defaults[MuseProviderSettingsKey.self]?.cookieSource == .off) + #expect(defaults[MuseProviderSettingsKey.self]?.webTeamID == nil) + picker.binding.wrappedValue = "auto" + team.binding.wrappedValue = " 424242424242 " + #expect(team.isVisible?() == true) + #expect(fixture.settings.providerConfig(for: .muse)?.workspaceID == "424242424242") + let chosen = try ProviderSettingsSnapshot( + contributions: [#require(implementation.settingsSnapshot(context: snapshotContext))]) + #expect(chosen[MuseProviderSettingsKey.self]?.cookieSource == .auto) + #expect(chosen[MuseProviderSettingsKey.self]?.webTeamID == "424242424242") + } + + @Test + func `Muse team picker never chooses the first visible team`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-muse-teams") + fixture.settings.museCookieSource = .auto + fixture.store.snapshots[.muse] = try UsageSnapshot( + primary: nil, + secondary: nil, + details: [.init(title: "Browser teams", rows: [ + .init(label: "Status", value: "Choose a browser team"), + .init(label: "Alpha", value: "11"), + .init(label: "Beta", value: "22"), + ])], + updatedAt: Date()) + let implementation = MuseProviderImplementation() + let context = fixture.settingsContext(provider: .muse) + let picker = try #require(implementation.settingsPickers(context: context) + .first { $0.id == "muse-web-team-id" }) + #expect(picker.options.map(\.id) == ["", "11", "22"]) + #expect(picker.options.last?.title == "Beta (22)") + #expect(picker.binding.wrappedValue.isEmpty) + picker.binding.wrappedValue = "22" + #expect(fixture.settings.museWebTeamID == "22") + fixture.store.snapshots[.muse] = nil + let unavailable = try #require(implementation.settingsPickers(context: context) + .first { $0.id == "muse-web-team-id" }) + #expect(unavailable.binding.wrappedValue == "22") + #expect(unavailable.options.map(\.id) == ["", "22"]) + #expect(unavailable.options.last?.title.contains("unavailable") == true) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 85125254c7..0654e7d0a1 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."), @@ -1912,13 +1912,13 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", line: 314, anchor: "if input.provider == .mimo, input.snapshot != nil {", - expectedProviderIDs: ["claude", "mimo", "opencodego"], - expectedReferenceCount: 3, - expectedReferenceFingerprint: ["mimo@0", "claude@4", "opencodego@10"], + expectedProviderIDs: ["claude", "mimo", "muse", "opencodego"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["mimo@0", "claude@4", "opencodego@10", "muse@15"], reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 544, + line: 549, anchor: "if input.provider == .factory, snapshot.tertiary != nil {", expectedProviderIDs: [ "alibabatokenplan", @@ -1948,7 +1948,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 712, + line: 717, anchor: "case .minimax:", expectedProviderIDs: ["codex", "minimax", "poe"], expectedReferenceCount: 3, @@ -1956,7 +1956,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 912, + line: 917, anchor: "if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage {", expectedProviderIDs: ["claude", "codex", "copilot"], expectedReferenceCount: 4, @@ -1964,7 +1964,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1007, + line: 1012, anchor: "guard provider == .kiro, namedWindow.id == \"kiro-overage\",", expectedProviderIDs: ["kiro"], expectedReferenceCount: 1, @@ -1972,7 +1972,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1035, + line: 1040, anchor: "if input.provider == .antigravity,", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, @@ -1980,7 +1980,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1071, + line: 1076, anchor: "if provider == .claude || provider == .cursor, window.windowMinutes != 10080 {", expectedProviderIDs: ["antigravity", "claude", "codex", "cursor"], expectedReferenceCount: 6, @@ -1995,7 +1995,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuCardView+ModelHelpers.swift", - line: 1105, + line: 1110, anchor: "guard input.provider == .antigravity else { return nil }", expectedProviderIDs: ["antigravity"], expectedReferenceCount: 1, diff --git a/docs/muse.md b/docs/muse.md index c29f03c17c..41743f7422 100644 --- a/docs/muse.md +++ b/docs/muse.md @@ -39,7 +39,33 @@ 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. + +## Browser team quota + +When `subs_usage` is missing, CodexBar can read the subscription quota of one `dev.meta.ai` team that you choose, using your browser session for `dev.meta.ai` (the `llama_dev_sess` cookie). A session can see more than one team, and nothing in the responses links a team to the CLI login, so CodexBar never picks a team for you. + +1. `GET https://dev.meta.ai/api/auth/me`. The session email must match the CLI login email, or CodexBar ignores the browser session. +2. `GET https://dev.meta.ai/api/portal/teams`. The selected **Browser team** must be in this list, or no quota is read. +3. `GET https://dev.meta.ai/api/portal/teams/{team_id}/subscription-quota` for the selected team only. Its `tier` must equal the plan in the login response (`subs_tier_name`), or the quota is ignored. + +Usage is `used / limit` for the 5-hour and weekly weighted limits. An idle 5-hour window with zero reported usage, or one whose valid reset time has passed, shows 0% with no reset time. A malformed reset, or nonzero usage without a reset, withholds the browser reading rather than inventing an idle window. If the weekly reset time has passed, or a limit is zero or missing, CodexBar shows no browser-team reading at all. The menu shows the values in a **Browser team quota (dev.meta.ai)** section with the team name, the source label becomes `oauth+web`, and the snapshot confidence is `estimated`, because the link between the team and the CLI login is your choice, not something Meta reports. The device-code token is sent only to `api.meta.ai`; `dev.meta.ai` requests carry only the browser cookie. + +Setup, in **Settings → Providers → Muse Code**: + +1. Set **Cookie source**. 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`. +2. Refresh Muse Code. When the login omits `subs_usage`, **Browser team** lists the teams the session can see, with their IDs. It starts at **Choose a team…** and never selects the first team automatically. The same list appears in the Muse menu. +3. Choose the team whose web quota you want to display, then refresh. A saved team that is no longer visible stays marked unavailable; CodexBar never switches to another team for you. + +Automatic mode tries later browser sessions when the first session is expired or belongs to another account. The fallback makes at most five web requests per refresh; Manual uses only the pasted session. + +In `~/.codexbar/config.json`, the team ID is the Muse entry's `workspaceID`: + +```json +{ "id": "muse", "cookieSource": "auto", "workspaceID": "" } +``` + +If the source is off, there is no session, the session belongs to another account, the team is not selected or not visible, the plan differs, the request is rejected or times out, or the response has 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 usage. ## Local token history diff --git a/docs/providers.md b/docs/providers.md index 276c593f57..a666aaef3b 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`); opt-in `dev.meta.ai` browser-team quota for a user-selected team 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`). |