From 7523244ed4c8547c5cbe2afda7fbec8ca7cddb89 Mon Sep 17 00:00:00 2001 From: PB <5535435+pekth@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:43:22 +0000 Subject: [PATCH] fix: map Claude plans and handle duplicate Desktop tokens --- CHANGELOG.md | 5 +++ docs/DESKTOP-CONNECTIONS.md | 5 +++ docs/KB.md | 8 ++++ docs/PRIVACY.md | 4 +- docs/plans/cliproxy-hub.md | 52 ++++++++++++++++++++++++++ src/main/providers/claude-desktop.ts | 9 +++-- src/main/providers/parsers.ts | 10 +++-- tests/connections.test.ts | 55 ++++++++++++++++++++++++++++ tests/providers.test.ts | 15 ++++++++ 9 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 docs/plans/cliproxy-hub.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e643b..3a240c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,11 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Read known Claude plan labels from the available tier fields without showing + internal identifiers or guessing a plan from an unrelated substring. +- Accept duplicate Claude Desktop cache entries when their eligible access + tokens are identical. Distinct matching tokens still require a clear login; + an ambiguous-token failure preserves the selected CLI source. - Preload the tray usage window hidden after completed onboarding, before connection recovery or provider collection. The first click reuses the loaded renderer instead of starting its initial load. diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index c3755f3..7bc96d0 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -38,6 +38,11 @@ below it to apply the choice. Existing profiles keep their current source. Keychain prompt. The candidate requests account allowance using Desktop's current access token. This interface can change and is not a documented third-party consumer API contract. + Duplicate eligible cache entries with the same access token count as one + credential. Different matching tokens remain ambiguous; MeterUsage does not + choose the first token or the one with the latest expiry. An ambiguous-token + failure leaves the previously selected CLI source available. This handling has + synthetic fixture coverage; live cache compatibility remains unverified. Alternatively, choose **Claude Code CLI** and **Use CLI** to read local Claude Code activity. CLI allowance requires a companion usage file; this option does not provide Claude website token history. diff --git a/docs/KB.md b/docs/KB.md index b920383..87b8422 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -41,6 +41,14 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. ## Product and source facts +- Electron Claude plan labels recognize known organization, seat and user tiers + in that order. An unknown higher-priority value permits a known fallback; + unknown-only metadata stays unavailable instead of exposing internal labels. + Claude Desktop treats identical eligible access tokens as one credential, + while distinct tokens remain ambiguous. Ambiguous-token Desktop connection + failures preserve selected CLI collection. These changes have synthetic fixture proof; live + Desktop compatibility remains separate. + - Unreleased Electron source adds opt-in Muse Code allowance with `muse serve` and the official experimental MSP `usage/read` method. The source starts disabled, reads no credential files and sends no model turn. It preserves diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index ba2cd6b..5263e76 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -108,7 +108,9 @@ transport; do not run the helper interactively or record its output. The main process decrypts `oauth:tokenCacheV2` from Claude Desktop's `config.json` and reads only the active organization cookie from a read-only cookie database. It selects account-scoped current access-token/expiry fields, skips refresh -tokens, and rejects ambiguous, legacy or expired entries. It never writes to +tokens, and rejects ambiguous, legacy or expired entries. Identical tokens in +eligible entries count as one credential; distinct tokens remain ambiguous. +It never writes to Claude stores or rotates their tokens. The access token authenticates a GET to diff --git a/docs/plans/cliproxy-hub.md b/docs/plans/cliproxy-hub.md new file mode 100644 index 0000000..38b1c88 --- /dev/null +++ b/docs/plans/cliproxy-hub.md @@ -0,0 +1,52 @@ +# CLIProxyAPI Hub connection + +Status: Research complete; implementation needs an existing saved-history source. + +## Goal and source contract + +Connect MeterUsage to an existing CLIProxyAPI history source and show retained +token activity grouped by provider and upstream account. Keep proxy activity +separate from local transcripts and subscription allowance to avoid double +counting or assigning activity to an unrelated account. + +CLIProxyAPI 8.0.35 exposes a usage queue that removes records when read. It does +not expose the legacy historical snapshot endpoints. This integration must not +consume that queue, change proxy configuration, or install a new collector. +A saved export or a documented history service must be identified first. + +Sources: [v8 usage contract](https://help.router-for.me/management/apiv8#usage), +[v8.0.35 routes](https://github.com/router-for-me/CLIProxyAPI/blob/ae3ee3be303fb07fb8173279923413005bf32177/internal/api/server_management_v8.go), +[legacy snapshot format](https://github.com/router-for-me/CLIProxyAPI/blob/db63f9b5d60e643ca060901cfe67ffc5ce63ac63/internal/api/handlers/management/usage.go). + +## Proposed minimum + +1. Verify the saved source's format, retention and non-destructive read contract. + Do not advertise complete history where coverage is unknown. +2. Add one explicit Hub connection in Settings. For an authenticated HTTP source, + keep its management key in main-process memory and OS-backed encrypted + storage; fail closed without secure storage. Reject redirects and credentials + embedded in URLs. Source details must precede transport implementation. +3. Read one bounded snapshot per refresh. Join reported `auth_index` values to + projected provider metadata where the source supplies that mapping. Keep + unknown or ambiguous identities in an unattributed group; model aliases are + not evidence of a subscription account. +4. Publish only numeric usage, dates and reduced display labels. Never publish + client API keys, credential files, email addresses, raw errors or headers. + Request counts must remain requests rather than sessions or messages. +5. Reuse the account heatmap's unavailable-day handling. Use reported total + tokens when component counters can overlap. Replace snapshots rather than + adding repeated cumulative totals. Do not merge Hub and local token totals. + +## Verification and limits + +Synthetic checks must cover authentication failures, cancellation, source +replacement, payload limits, identity ambiguity, missing dates, snapshot reset, +overlapping token categories and repeated polling. Renderer checks must verify +provider/account labels, unavailable history and separate local/Hub scope. +Native secure storage and the actual history source need independent execution +proof before runtime support is claimed. + +No Hub adapter or credential store is implemented by this plan. The existing +local Claude heatmap and Desktop allowance remain separate data sources. No +server deployment, queue consumer, provider login or app installation follows +from this plan. diff --git a/src/main/providers/claude-desktop.ts b/src/main/providers/claude-desktop.ts index 0ec4e6a..e5f9be1 100644 --- a/src/main/providers/claude-desktop.ts +++ b/src/main/providers/claude-desktop.ts @@ -43,12 +43,13 @@ export function desktopCredential(config: string, organization: string, password const entry = object(value), scopes = match?.[4].split(/\s+/); return match && match[1].toLowerCase() === account && uuid.test(match[2]) && match[3].toLowerCase() === organization.toLowerCase() && scopes?.includes("user:profile") && scopes.includes("user:inference") && typeof entry.expiresAt === "number" && Number.isFinite(entry.expiresAt) && entry.expiresAt > now + 120000 && !!text(entry.token); }); - // Fail closed for ambiguous caches instead of guessing which login to use. - if (candidates.length !== 1) { - if (candidates.length > 1) reading?.("ambiguous"); + // Identical eligible tokens are one credential; distinct tokens stay ambiguous. + const tokens = [...new Set(candidates.map(([, value]) => object(value).token))]; + if (tokens.length !== 1) { + if (tokens.length > 1) reading?.("ambiguous"); throw unavailable(); } - return { token: text(object(candidates[0][1]).token)!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") }; + return { token: text(tokens[0])!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") }; } finally { plaintext.fill(0); } } catch { throw unavailable(); } } diff --git a/src/main/providers/parsers.ts b/src/main/providers/parsers.ts index d2f77bb..66e1254 100644 --- a/src/main/providers/parsers.ts +++ b/src/main/providers/parsers.ts @@ -79,10 +79,12 @@ export function parseClaudeQuota(s: string, now = Date.now()): Quota { export const claudeTierSelection = { oauthAccount: { organizationRateLimitTier: true, seatTier: true, userRateLimitTier: true } } as const; export function parseClaudePlan(s: string): string { const r = object(selectJSON(s, claudeTierSelection)), a = object(r.oauthAccount); - const tier = text(a.organizationRateLimitTier) ?? text(a.seatTier) ?? text(a.userRateLimitTier); - if (!tier) throw new Unavailable("noData"); - const v = tier.toLowerCase(); - return /max_?20x/.test(v) ? "Max 20×" : /max_?5x/.test(v) ? "Max 5×" : v.includes("enterprise") ? "Enterprise" : v.includes("team") ? "Team" : v.includes("pro") ? "Pro" : v.includes("free") ? "Free" : displayText(tier) ?? "Unknown"; + const labels: Record = { max_20x: "Max 20×", max20x: "Max 20×", max_5x: "Max 5×", max5x: "Max 5×", pro: "Pro", free: "Free", team: "Team", enterprise: "Enterprise" }; + for (const tier of [a.organizationRateLimitTier, a.seatTier, a.userRateLimitTier].map(text)) { + const key = tier?.toLowerCase().replace(/^default_claude_/, ""), label = key && Object.hasOwn(labels, key) ? labels[key] : undefined; + if (label) return label; + } + throw new Unavailable("noData"); } export function parseOpenRouter(s: string, creditsData?: string, now = Date.now()): Quota { const r = object(json(s).data), usedRaw = number(r.usage), used = usedRaw === undefined ? undefined : Math.max(0, usedRaw), limit = number(r.limit); diff --git a/tests/connections.test.ts b/tests/connections.test.ts index 09b062f..ea7a4b1 100644 --- a/tests/connections.test.ts +++ b/tests/connections.test.ts @@ -499,6 +499,61 @@ it("selects only current account/org access tokens and rejects stale, ambiguous expect(() => desktopCredential(config({ [cacheKey()]: entry() }), "other-org", password, now)).toThrow(); expect(() => desktopCredential(config({ [cacheKey()]: entry() }), org, "wrong-password", now)).toThrow(); }); +it.each([ + cacheKey().replace(client, "55555555-5555-5555-5555-555555555555"), + cacheKey().replace("user:profile user:inference", "user:inference user:profile"), +])("accepts identical eligible Claude tokens under cache key %s regardless of record order", duplicateKey => { + const entries = { + [cacheKey()]: entry(), + [duplicateKey]: entry("synthetic-access-one", now + 240000), + [cacheKey().replace(client, "66666666-6666-6666-6666-666666666666")]: entry("synthetic-expiring", now + 120000), + [cacheKey("44444444-4444-4444-4444-444444444444")]: entry("synthetic-other-account"), + [cacheKey().replace(org, "77777777-7777-7777-7777-777777777777")]: entry("synthetic-other-org"), + [cacheKey().replace("api.anthropic.com", "example.invalid")]: entry("synthetic-other-host"), + [cacheKey().replace(" user:inference", "")]: entry("synthetic-missing-scope"), + }; + const expected = desktopCredential(config({ [cacheKey()]: entry() }), org, password, now); + for (const records of [Object.entries(entries), Object.entries(entries).reverse()]) { + expect(desktopCredential(config(Object.fromEntries(records)), org, password, now)).toEqual(expected); + } +}); +it.each(["identical", "distinct"])("handles %s Claude cache tokens when switching from CLI to Desktop", async kind => { + const { launch, preferences } = await prefs(); desktop(launch.home); + const root = join(launch.home, "Library/Application Support/Claude"); + writeFileSync(join(root, "config.json"), config({ + [cacheKey()]: entry(), + [cacheKey().replace(client, "55555555-5555-5555-5555-555555555555")]: entry(kind === "identical" ? "synthetic-access-one" : "synthetic-access-two"), + })); + const before = ["config.json", "Cookies"].map(name => readFileSync(join(root, name))); + mkdirSync(launch.data, { recursive: true }); + writeFileSync(join(launch.data, "claude-usage.json"), '{"five_hour":{"used_percentage":17}}'); + const helper = vi.fn(async () => password), http = vi.fn(async () => '{"five_hour":{"utilization":25}}'); + const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, helper, http, () => now); + const source = () => compose(launch, preferences, { connections }).find(s => s.slot.provider === "claude")!; + expect(connections.state.find(s => s.provider === "claude")?.source).toBe("cli"); + expect((await source().quota!()).windows[0].usedPercent).toBe(17); + if (kind === "identical") { + expect(await connections.connect("claude", async () => {}, async () => true)).toBe(true); + expect(connections.state.find(s => s.provider === "claude")).toMatchObject({ source: "account", status: "connected" }); + const connected = source(); + expect(connected.accountBound).toBe(true); expect(connected.activity).toBeUndefined(); expect(connected.plan).toBeUndefined(); + expect((await connected.quota!()).windows[0].usedPercent).toBe(25); + expect(http.mock.calls.map(([url, , token]) => [url, token])).toEqual(Array(2).fill([endpoints.claudeAllowance, "synthetic-access-one"])); + expect(helper.mock.calls.map(([, args]) => args)).toEqual([["--allow-ui"], [], [], []]); + await connections.useLocal("claude"); + await expect(connected.quota!()).rejects.toMatchObject({ code: "notSignedIn" }); + expect(http).toHaveBeenCalledTimes(2); expect(helper).toHaveBeenCalledTimes(4); + } else { + await expect(connections.connect("claude", async () => {}, async () => true)).rejects.toThrow("multiple matching allowance tokens"); + expect(connections.state.find(s => s.provider === "claude")?.status).toBe("failed"); + expect(http).not.toHaveBeenCalled(); expect(helper).toHaveBeenCalledOnce(); + } + expect(preferences.values.desktopClaudeIdentity).toBe(""); + expect(connections.state.find(s => s.provider === "claude")).toMatchObject({ source: "cli", localAvailable: true }); + expect(source().accountBound).toBeUndefined(); expect(source().activity).toBeTypeOf("function"); expect(source().plan).toBeTypeOf("function"); + expect((await source().quota!()).windows[0].usedPercent).toBe(17); + for (const [i, name] of ["config.json", "Cookies"].entries()) expect(readFileSync(join(root, name))).toEqual(before[i]); +}); it("normalizes local Claude file, SQLite, decryption, JSON and helper failures to notSignedIn", async () => { const home = temp(); await expect(readDesktopCredential(home, password, now)).rejects.toMatchObject({ code: "notSignedIn" }); diff --git a/tests/providers.test.ts b/tests/providers.test.ts index 0a59f6b..4eab5f6 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -25,6 +25,21 @@ describe("selective privacy boundary", () => { expect(scalars).toEqual(['"max_20x"']); expect(JSON.stringify(result)).not.toContain("private"); expect(parseClaudePlan(input)).toBe("Max 20×"); }); + it("recognizes the default Claude Max 5x tier alias", () => { + expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"default_claude_max_5x"}}`)).toBe("Max 5×"); + }); + it("falls back from an unknown organization tier without exposing internal metadata", () => { + const scalars: string[] = []; + const input = String.raw`{"oauthAccount":{"organizationRateLimit\u0054ier":"internal_org_42","seatTier":"default_claude_ai","userRateLimitTier":"max_5x","email":"private@example.invalid","unknown":"private_metadata"}}`; + selectJSON(input, claudeTierSelection, scalar => scalars.push(scalar)); + expect(parseClaudePlan(input)).toBe("Max 5×"); + expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"internal_profile","seatTier":"max_5x"}}`)).toBe("Max 5×"); + expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"project_internal","seatTier":"max_5x"}}`)).toBe("Max 5×"); + expect(scalars).toEqual(['"internal_org_42"', '"default_claude_ai"', '"max_5x"']); + expect(() => parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"internal_org_42","seatTier":"default_claude_ai"}}`)).toThrow(); + expect(() => parseClaudePlan(String.raw`{"oauthAccount":{}}`)).toThrow(); + expect(parseClaudePlan(String.raw`{"oauthAccount":{"organizationRateLimitTier":"pro","seatTier":"max_5x"}}`)).toBe("Pro"); + }); it("skips transcript bodies and tools before scalar decoding", () => { const input = JSON.stringify({ type: "assistant", timestamp: "2026-10-06T11:00:00Z", message: { model: "claude-sonnet-4-6", content: [{ text: "PRIVATE_CONTENT" }], tool: { arguments: "PRIVATE_ARGUMENTS" }, usage: { input_tokens: 0, output_tokens: 7 } } }); const scalars: string[] = []; selectJSON(input, claudeLineSelection, scalar => scalars.push(scalar));