claude: a Team seat and a personal plan of one email keep their own usage (netfishx on X: with several Claude Code accounts the Usage page's allowances and plans got mixed up) — ClaudeCodeMovedOff matched "me@x.com · Acme" and "me@x.com" as one account, so once Claude Code was switched from one to the other, a /usage run asked for the first (by a caller that looked before the switch) and a rate_limit_event of a run started on it were kept as the first's: the seat's card showed the personal account's figures, and the other way round. Claude Code's account named from its profile (~/.claude.json) is now told by its whole name; the email-alone match stays only for a name from claude auth status, which gives no org. The single-account Usage card reads its allowance under the name magpie gives the account (claudeAccount), not status's email, so a seat's own reading and what it said answering are found under one key. Not tried with two real Claude accounts of one email (only one here). claude_seats_test (Claude Code moved seat → personal and back; the seat keeps its 5% after /usage reads 100% on the personal account) fails without the fix and passes with -race -count=3; go test ./internal/provider/ ./internal/gateway/, go vet, linux(nogui)/windows/nogui builds pass.
This commit is contained in:
1 parent
391c45e367
commit
fdf8b01e04
3 files changed
+103
-9
No files matched your search
@@ -302,19 +302,30 @@ func (a *Account) AgentsOwn() bool { return a != nil && a.token == nil }
|
||||
// ClaudeCodeMovedOff says Claude Code itself is signed in to another
|
||||
// account than user now: what a Claude Code run in its own home says of
|
||||
// the account it is on is no longer user's. false when it can't be told.
|
||||
// Named from its profile, the account is told by its whole name: a Team
|
||||
// seat and a personal plan of one email ("me@x.com · Acme", "me@x.com")
|
||||
// are two accounts, and one's usage isn't the other's (netfishx on X).
|
||||
func ClaudeCodeMovedOff(user string) bool {
|
||||
on := claudeCodeOn()
|
||||
return on != "" && !sameClaudeUser(on, user)
|
||||
on, named := claudeCodeOn()
|
||||
if on == "" {
|
||||
return false
|
||||
}
|
||||
if named {
|
||||
return !strings.EqualFold(strings.TrimSpace(on), strings.TrimSpace(user))
|
||||
}
|
||||
return !sameClaudeUser(on, user)
|
||||
}
|
||||
|
||||
// claudeCodeOn is the account Claude Code itself is signed in to now, as
|
||||
// magpie names it; "" when signed out or unknown.
|
||||
func claudeCodeOn() string {
|
||||
// magpie names it; "" when signed out or unknown. named: the name is from
|
||||
// its profile (~/.claude.json), whole, not the email alone `claude auth
|
||||
// status` gives.
|
||||
func claudeCodeOn() (user string, named bool) {
|
||||
l, ok := liveLogin("claude")
|
||||
if !ok {
|
||||
return ""
|
||||
return "", false
|
||||
}
|
||||
return l.User
|
||||
return l.User, len(l.Profile) > 0
|
||||
}
|
||||
|
||||
// sameClaudeUser: a and b name one account, the one as magpie names a Team
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// claudeOnSeat signs Claude Code in to me@x.com's subscription of org,
|
||||
// as /login leaves it: the sign-in and the profile naming its org.
|
||||
func claudeOnSeat(t *testing.T, home, cred, org, orgID, plan string) {
|
||||
t.Helper()
|
||||
writeFile(t, filepath.Join(home, ".claude.json"), map[string]any{"oauthAccount": map[string]any{"emailAddress": "me@x.com",
|
||||
"organizationName": org, "organizationUuid": orgID}})
|
||||
writeFile(t, cred, map[string]any{"claudeAiOauth": map[string]any{"accessToken": "tok-" + orgID, "refreshToken": "r-" + orgID,
|
||||
"expiresAt": time.Now().Add(time.Hour).UnixMilli(), "subscriptionType": plan}})
|
||||
forgetAccountCaches()
|
||||
}
|
||||
|
||||
// A Team seat and a personal Max of one email are two accounts: Claude
|
||||
// Code moved from one to the other is moved off it, either way (netfishx
|
||||
// on X: several Claude accounts' usage and plans mixed up).
|
||||
func TestClaudeSeatAndPersonalOfOneEmailAreTwo(t *testing.T) {
|
||||
home := claudeHome(t)
|
||||
cred := claudeSignIn(t, home, time.Now().Add(time.Hour))
|
||||
claudeOnSeat(t, home, cred, "Acme", "org-acme", "team")
|
||||
if u, _ := claudeCodeOn(); u != "me@x.com · Acme" {
|
||||
t.Fatalf("on %q", u)
|
||||
}
|
||||
if ClaudeCodeMovedOff("me@x.com · Acme") || ClaudeCodeMovedOff("ME@x.com · Acme ") {
|
||||
t.Error("Claude Code on the Acme seat taken as moved off it")
|
||||
}
|
||||
if !ClaudeCodeMovedOff("me@x.com") {
|
||||
t.Error("Claude Code on the Acme seat taken as on the personal me@x.com")
|
||||
}
|
||||
claudeOnSeat(t, home, cred, "me@x.com's Organization", "org-me", "max")
|
||||
if u, _ := claudeCodeOn(); u != "me@x.com" {
|
||||
t.Fatalf("on %q", u)
|
||||
}
|
||||
if !ClaudeCodeMovedOff("me@x.com · Acme") {
|
||||
t.Error("Claude Code on the personal me@x.com taken as still on the Acme seat")
|
||||
}
|
||||
if ClaudeCodeMovedOff("me@x.com") {
|
||||
t.Error("Claude Code on the personal me@x.com taken as moved off it")
|
||||
}
|
||||
}
|
||||
|
||||
// The seat's reading isn't overwritten by /usage run once Claude Code is
|
||||
// on the personal account of the same email.
|
||||
func TestClaudeSeatKeepsItsOwnUsage(t *testing.T) {
|
||||
home := claudeHome(t)
|
||||
cred := claudeSignIn(t, home, time.Now().Add(time.Hour))
|
||||
claudeOnSeat(t, home, cred, "Acme", "org-acme", "team")
|
||||
var out atomic.Value
|
||||
out.Store("Current session: 5% used · resets " + soon(1) + " at 3:30pm (UTC)\n")
|
||||
fakeClaudeUsage(t, &out, nil)
|
||||
AskClaudeUsage()
|
||||
if ws, err := claudeWindows(context.Background(), "me@x.com · Acme", true); err != nil || len(ws) == 0 || ws[0].Used != 5 {
|
||||
t.Fatalf("seat: %v %+v", err, ws)
|
||||
}
|
||||
claudeOnSeat(t, home, cred, "me@x.com's Organization", "org-me", "max")
|
||||
out.Store("Current session: 100% used · resets " + soon(1) + " at 3:30pm (UTC)\n")
|
||||
claudeUsage.Lock()
|
||||
for k, e := range claudeUsage.m {
|
||||
e.tried = e.tried.Add(-claudeAskFloor)
|
||||
claudeUsage.m[k] = e
|
||||
}
|
||||
claudeUsage.Unlock()
|
||||
AskClaudeUsage()
|
||||
// asked as the active account by a caller that looked before the switch
|
||||
if ws, _ := claudeWindows(context.Background(), "me@x.com · Acme", true); len(ws) == 0 || ws[0].Used != 5 {
|
||||
t.Fatalf("the seat shows the personal account's reading: %+v", ws)
|
||||
}
|
||||
if ws, err := claudeWindows(context.Background(), "me@x.com", true); err != nil || len(ws) == 0 || ws[0].Used != 100 {
|
||||
t.Fatalf("personal: %v %+v", err, ws)
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ func fetchSubscriptionUsage(ctx context.Context) []SubscriptionQuota {
|
||||
if ls := accountsOf("claude"); len(ls) > 1 || p.Account.standIn {
|
||||
fetches = append(fetches, perLogin(via("claude"), ls, "Claude Code", "claude-color")...)
|
||||
} else {
|
||||
fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(viaLogin("claude", p.Account.User)) }))
|
||||
fetches = append(fetches, withUser(ctx, p.Account.User, func() SubscriptionQuota { return claudeSubscriptionUsage(viaLogin("claude", p.Account.User), p.Account.User) }))
|
||||
}
|
||||
}
|
||||
if user, plan, ok := cursorIdentity(); !moved("cursor") && ok && !hidden["cursor"] {
|
||||
@@ -491,13 +491,17 @@ type accountStatusError struct {
|
||||
|
||||
func (e *accountStatusError) Error() string { return http.StatusText(e.status) }
|
||||
|
||||
func claudeSubscriptionUsage(ctx context.Context) SubscriptionQuota {
|
||||
// claudeSubscriptionUsage is the allowance of user, the account Claude Code
|
||||
// is signed in to, by the name magpie gives it (claudeAccount): its
|
||||
// readings and what it said answering are kept under that name, which for
|
||||
// a Team seat isn't the email alone `claude auth status` gives.
|
||||
func claudeSubscriptionUsage(ctx context.Context, user string) SubscriptionQuota {
|
||||
q := SubscriptionQuota{Provider: "claude", Name: "Claude Code", Icon: "claude-color", Windows: []QuotaWindow{}}
|
||||
if _, _, ok := claudeCredential(); !ok {
|
||||
q.Error = "Claude Code is signed out; run claude auth login"
|
||||
return q
|
||||
}
|
||||
user, plan, _ := claudeIdentity()
|
||||
_, plan, _ := claudeIdentity()
|
||||
q.Plan = plan
|
||||
var err error
|
||||
q.Windows, err = claudeWindows(ctx, user, true)
|
||||
|
||||
Reference in new issue
Block a user