From fe80c80d84b49c4aca57039e1d8e3213c68d8a8d Mon Sep 17 00:00:00 2001 From: enieuwy <121954036+enieuwy@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:08:23 +0800 Subject: [PATCH 1/4] Muse Code: show a user-selected dev.meta.ai team quota when the login omits quotas --- CHANGELOG.md | 1 + .../CodexBar/MenuCardView+ModelHelpers.swift | 5 + .../Muse/MuseProviderImplementation.swift | 62 +++- .../Providers/Muse/MuseSettingsStore.swift | 23 ++ .../SettingsStore+MenuObservation.swift | 2 + Sources/CodexBar/UsageStore+Logging.swift | 1 + .../Muse/MuseProviderDescriptor.swift | 45 ++- .../Providers/Muse/MuseProviderSettings.swift | 29 ++ .../CodexBarCore/Resources/Plugins/muse.js | 222 ++++++++++-- .../CodexBarCore/Resources/Plugins/muse.ts | 143 +++++++- .../CodexBarTests/ConfigValidationTests.swift | 2 +- .../MuseMenuCardModelTests.swift | 45 +++ Tests/CodexBarTests/MusePluginTests.swift | 331 ++++++++++++++++++ .../MuseSettingsDescriptorTests.swift | 31 ++ .../ProviderArchitectureGatekeeperTests.swift | 24 +- docs/muse.md | 26 +- docs/providers.md | 2 +- 17 files changed, 939 insertions(+), 55 deletions(-) create mode 100644 Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift create mode 100644 Tests/CodexBarTests/MuseMenuCardModelTests.swift create mode 100644 Tests/CodexBarTests/MuseSettingsDescriptorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb3030872..4578d8d4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Usage & Spend: start long daily ledgers with the newest 30 rows and a Show all control, reducing initial layout work while preserving full-period totals and charts (#3998). 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), optionally show the quota of a user-selected `dev.meta.ai` team from an opt-in browser session for the same account and plan (Off by default; the menu lists the teams the session can see). - 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/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..9754f06938 100644 --- a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift @@ -10,15 +10,71 @@ 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] { + [ + 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 }), + ProviderSettingsFieldDescriptor( + id: "muse-web-team-id", + title: "Browser team ID", + subtitle: "The dev.meta.ai team whose quota fills in when the login response omits it. " + + "The Muse menu lists the teams your browser session can see.", + kind: .plain, + placeholder: "Team ID", + binding: context.binding(\.museWebTeamID), + actions: [], + isVisible: { context.settings.museCookieSource != .off }), + ] } } 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..85e7c2b8bf 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 (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 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..5c5b177025 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,171 @@ 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 { + 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; + }; + // 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) return undefined; + const listed = await _asyncOptionalChain([ + await get("/api/portal/teams"), + "optionalAccess", + async (_7) => _7.teams, + ]); + 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 ID 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 (!quota || typeof quota !== "object") return { 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 { note: "The selected team's plan differs from the Muse login" }; + } + const parsed = parseWebQuota(record); + return parsed ? { quota: { team, ...parsed } } : undefined; + } 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 || 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; + // 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; + if (!web.quota) { + 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 }), + ]); + 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 +295,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..551b7f2826 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,127 @@ 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 { + 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; + }; + // 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) return undefined; + const listed = (await get("/api/portal/teams"))?.teams; + 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 ID 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 (!quota || typeof quota !== "object") return { 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 { note: "The selected team's plan differs from the Muse login" }; + } + const parsed = parseWebQuota(record); + return parsed ? { quota: { team, ...parsed } } : undefined; + } 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 || 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; + // 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; + if (!web.quota) { + 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 }); + 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 +202,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..d37022a97d 100644 --- a/Tests/CodexBarTests/MusePluginTests.swift +++ b/Tests/CodexBarTests/MusePluginTests.swift @@ -142,6 +142,337 @@ 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: 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), + ], 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: [ + (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) + } + + 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, + 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/MuseSettingsDescriptorTests.swift b/Tests/CodexBarTests/MuseSettingsDescriptorTests.swift new file mode 100644 index 0000000000..03b1044856 --- /dev/null +++ b/Tests/CodexBarTests/MuseSettingsDescriptorTests.swift @@ -0,0 +1,31 @@ +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 fields = implementation.settingsFields(context: context) + let team = try #require(fields.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") + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index a5a9c18833..9513af3eb1 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..a53c44b6fd 100644 --- a/docs/muse.md +++ b/docs/muse.md @@ -39,7 +39,31 @@ 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 ID** 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, or one whose reset time has passed, shows 0% with no reset time. 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. Leave **Browser team ID** empty at first. The next refresh that lacks `subs_usage` adds a **Browser teams** section to the Muse menu, listing each team the session can see with its ID. The `team_id` in the `dev.meta.ai/usage` address is the same value. +3. Enter the ID of the team that holds your Muse Code subscription. + +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`). | From 2cd6b2e5b2b3f650981b6e81eed0101fbe97dcec Mon Sep 17 00:00:00 2001 From: enieuwy <121954036+enieuwy@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:08:23 +0800 Subject: [PATCH 2/4] Add Muse browser team quota proof --- .github/pr-proof/muse-web-team-quota.log | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/pr-proof/muse-web-team-quota.log 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%. From 9654e3d4bd4be8eabad9ba0a5d5cb152a7c75c0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 19:11:40 -0700 Subject: [PATCH 3/4] fix(muse): harden explicit browser team quotas List visible teams in settings without choosing one implicitly. Preserve a selected team that becomes unavailable, and disclose browser-team quota usage. Try later browser sessions after account mismatches or expired credentials, with a five-request budget. Keep malformed resets and durations unknown. Add synthetic regressions on both plugin engines and menu rendering proof. Move the release note to the current Unreleased section and credit enieuwy. Fixes #4002. Refs #4011. --- CHANGELOG.md | 5 +- .../Muse/MuseProviderImplementation.swift | 31 ++- .../Muse/MuseProviderDescriptor.swift | 2 +- .../CodexBarCore/Resources/Plugins/muse.js | 188 +++++++++--------- .../CodexBarCore/Resources/Plugins/muse.ts | 112 ++++++----- Tests/CodexBarTests/MusePluginTests.swift | 88 +++++++- .../MuseScreenshotRenderTests.swift | 40 ++++ .../MuseSettingsDescriptorTests.swift | 33 ++- docs/muse.md | 10 +- 9 files changed, 348 insertions(+), 161 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c63ed550a6..dbb295b201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 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! + ## 0.67.0 — 2026-09-25 ### Highlights @@ -39,7 +43,6 @@ - Usage & Spend: start long daily ledgers with the newest 30 rows and a Show all control, reducing initial layout work while preserving full-period totals and charts (#3998). 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), optionally show the quota of a user-selected `dev.meta.ai` team from an opt-in browser session for the same account and plan (Off by default; the menu lists the teams the session can see). - 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 9754f06938..835b65149b 100644 --- a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift @@ -33,7 +33,17 @@ struct MuseProviderImplementation: ProviderImplementation { /// 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, @@ -45,6 +55,15 @@ struct MuseProviderImplementation: ProviderImplementation { 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), ] } @@ -65,16 +84,6 @@ struct MuseProviderImplementation: ProviderImplementation { url: URL(string: "https://dev.meta.ai/usage")), ], isVisible: { context.settings.museCookieSource == .manual }), - ProviderSettingsFieldDescriptor( - id: "muse-web-team-id", - title: "Browser team ID", - subtitle: "The dev.meta.ai team whose quota fills in when the login response omits it. " - + "The Muse menu lists the teams your browser session can see.", - kind: .plain, - placeholder: "Team ID", - binding: context.binding(\.museWebTeamID), - actions: [], - isVisible: { context.settings.museCookieSource != .off }), ] } } diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift index 85e7c2b8bf..5ab051f7ea 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -100,7 +100,7 @@ struct MuseOAuthFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let token = try MuseCredentials.accessToken(environment: context.env) - // The key request (15 s) and the bounded dev.meta.ai fallback (4 × 8 s) fit one 60 s deadline. + // 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) diff --git a/Sources/CodexBarCore/Resources/Plugins/muse.js b/Sources/CodexBarCore/Resources/Plugins/muse.js index 5c5b177025..84afa20a51 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.js +++ b/Sources/CodexBarCore/Resources/Plugins/muse.js @@ -129,84 +129,94 @@ defineProvider({ 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; - }; - // 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) return undefined; - const listed = await _asyncOptionalChain([ - await get("/api/portal/teams"), - "optionalAccess", - async (_7) => _7.teams, - ]); - 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, + 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", - (_10) => _10("MUSE_WEB_TEAM_ID"), + (_3) => _3(root.user_email, "user_email"), "optionalAccess", - (_11) => _11.trim, + (_4) => _4.toLowerCase, "call", - (_12) => _12(), - ]), - () => "", - ); - const team = teams.find((candidate) => candidate.id === selected); - if (!selected) return { teams, note: "Choose a browser team ID 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 (!quota || typeof quota !== "object") return { 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 { note: "The selected team's plan differs from the Muse login" }; + (_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 }; } - const parsed = parseWebQuota(record); - return parsed ? { quota: { team, ...parsed } } : undefined; } catch (error) { void error; } @@ -233,10 +243,12 @@ defineProvider({ 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 || seconds < 60) 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; @@ -256,20 +268,18 @@ defineProvider({ if (!_optionalChain([web, "optionalAccess", (_14) => _14.quota])) rows.push({ label: "Quota", value: "Not included in this login response" }); if (!web) return snapshot; - if (!web.quota) { - 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 }), - ]); - 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 }, diff --git a/Sources/CodexBarCore/Resources/Plugins/muse.ts b/Sources/CodexBarCore/Resources/Plugins/muse.ts index 551b7f2826..1b3eabe9c6 100644 --- a/Sources/CodexBarCore/Resources/Plugins/muse.ts +++ b/Sources/CodexBarCore/Resources/Plugins/muse.ts @@ -85,52 +85,62 @@ defineProvider({ async function webQuota(): Promise<{ quota?: WebQuota; teams?: WebTeam[]; note?: string } | undefined> { 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; + 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 }); } - 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) return undefined; - const listed = (await get("/api/portal/teams"))?.teams; - 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 ID 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 (!quota || typeof quota !== "object") return { 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 { note: "The selected team's plan differs from the Muse login" }; + 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 }; } - const parsed = parseWebQuota(record); - return parsed ? { quota: { team, ...parsed } } : undefined; } catch (error) { void error; } @@ -157,10 +167,12 @@ defineProvider({ 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 || seconds < 60) 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; @@ -179,12 +191,10 @@ defineProvider({ const web = await webQuota(); if (!web?.quota) rows.push({ label: "Quota", value: "Not included in this login response" }); if (!web) return snapshot; - if (!web.quota) { - 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 }); - 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 }, diff --git a/Tests/CodexBarTests/MusePluginTests.swift b/Tests/CodexBarTests/MusePluginTests.swift index d37022a97d..6677dfe159 100644 --- a/Tests/CodexBarTests/MusePluginTests.swift +++ b/Tests/CodexBarTests/MusePluginTests.swift @@ -234,6 +234,26 @@ struct MusePluginTests { #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() @@ -338,6 +358,7 @@ struct MusePluginTests { 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), @@ -389,6 +410,69 @@ struct MusePluginTests { #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), @@ -420,7 +504,7 @@ struct MusePluginTests { #expect(settings[MuseProviderSettingsKey.self]?.cookieSource == expected) } - private final class RequestLog: @unchecked Sendable { + final class RequestLog: @unchecked Sendable { private let lock = NSLock() private var requests: [URLRequest] = [] private var rejectedDomains: [String] = [] @@ -441,7 +525,7 @@ struct MusePluginTests { } } - private static func fetchWithWeb( + static func fetchWithWeb( engine: ProviderPluginEngineKind, account: String = Self.activeWithoutWindows, cookieSource: ProviderCookieSource = .auto, 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 index 03b1044856..4a7e9d6569 100644 --- a/Tests/CodexBarTests/MuseSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/MuseSettingsDescriptorTests.swift @@ -10,8 +10,7 @@ extension ProviderSettingsDescriptorTests { let implementation = MuseProviderImplementation() let context = fixture.settingsContext(provider: .muse) let picker = try #require(implementation.settingsPickers(context: context).first) - let fields = implementation.settingsFields(context: context) - let team = try #require(fields.first { $0.id == "muse-web-team-id" }) + 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) @@ -28,4 +27,34 @@ extension ProviderSettingsDescriptorTests { #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/docs/muse.md b/docs/muse.md index a53c44b6fd..41743f7422 100644 --- a/docs/muse.md +++ b/docs/muse.md @@ -46,16 +46,18 @@ An active subscription whose mint response omits `subs_usage` or returns it as ` 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 ID** must be in this list, or no quota is read. +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, or one whose reset time has passed, shows 0% with no reset time. 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. +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. Leave **Browser team ID** empty at first. The next refresh that lacks `subs_usage` adds a **Browser teams** section to the Muse menu, listing each team the session can see with its ID. The `team_id` in the `dev.meta.ai/usage` address is the same value. -3. Enter the ID of the team that holds your Muse Code subscription. +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`: From bf7eebd443a566751c67a5de5977197d6297d6e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 21:18:21 -0700 Subject: [PATCH 4/4] chore: tidy changelog spacing after merging main --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index caea4dd698..d52bce5368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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! + ### Changed - Settings: simplify menu bar layout controls while keeping token-removal instructions in the section footer (#3999). Thanks @elijahfriedman!