provider: WorkBuddy is a subscription — Tencent's CodeBuddy plan, signed in as WorkBuddy does, its own account read too

WorkBuddy (CodeBuddy Code's desktop app) keeps its signed-in session in
<shared data>/auth/workbuddy-desktop.info, plain JSON while credential
protection is off (its default); magpie reads that account, never writes
it, and skips it when the tokens are encrypted at rest. Further accounts
are signed in with WorkBuddy's own external-link flow (auth/state, then
auth/token polled until ready, then login/account) and kept in
logins.json. The plan is served OpenAI-compatibly at copilot.tencent.com
/v2 under the account's access token, with its id and domain in headers
(X-User-Id, X-Domain), exactly as WorkBuddy sends them; a near-expired
JWT is refreshed as WorkBuddy does, and a magpie-signed-in one's refresh
is written back beside it while WorkBuddy's own file is left untouched.
The models are its CLI agent's coding picks, and the credits come from
its resource summary as one window.

A card in Providers' subscription list signs in and shows the accounts;
the Provider is one of the account ids and joins the usage rollup.
This commit is contained in:
yetone committed 2026-09-28 16:31:11 +08:00
1 parent 3bc9a744c8
commit 9687f3d297
12 files changed
+951 -2

No files matched your search

+3
View File
@@ -1392,6 +1392,7 @@ function accountPlan(a) {
if (a.agent === "grok") return a.plan || "SuperGrok";
if (a.agent === "gemini" || a.agent === "antigravity") return a.plan || "Google";
if (a.agent === "zcode") return a.plan || "GLM Coding Plan";
if (a.agent === "workbuddy") return a.plan || "WorkBuddy";
return t("signed in");
}
@@ -3322,6 +3323,8 @@ const SUBS = [
{ agent: "copilot", name: "Copilot", icon: "githubcopilot", plans: "Pro · Pro+ · Business", own: true },
// Z.ai's GLM Coding Plan, signed in as ZCode does; ZCode's own account is read too
{ agent: "zcode", name: "ZCode (GLM Coding Plan)", icon: "zcode", plans: "Lite · Pro · Max", own: true },
// Tencent's CodeBuddy plan, signed in as WorkBuddy does; WorkBuddy's own account is read too
{ agent: "workbuddy", name: "WorkBuddy (CodeBuddy)", icon: "workbuddy-color", plans: "Free · Pro", own: true },
// devin's credentials.toml keeps one account too
{ agent: "devin", name: "Devin", icon: "devin", plans: "Pro · Enterprise", single: true },
// Google's sign-ins; Gemini CLI's own account is read too
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8" ?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64.0003" height="64"><defs><linearGradient id="gradient_0" gradientTransform="matrix(3.91889e-15 64 -64.0003 3.91887e-15 32.0002 -1.95943e-15)" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#0EC7A8"/><stop offset="1" stop-color="#00C885"/></linearGradient></defs><rect fill="url(#gradient_0)" width="64.0003" height="64" rx="13.8106" ry="13.8106"/><defs><filter id="filter_0" x="-3" y="13" width="78" height="69" filterUnits="userSpaceOnUse"><feGaussianBlur stdDeviation="4.4002" result="blur"/></filter></defs><g transform="matrix(1 0 0 1 18 18)" filter="url(#filter_0)"><clipPath id="clip_0"><path d="M63.9867 13.197Q63.9935 13.3503 63.9969 13.5037Q64.0003 13.6571 64.0003 13.8106L64.0003 50.1894Q64.0003 55.91 59.9553 59.955Q55.9103 64 50.1898 64L13.8106 64Q8.09 64 4.045 59.955Q0 55.91 0 50.1894L0 13.8106Q0 13.6571 0.0034 13.5037Q0.0068 13.3503 0.0136 13.197L63.9867 13.197Z" clip-rule="evenodd"/></clipPath><g clip-path="url(#clip_0)"><circle fill="#FFE255" fill-opacity="0.49" transform="matrix(1 0 0 1 14.7525 31.197)" cx="20.5928" cy="20.5928" r="20.5928"/></g></g><clipPath id="clip_1"><rect width="64.0003" height="64" rx="13.8106" ry="13.8106"/></clipPath><g clip-path="url(#clip_1)"><defs><linearGradient id="gradient_1" gradientTransform="matrix(4.35136e-15 60.044 78.3872 0 39.1936 -2.86512e-06)" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#FFF" stop-opacity="0.8"/><stop offset="0.4377" stop-color="#FFF"/><stop offset="1" stop-color="#FFF"/></linearGradient></defs><path fill="url(#gradient_1)" fill-opacity="1" transform="matrix(0.866025 -0.5 0.5 0.866025 -7.25994 37.2434)" d="M10.4632 2.7569C11.0168 1.0854 11.4183 0.4342 12.0804 0.1628C12.482 0 13.4155 0.1302 14.4249 0.4884C16.9539 1.411 21.7513 4.6672 26.755 8.8677L27.276 9.3018L29.1971 8.9111C32.3882 8.2598 34.3961 8.0211 37.9128 7.9017C41.5815 7.7714 45.4237 8.1405 49.4831 9.0196L50.7856 9.3018L52.3051 8.0645C57.7647 3.6035 62.4102 0.6729 64.798 0.1845C65.6229 0.0109 65.6663 0.0109 66.0788 0.2171C66.7517 0.5427 67.1642 1.1939 67.6418 2.6918C68.7597 6.1867 69.4978 12.4712 69.3675 17.3989L69.3133 19.3092L70.1056 20.5031C70.8654 21.6645 71.9833 23.7918 72.3741 24.8121C72.5477 25.2788 72.5912 25.3114 73.0145 25.3765C74.9248 25.6696 76.694 27.7644 77.5731 30.7926C78.3655 33.517 78.3872 37.4027 77.6382 40.203C77.508 40.648 77.1498 41.5597 76.835 42.211C75.7171 44.4903 74.0022 45.8362 72.1896 45.8471C71.8205 45.847 71.7771 45.8796 71.3864 46.5743C70.0538 48.9871 68.2229 51.1078 65.931 52.9197L65.931 18.983L12.3947 18.983L12.3947 53.6887C12.3876 53.6834 12.3806 53.6781 12.3735 53.6727C9.8011 51.7407 7.2938 48.8536 6.165 46.5308C5.872 45.9122 5.8069 45.847 5.3727 45.7385C3.0608 45.1632 1.1397 42.4932 0.3473 38.7703C0.0326 37.2833 0 34.0597 0.2822 32.5075C0.9551 28.8823 2.7352 26.2882 5.0905 25.4851C5.6983 25.2788 5.72 25.268 6.0456 24.5191C6.5775 23.2926 7.5001 21.5559 8.1947 20.4922L8.8351 19.4937L8.8677 15.8793C8.9328 10.257 9.4755 5.8177 10.4632 2.7569ZM12.5027 53.7693C16.8022 56.9564 22.7683 58.8585 31.0748 59.6967C34.5807 60.044 42.7971 60.0114 46.4874 59.6207C53.3471 58.9152 59.0672 57.2654 63.1482 54.8341C63.7166 54.4938 64.2642 54.1388 64.7905 53.7693L12.5027 53.7693Z" fill-rule="evenodd"/><path fill="#FFF" transform="matrix(0.866025 -0.5 0.5 0.866025 29.5937 50.1354)" d="M6.4145 3.2072L6.4145 10.1152Q6.4145 11.4436 5.4751 12.383Q4.5357 13.3224 3.2072 13.3224L3.2072 13.3224Q1.8788 13.3224 0.9394 12.383Q0 11.4436 0 10.1152L0 3.2072Q0 1.8788 0.9394 0.9394Q1.8788 0 3.2072 0L3.2072 0Q4.5357 0 5.4751 0.9394Q6.4145 1.8788 6.4145 3.2072Z"/><path fill="#FFF" transform="matrix(0.866025 -0.5 0.5 0.866025 46.9001 40.1434)" d="M6.4145 3.2072L6.4145 10.1152Q6.4145 11.4436 5.4751 12.383Q4.5357 13.3224 3.2072 13.3224L3.2072 13.3224Q1.8788 13.3224 0.9394 12.383Q0 11.4436 0 10.1152L0 3.2072Q0 1.8788 0.9394 0.9394Q1.8788 0 3.2072 0L3.2072 0Q4.5357 0 5.4751 0.9394Q6.4145 1.8788 6.4145 3.2072Z"/></g></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+3
View File
@@ -703,6 +703,9 @@ func Accounts() []Provider {
if p, ok := zcodeAccount(); ok {
out = append(out, p)
}
if p, ok := workBuddyAccount(); ok {
out = append(out, p)
}
for _, agent := range []string{"gemini", "antigravity"} {
if p, ok := googleAccountOf(agent); ok {
out = append(out, p)
+5
View File
@@ -38,6 +38,8 @@ func LoginUsage(ctx context.Context, agent string) map[string]SubscriptionQuota
logins = copilotLoginList()
case "zcode":
logins = zcodeLoginList()
case "workbuddy":
logins = wbLoginList()
case "gemini", "antigravity":
logins = googleLoginList(agent)
case "cursor": // one account, the one cursor-agent is signed in to
@@ -93,6 +95,9 @@ func loginQuota(ctx context.Context, l Login) SubscriptionQuota {
if l.Agent == "zcode" {
return zcodeLoginQuota(ctx, l)
}
if l.Agent == "workbuddy" {
return wbLoginQuota(ctx, l)
}
if l.Agent == "copilot" {
for _, c := range copilotLogins(copilotConfigDir()) {
if strings.EqualFold(c.User, l.User) {
+7
View File
@@ -423,11 +423,14 @@ func Logins(agent string) []Login {
return copilotLoginList()
case "zcode":
return zcodeLoginList()
case "workbuddy":
return wbLoginList()
case "gemini", "antigravity":
return googleLoginList(agent)
case "":
side = append(grokLoginList(), copilotLoginList()...)
side = append(side, zcodeLoginList()...)
side = append(side, wbLoginList()...)
side = append(side, googleLoginList("gemini")...)
side = append(side, googleLoginList("antigravity")...)
}
@@ -466,6 +469,8 @@ func SwitchLogin(agent, user string) error {
return switchCopilotLogin(user)
case "zcode":
return switchZCodeLogin(user)
case "workbuddy":
return switchWorkBuddyLogin(user)
case "gemini", "antigravity":
return switchGoogleLogin(agent, user)
}
@@ -575,6 +580,8 @@ func ForgetLogin(agent, user string) error {
return forgetCopilotLogin(user)
case "zcode":
return forgetZCodeLogin(user)
case "workbuddy":
return forgetWorkBuddyLogin(user)
case "gemini", "antigravity":
return forgetGoogleLogin(agent, user)
}
+5
View File
@@ -31,6 +31,8 @@ func SetLoginOn(agent, user string, on bool) error {
return setCopilotLoginOn(user, on)
case "zcode":
return setZCodeLoginOn(user, on)
case "workbuddy":
return setWorkBuddyLoginOn(user, on)
case "gemini", "antigravity":
return setGoogleLoginOn(agent, user, on)
}
@@ -64,6 +66,9 @@ func (p Provider) AlsoOn() []Provider {
if p.Account != nil && p.Account.Agent == "zcode" {
return zcodeAlsoOn()
}
if p.Account != nil && p.Account.Agent == "workbuddy" {
return workBuddyAlsoOn()
}
if p.Account != nil && (p.Account.Agent == "gemini" || p.Account.Agent == "antigravity") {
return googleAlsoOn(p.Account.Agent)
}
+1 -1
View File
@@ -359,7 +359,7 @@ func freeName(name string) string {
}
// accountIDs are the ids of the subscriptions magpie can list (account.go).
var accountIDs = []string{"antigravity", "claude", "codex", "copilot", "cursor", "devin", "gemini", "grok", "kiro", "zcode"}
var accountIDs = []string{"antigravity", "claude", "codex", "copilot", "cursor", "devin", "gemini", "grok", "kiro", "workbuddy", "zcode"}
func stored(id string) bool {
for _, p := range load().Providers {
+1 -1
View File
@@ -184,7 +184,7 @@ func addSideLogin(l savedLogin, dup func(savedLogin)) error {
// agent's own store as Claude Code's and Codex's are.
func sideAgent(agent string) bool {
switch agent {
case "grok", "copilot", "zcode", "gemini", "antigravity":
case "grok", "copilot", "zcode", "workbuddy", "gemini", "antigravity":
return true
}
return false
+5
View File
@@ -233,6 +233,11 @@ func (s *signInFlow) begin() error {
if err := startCopilotSignIn(s); err != nil {
return err
}
case "workbuddy":
// Tencent's sign-in, as WorkBuddy (CodeBuddy) makes it
if err := startWorkBuddySignIn(s); err != nil {
return err
}
case "zcode":
// Z.ai's sign-in, as ZCode makes it
if err := startZCodeSignIn(s); err != nil {
+3
View File
@@ -208,6 +208,9 @@ func fetchSubscriptionUsage() []SubscriptionQuota {
if !hidden["zcode"] {
fetches = append(fetches, perLogin(ctx, zcodeLoginList(), "ZCode", "zcode")...)
}
if !hidden["workbuddy"] {
fetches = append(fetches, perLogin(ctx, wbLoginList(), "WorkBuddy", "workbuddy-color")...)
}
for _, agent := range []string{"gemini", "antigravity"} {
if hidden[agent] {
continue
+669
View File
@@ -0,0 +1,669 @@
package provider
// A WorkBuddy subscription is Tencent's CodeBuddy plan, which WorkBuddy
// (its desktop app, packaged from CodeBuddy Code) signs in to. The plan is
// served on an OpenAI-compatible endpoint under the account's own access
// token — a JWT the account refreshes — with the account's id and domain in
// headers, exactly as WorkBuddy sends them.
//
// WorkBuddy's own account is read, never changed, from its auth store,
// <shared data>/auth/workbuddy-desktop.info (plain JSON while credential
// protection is off, which it is by default); its access and refresh tokens
// and the account are taken as they sit there. Further accounts are signed
// in by magpie with WorkBuddy's own external-link flow (auth/state, then
// auth/token polled until ready, then login/account), and their tokens are
// kept in logins.json. A near-expired token is refreshed as WorkBuddy does,
// and a magpie-signed-in one's refresh is written back beside it.
//
// The plan has no small model list to ask; the models are WorkBuddy's, its
// CLI agent's coding picks.
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
)
// Where WorkBuddy's plan and its sign-in are; vars so tests can point them
// elsewhere.
var (
wbEndpoint = "https://copilot.tencent.com"
// wbAppVersion is the plugin version the sign-in page is told.
wbAppVersion = "2.0.0"
// wbPollInterval is how often the sign-in asks whether it is ready.
wbPollInterval = time.Second
)
// wbModels are the plan's models, WorkBuddy's CLI agent's coding picks, with
// the names and context windows its config gives them.
var wbModels = []catalog.Model{
{ID: "auto", Name: "Auto", Context: 168_000},
{ID: "hy4-preview-f", Name: "Hy4 preview", Context: 1_000_000},
{ID: "hy3", Name: "Hy3", Context: 192_000},
{ID: "hy3-x", Name: "Hy3-X", Context: 192_000},
{ID: "deepseek-v4.1-flash", Name: "Deepseek-V4.1-Flash", Context: 1_000_000},
{ID: "glm-5.3", Name: "GLM-5.3", Context: 1_000_000},
{ID: "glm-5.3-flash", Name: "GLM-5.3-Flash", Context: 1_000_000},
{ID: "glm-5.2", Name: "GLM-5.2", Context: 1_000_000},
{ID: "glm-5.1", Name: "GLM-5.1", Context: 200_000},
{ID: "glm-5v-turbo", Name: "GLM-5v-Turbo", Context: 200_000},
{ID: "minimax-m3", Name: "MiniMax-M3", Context: 512_000},
{ID: "kimi-k3-1", Name: "Kimi-K3", Context: 1_000_000},
{ID: "kimi-k2.7", Name: "Kimi-K2.7-Code", Context: 256_000},
{ID: "kimi-k2.6", Name: "Kimi-K2.6", Context: 256_000},
{ID: "deepseek-v4-pro", Name: "Deepseek-V4-Pro", Context: 1_000_000},
}
// wbCreds is a WorkBuddy account's tokens and where they are served, as the
// auth store and the sign-in name them.
type wbCreds struct {
UID string `json:"uid"`
Access string `json:"accessToken"`
Refresh string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"` // unix ms, when the access token lapses
RefreshExpiresAt int64 `json:"refreshExpiresAt"` // unix ms, when the refresh token lapses
Domain string `json:"domain"`
TokenType string `json:"tokenType,omitempty"`
}
// wbAccount is a signed-in WorkBuddy account: which login it is (who, its
// plan, and whether it is the one in use), its tokens, and whether it is
// WorkBuddy's own sign-in (read-only) or one magpie added (in logins.json).
type wbAccount struct {
Login
creds wbCreds
own bool
}
// wbAPI is the API root for a domain: auth and billing sit under it. The
// account's domain (www.codebuddy.cn) names the site; requests otherwise go
// to the product endpoint.
func wbAPI() string { return strings.TrimRight(wbEndpoint, "/") }
// ---- WorkBuddy's own account --------------------------------------------------
// wbAuthPath is where WorkBuddy keeps the signed-in session, per platform,
// as its file-authentication-storage does: <shared data>/auth/<id>.info.
func wbAuthPath() string {
home, _ := os.UserHomeDir()
var base string
switch runtime.GOOS {
case "darwin":
base = filepath.Join(home, "Library", "Application Support", "CodeBuddyExtension")
case "windows":
base = filepath.Join(home, "AppData", "Local", "CodeBuddyExtension")
default:
base = filepath.Join(home, ".local", "share", "CodeBuddyExtension")
}
return filepath.Join(base, "Data", "Public", "auth", "workbuddy-desktop.info")
}
// wbStoredSession is the shape of the auth store's session: the account and
// its tokens. The tokens are plain strings while credential protection is
// off (the default); when it is on they are objects magpie can't read, and
// the account is then taken as not readable.
type wbStoredSession struct {
Account struct {
UID string `json:"uid"`
Nickname json.RawMessage `json:"nickname"`
PhoneNumber json.RawMessage `json:"phoneNumber"`
} `json:"account"`
Auth struct {
AccessToken json.RawMessage `json:"accessToken"`
RefreshToken json.RawMessage `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
ExpiresIn int64 `json:"expiresIn"`
RefreshExpiresAt int64 `json:"refreshExpiresAt"`
RefreshExpiresIn int64 `json:"refreshExpiresIn"`
Domain string `json:"domain"`
TokenType string `json:"tokenType"`
} `json:"auth"`
}
// wbString reads a JSON value that is a plain string, "" for anything else
// (an encrypted-field object magpie doesn't decrypt).
func wbString(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
return ""
}
// wbOwn is the account WorkBuddy is signed in to and its tokens; ok is
// false when it has none, or they are encrypted at rest.
func wbOwn() (who string, c wbCreds, ok bool) {
var s wbStoredSession
if !readJSON(wbAuthPath(), &s) {
return "", wbCreds{}, false
}
access, refresh := wbString(s.Auth.AccessToken), wbString(s.Auth.RefreshToken)
if s.Account.UID == "" || access == "" {
return "", wbCreds{}, false
}
c = wbCreds{
UID: s.Account.UID,
Access: access,
Refresh: refresh,
ExpiresAt: s.Auth.ExpiresAt,
RefreshExpiresAt: s.Auth.RefreshExpiresAt,
Domain: s.Auth.Domain,
TokenType: s.Auth.TokenType,
}
now := time.Now().UnixMilli()
if c.ExpiresAt == 0 && s.Auth.ExpiresIn > 0 {
c.ExpiresAt = now + s.Auth.ExpiresIn*1000
}
if c.RefreshExpiresAt == 0 && s.Auth.RefreshExpiresIn > 0 {
c.RefreshExpiresAt = now + s.Auth.RefreshExpiresIn*1000
}
return wbWho(wbString(s.Account.Nickname), wbString(s.Account.PhoneNumber), s.Account.UID), c, true
}
// wbWho names a WorkBuddy account: its nickname, its phone number, or its id.
func wbWho(nickname, phone, id string) string {
return firstNonEmpty(strings.TrimSpace(nickname), strings.TrimSpace(phone), id, "WorkBuddy")
}
// ---- the accounts -------------------------------------------------------------
func wbSavedCreds(l savedLogin) (wbCreds, bool) {
var c wbCreds
if json.Unmarshal(l.Auth, &c) != nil || c.Access == "" || c.UID == "" {
return wbCreds{}, false
}
return c, true
}
// wbLogins is every WorkBuddy account signed in, the first in use first.
func wbLogins() []wbAccount {
ownUser, own, hasOwn := wbOwn()
if !hasOwn {
ownUser = ""
}
var out []wbAccount
for _, l := range sideLogins("workbuddy", ownUser, func(l savedLogin) bool {
_, ok := wbSavedCreds(l)
return ok
}) {
a := wbAccount{Login: l.Login, own: l.saved.own()}
if a.own {
a.creds = own
} else {
a.creds, _ = wbSavedCreds(l.saved)
}
out = append(out, a)
}
return out
}
func wbSide() []sideLogin {
var out []sideLogin
for _, a := range wbLogins() {
out = append(out, sideLogin{Login: a.Login})
}
return out
}
func wbLoginList() []Login { return loginsOf(wbSide()) }
func switchWorkBuddyLogin(user string) error {
return switchSideLogin("workbuddy", user, wbSide())
}
func setWorkBuddyLoginOn(user string, on bool) error {
return setSideLoginOn("workbuddy", user, on, wbSide())
}
func forgetWorkBuddyLogin(user string) error {
return forgetSideLogin("workbuddy", user, "WorkBuddy's own sign-in; sign out in WorkBuddy", wbSide(), nil)
}
func workBuddyAccount() (Provider, bool) {
ls := wbLogins()
if len(ls) == 0 {
return Provider{}, false
}
return wbProvider(ls[0]), true
}
// workBuddyAlsoOn is the WorkBuddy accounts in use behind the first.
func workBuddyAlsoOn() []Provider {
var out []Provider
for _, a := range wbLogins() {
if !a.Active && a.On {
out = append(out, wbProvider(a))
}
}
return out
}
func wbProvider(a wbAccount) Provider {
acct := &Account{Agent: "workbuddy", User: a.User, Plan: a.Plan}
acct.sign = func(ctx context.Context, req *http.Request, body []byte) error {
c, err := wbFresh(ctx, a)
if err != nil {
return err
}
req.Header.Del("Authorization")
req.Header.Set("Authorization", "Bearer "+c.Access)
req.Header.Set("X-User-Id", c.UID)
req.Header.Set("X-Domain", wbDomain(c))
req.Header.Set("X-Product", "SaaS")
req.Header.Set("X-IDE-Type", "WorkBuddy")
return nil
}
acct.models = func() []catalog.Model { return wbModels }
return Provider{ID: "workbuddy", Name: "WorkBuddy", Icon: "workbuddy-color", Chat: wbAPI() + "/v2", Website: "https://www.codebuddy.cn", Account: acct}
}
// wbDomain is the X-Domain a request carries: the account's own domain, or
// the endpoint's authority.
func wbDomain(c wbCreds) string {
if c.Domain != "" {
return c.Domain
}
if u, err := url.Parse(wbAPI()); err == nil && u.Host != "" {
return u.Host
}
return ""
}
// ---- tokens -------------------------------------------------------------------
// wbTokens caches each account's freshest tokens (by uid), so a token
// refreshed for one request is used by the next; WorkBuddy's own file is
// never written, and a magpie-added account's refresh goes to logins.json.
var wbTokens = struct {
sync.Mutex
m map[string]wbCreds
}{m: map[string]wbCreds{}}
// wbFresh is a's tokens, refreshed when the access token is about to lapse.
func wbFresh(ctx context.Context, a wbAccount) (wbCreds, error) {
wbTokens.Lock()
c := a.creds
if cached, ok := wbTokens.m[a.creds.UID]; ok && cached.ExpiresAt >= c.ExpiresAt {
c = cached
}
wbTokens.Unlock()
if c.Access != "" && !wbNearExpiry(c.ExpiresAt) {
return c, nil
}
if c.Refresh == "" || (c.RefreshExpiresAt > 0 && time.Now().UnixMilli() >= c.RefreshExpiresAt) {
if c.Access != "" {
return c, nil // no way to refresh; let the request try what there is
}
return wbCreds{}, errors.New("this WorkBuddy account is signed out; sign in again")
}
refreshed, err := wbRefresh(ctx, c)
if err != nil {
if c.Access != "" {
return c, nil // a refresh hiccup: the current token may still work
}
return wbCreds{}, err
}
wbTokens.Lock()
wbTokens.m[refreshed.UID] = refreshed
wbTokens.Unlock()
if !a.own {
wbSaveCreds(a.User, refreshed)
}
return refreshed, nil
}
// wbNearExpiry is true within a minute of a token's end (or when unknown).
func wbNearExpiry(expiresAt int64) bool {
if expiresAt == 0 {
return false // the store didn't say; trust it until refused
}
return time.Now().UnixMilli() >= expiresAt-60_000
}
// wbRefresh trades a refresh token for a fresh access token, as WorkBuddy's
// auth provider does: POST /v2/plugin/auth/token/refresh with the refresh
// token in a header.
func wbRefresh(ctx context.Context, c wbCreds) (wbCreds, error) {
var got wbRefreshed
err := wbCall(ctx, http.MethodPost, wbAPI()+"/v2/plugin/auth/token/refresh", map[string]string{
"X-Refresh-Token": c.Refresh,
"X-Auth-Refresh-Source": "plugin",
"X-Domain": wbDomain(c),
}, map[string]any{}, &got)
if err != nil {
return wbCreds{}, fmt.Errorf("WorkBuddy token refresh: %w", err)
}
if got.AccessToken == "" {
return wbCreds{}, errors.New("WorkBuddy gave no refreshed token")
}
return wbMergeRefreshed(c, got), nil
}
type wbRefreshed struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
ExpiresIn int64 `json:"expiresIn"`
RefreshExpiresAt int64 `json:"refreshExpiresAt"`
RefreshExpiresIn int64 `json:"refreshExpiresIn"`
Domain string `json:"domain"`
TokenType string `json:"tokenType"`
}
func wbMergeRefreshed(c wbCreds, got wbRefreshed) wbCreds {
now := time.Now().UnixMilli()
out := c
out.Access = got.AccessToken
if got.RefreshToken != "" {
out.Refresh = got.RefreshToken
}
if got.Domain != "" {
out.Domain = got.Domain
}
if got.TokenType != "" {
out.TokenType = got.TokenType
}
switch {
case got.ExpiresAt > 0:
out.ExpiresAt = got.ExpiresAt
case got.ExpiresIn > 0:
out.ExpiresAt = now + got.ExpiresIn*1000
}
switch {
case got.RefreshExpiresAt > 0:
out.RefreshExpiresAt = got.RefreshExpiresAt
case got.RefreshExpiresIn > 0:
out.RefreshExpiresAt = now + got.RefreshExpiresIn*1000
}
return out
}
// wbSaveCreds writes a magpie-added account's refreshed tokens back to
// logins.json, so the next run starts from them.
func wbSaveCreds(user string, c wbCreds) {
_ = editSideLogin("workbuddy", user, func(ls []savedLogin, i int) ([]savedLogin, error) {
if ls[i].own() {
return ls, nil // never write WorkBuddy's own file
}
auth, err := json.Marshal(c)
if err != nil {
return ls, err
}
ls[i].Auth = auth
ls[i].Renewed = time.Now().UTC().Truncate(time.Second)
return ls, nil
})
}
// ---- allowance ----------------------------------------------------------------
// wbQuota is a WorkBuddy account's credit allowance, from its resource
// summary: the plan's credits used against what the cycle grants.
func wbQuota(ctx context.Context, a wbAccount) SubscriptionQuota {
q := SubscriptionQuota{Provider: "workbuddy", Name: "WorkBuddy", Icon: "workbuddy-color", Plan: a.Plan, User: a.User, Windows: []QuotaWindow{}}
c, err := wbFresh(ctx, a)
if err != nil {
q.Error = err.Error()
return q
}
var sum wbResourceSummary
if err := wbCall(ctx, http.MethodPost, wbAPI()+"/v2/billing/meter/get-user-resource-summary", wbAuthHeaders(c), map[string]any{}, &sum); err != nil {
q.Error = err.Error()
return q
}
if sum.IsPaidUser {
q.Plan = firstNonEmpty(a.Plan, "Pro")
} else {
q.Plan = firstNonEmpty(a.Plan, "Free")
}
var total, used float64
for _, p := range sum.Packages {
total += p.CycleTotalCapacity
used += p.CycleUsedCapacity
}
if total > 0 {
w := QuotaWindow{Name: "Credits", Used: 100 * used / total, Display: fmt.Sprintf("%s / %s", compactNumber(used), compactNumber(total))}
q.Windows = append(q.Windows, w)
}
return q
}
type wbResourceSummary struct {
Packages []struct {
PackageCode string `json:"PackageCode"`
CycleTotalCapacity float64 `json:"CycleTotalCapacity"`
CycleRemainCapacity float64 `json:"CycleRemainCapacity"`
CycleUsedCapacity float64 `json:"CycleUsedCapacity"`
} `json:"Packages"`
SubscriptionPackageCode string `json:"SubscriptionPackageCode"`
IsPaidUser bool `json:"IsPaidUser"`
}
func wbAuthHeaders(c wbCreds) map[string]string {
return map[string]string{
"Authorization": "Bearer " + c.Access,
"X-User-Id": c.UID,
"X-Domain": wbDomain(c),
"X-Product": "SaaS",
"X-IDE-Type": "WorkBuddy",
}
}
func wbLoginQuota(ctx context.Context, l Login) SubscriptionQuota {
for _, a := range wbLogins() {
if strings.EqualFold(a.User, l.User) {
return wbQuota(ctx, a)
}
}
return SubscriptionQuota{Provider: "workbuddy", Plan: l.Plan, Windows: []QuotaWindow{}, Error: "not signed in"}
}
// ---- WorkBuddy's API ----------------------------------------------------------
// wbError is a WorkBuddy business error: its code lets a poll loop tell a
// "come back" answer (retry) from a real failure.
type wbError struct {
code int
msg string
}
func (e *wbError) Error() string {
if e.msg != "" {
return e.msg
}
return fmt.Sprintf("error %d", e.code)
}
// WorkBuddy's retry codes: the token or the account isn't ready yet.
const (
wbRetryToken = 11217
wbRetryAccount = 12151
)
// wbCall asks one of WorkBuddy's JSON endpoints, which wrap what they say in
// {code, msg, data}: code 0 is a success. On a non-zero code it returns a
// *wbError carrying it.
func wbCall(ctx context.Context, method, u string, headers map[string]string, body, dst any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "WorkBuddy/"+wbAppVersion)
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
var env struct {
Code json.Number `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
_ = json.Unmarshal(b, &env)
if res.StatusCode < 200 || res.StatusCode >= 300 {
if code, _ := env.Code.Int64(); code != 0 {
return &wbError{code: int(code), msg: env.Msg}
}
if env.Msg != "" {
return &wbError{code: res.StatusCode, msg: env.Msg}
}
return &accountStatusError{status: res.StatusCode}
}
if code, _ := env.Code.Int64(); code != 0 {
return &wbError{code: int(code), msg: env.Msg}
}
if dst == nil || len(env.Data) == 0 || string(env.Data) == "null" {
return nil
}
return json.Unmarshal(env.Data, dst)
}
// ---- signing in ---------------------------------------------------------------
// startWorkBuddySignIn is WorkBuddy's own external-link sign-in: the app
// asks for a state and a page, opens the page for the user to sign in, then
// polls for the token and the account.
func startWorkBuddySignIn(s *signInFlow) error {
ctx, cancel := context.WithCancel(context.Background())
var state struct {
State string `json:"state"`
AuthURL string `json:"authUrl"`
}
stateHeaders := map[string]string{
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
"X-No-Department-Info": "true",
}
if err := wbCall(ctx, http.MethodPost, wbAPI()+"/v2/plugin/auth/state?platform=workbuddy", stateHeaders, map[string]any{}, &state); err != nil {
cancel()
return fmt.Errorf("WorkBuddy sign-in: %w", err)
}
u, err := url.Parse(state.AuthURL)
if state.State == "" || err != nil || u.Scheme != "https" {
cancel()
return errors.New("WorkBuddy gave no sign-in page")
}
sid := make([]byte, 16)
_, _ = rand.Read(sid)
q := u.Query()
q.Set("version", wbAppVersion)
q.Set("loginSessionId", hex.EncodeToString(sid))
u.RawQuery = q.Encode()
s.mu.Lock()
s.st.URL = u.String()
s.stop = cancel
s.mu.Unlock()
go func() {
defer cancel()
fail := func(msg string) { s.finish(SignInState{State: "failed", Error: msg}) }
deadline := time.Now().Add(5 * time.Minute)
token, ok := wbPoll(ctx, deadline, "/v2/plugin/auth/token?state="+url.QueryEscape(state.State), nil, wbRetryToken, fail)
if !ok {
return
}
var tok wbRefreshed
if json.Unmarshal(token, &tok) != nil || tok.AccessToken == "" {
fail("WorkBuddy gave no token")
return
}
c := wbMergeRefreshed(wbCreds{}, tok)
acctHeaders := map[string]string{
"Authorization": "Bearer " + c.Access,
"X-Domain": wbDomain(c),
"X-No-User-Id": "true",
"X-No-Enterprise-Id": "true",
}
account, ok := wbPoll(ctx, deadline, "/v2/plugin/login/account?state="+url.QueryEscape(state.State), acctHeaders, wbRetryAccount, fail)
if !ok {
return
}
var acc struct {
UID string `json:"uid"`
Nickname string `json:"nickname"`
PhoneNumber string `json:"phoneNumber"`
}
if json.Unmarshal(account, &acc) != nil || acc.UID == "" {
fail("WorkBuddy gave no account")
return
}
c.UID = acc.UID
who := wbWho(acc.Nickname, acc.PhoneNumber, acc.UID)
auth, _ := json.Marshal(c)
if err := addSideLogin(savedLogin{Agent: "workbuddy", User: who, Auth: auth}, func(savedLogin) {}); err != nil {
fail(err.Error())
return
}
wbTokens.Lock()
wbTokens.m[c.UID] = c
wbTokens.Unlock()
ownUser, _, hasOwn := wbOwn()
s.finish(SignInState{State: "done", User: who, Using: hasOwn && strings.EqualFold(ownUser, who)})
}()
return nil
}
// wbPoll asks path every second until it answers with data, giving up at
// the deadline. A retry code (the answer isn't ready) waits and asks again;
// any other error ends the sign-in through fail.
func wbPoll(ctx context.Context, deadline time.Time, path string, headers map[string]string, retryCode int, fail func(string)) (json.RawMessage, bool) {
for {
select {
case <-ctx.Done():
return nil, false
case <-time.After(wbPollInterval):
}
if time.Now().After(deadline) {
fail("the sign-in expired; start again")
return nil, false
}
var raw json.RawMessage
err := wbCall(ctx, http.MethodGet, wbAPI()+path, headers, nil, &raw)
switch {
case ctx.Err() != nil:
return nil, false
case err == nil && len(raw) > 0 && string(raw) != "null":
return raw, true
case err == nil:
continue // ready, but empty: ask again
}
var we *wbError
if errors.As(err, &we) && we.code == retryCode {
continue // not ready yet
}
var st *accountStatusError
if errors.As(err, &st) && (st.status == 408 || st.status == 429) {
continue // a hiccup: ask again
}
fail("WorkBuddy sign-in: " + err.Error())
return nil, false
}
}
+248
View File
@@ -0,0 +1,248 @@
package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// wbAuthFile is where WorkBuddy keeps its signed-in session under a temp home.
func wbAuthFile(home string) string {
// mirrors wbAuthPath's darwin layout; the test runs on darwin
return filepath.Join(home, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth", "workbuddy-desktop.info")
}
// WorkBuddy's own account is read from its auth store; a second, signed in
// from magpie with WorkBuddy's own flow, is kept beside it with its tokens.
func TestWorkBuddyAccounts(t *testing.T) {
home := signIn(t)
wbTokens.Lock()
wbTokens.m = map[string]wbCreds{}
wbTokens.Unlock()
// far-future so the own token isn't seen as near expiry
future := time.Now().Add(24 * time.Hour).UnixMilli()
writeFile(t, wbAuthFile(home), map[string]any{
"account": map[string]any{"uid": "u1", "nickname": "旅行者", "phoneNumber": "13800000000"},
"auth": map[string]any{
"accessToken": "own-access", "refreshToken": "own-refresh",
"expiresAt": future, "refreshExpiresAt": future,
"domain": "www.codebuddy.cn", "tokenType": "Bearer",
},
})
var polls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ok := func(data any) { json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": data}) }
retry := func(code int) { json.NewEncoder(w).Encode(map[string]any{"code": code, "msg": "pending"}) }
switch r.URL.Path {
case "/v2/plugin/auth/state":
ok(map[string]any{"state": "s1", "authUrl": "https://login.codebuddy.cn/oauth?client_id=c"})
case "/v2/plugin/auth/token":
if polls.Add(1) < 2 {
retry(wbRetryToken)
return
}
ok(map[string]any{"accessToken": "two-access", "refreshToken": "two-refresh",
"expiresIn": 3600, "refreshExpiresIn": 7200, "domain": "www.codebuddy.cn", "tokenType": "Bearer"})
case "/v2/plugin/login/account":
if r.Header.Get("Authorization") != "Bearer two-access" {
w.WriteHeader(401)
return
}
ok(map[string]any{"uid": "u2", "nickname": "Two", "phoneNumber": ""})
case "/v2/billing/meter/get-user-resource-summary":
auth := r.Header.Get("Authorization")
if auth != "Bearer own-access" && auth != "Bearer two-access" {
w.WriteHeader(401)
return
}
ok(map[string]any{"IsPaidUser": true, "Packages": []any{
map[string]any{"PackageCode": "coding", "CycleTotalCapacity": 10000, "CycleRemainCapacity": 7500, "CycleUsedCapacity": 2500},
}})
default:
w.WriteHeader(404)
}
}))
defer srv.Close()
oldEnd, oldInt := wbEndpoint, wbPollInterval
wbEndpoint, wbPollInterval = srv.URL, 5*time.Millisecond
defer func() { wbEndpoint, wbPollInterval = oldEnd, oldInt }()
// WorkBuddy's own, named by its nickname
who, c, ok := wbOwn()
if !ok || who != "旅行者" || c.Access != "own-access" || c.UID != "u1" {
t.Fatalf("own: %v %q %+v", ok, who, c)
}
st, err := StartSignIn("workbuddy")
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse(st.URL)
if u.Host != "login.codebuddy.cn" || u.Query().Get("version") == "" || u.Query().Get("loginSessionId") == "" {
t.Fatalf("sign-in page: %s", st.URL)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
st, _ = WaitSignIn(ctx, st.ID)
if st.State != "done" || st.User != "Two" || st.Using {
t.Fatalf("done: %+v", st)
}
var users []string
for _, l := range Logins("workbuddy") {
users = append(users, l.User+map[bool]string{true: "*", false: ""}[l.Active]+map[bool]string{true: "+", false: ""}[l.On])
}
if got := strings.Join(users, " "); got != "旅行者*+ Two+" {
t.Fatalf("logins: %s", got)
}
p, ok := find(All(), "workbuddy")
if !ok || p.Account.User != "旅行者" || p.Chat != srv.URL+"/v2" || p.Icon != "workbuddy-color" {
t.Fatalf("first: %+v", p)
}
// the first signs with the own account's token, id and domain
req, _ := http.NewRequest("POST", p.Chat+"/chat/completions", nil)
if err := p.Sign(context.Background(), req, Chat, []byte(`{}`)); err != nil ||
req.Header.Get("Authorization") != "Bearer own-access" ||
req.Header.Get("X-User-Id") != "u1" || req.Header.Get("X-Domain") != "www.codebuddy.cn" {
t.Fatalf("own sign: %v %v", err, req.Header)
}
also := p.AlsoOn()
if len(also) != 1 || also[0].Account.User != "Two" {
t.Fatalf("also on: %+v", also)
}
req2, _ := http.NewRequest("POST", also[0].Chat+"/chat/completions", nil)
if err := also[0].Sign(context.Background(), req2, Chat, []byte(`{}`)); err != nil ||
req2.Header.Get("Authorization") != "Bearer two-access" || req2.Header.Get("X-User-Id") != "u2" {
t.Fatalf("second signs with its own token: %v %v", err, req2.Header)
}
if ms := p.Account.models(); len(ms) != len(wbModels) || ms[0].ID != "auto" {
t.Fatalf("models: %+v", ms)
}
// credits, aggregated from the resource summary
use := LoginUsage(context.Background(), "workbuddy")
q := use["旅行者"]
if q.Error != "" || len(q.Windows) != 1 || q.Windows[0].Name != "Credits" || q.Windows[0].Used != 25 {
t.Fatalf("usage: %+v", q)
}
if err := ForgetLogin("workbuddy", "旅行者"); err == nil {
t.Fatal("forgot the one in use")
}
if err := SwitchLogin("workbuddy", "Two"); err != nil {
t.Fatal(err)
}
if p, _ := find(All(), "workbuddy"); p.Account.User != "Two" {
t.Fatalf("after switch: %+v", p.Account)
}
if err := SwitchLogin("workbuddy", "旅行者"); err != nil {
t.Fatal(err)
}
if err := ForgetLogin("workbuddy", "Two"); err != nil {
t.Fatal(err)
}
if ls := Logins("workbuddy"); len(ls) != 1 {
t.Fatalf("after forget: %+v", ls)
}
// signed out of WorkBuddy, with none of magpie's: no WorkBuddy subscription
os.Remove(wbAuthFile(home))
if _, ok := find(All(), "workbuddy"); ok {
t.Fatal("workbuddy without an account")
}
}
// A near-expired own token is refreshed before use, and the refreshed one
// is kept in memory (WorkBuddy's file is never written).
func TestWorkBuddyRefresh(t *testing.T) {
home := signIn(t)
wbTokens.Lock()
wbTokens.m = map[string]wbCreds{}
wbTokens.Unlock()
past := time.Now().Add(-time.Minute).UnixMilli() // access already lapsed
far := time.Now().Add(24 * time.Hour).UnixMilli() // refresh still good
writeFile(t, wbAuthFile(home), map[string]any{
"account": map[string]any{"uid": "u1", "nickname": "R", "phoneNumber": ""},
"auth": map[string]any{
"accessToken": "stale", "refreshToken": "good-refresh",
"expiresAt": past, "refreshExpiresAt": far, "domain": "www.codebuddy.cn", "tokenType": "Bearer",
},
})
var refreshed atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/plugin/auth/token/refresh" {
if r.Header.Get("X-Refresh-Token") != "good-refresh" {
w.WriteHeader(401)
return
}
refreshed.Add(1)
json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{
"accessToken": "fresh", "expiresIn": 3600,
}})
return
}
w.WriteHeader(404)
}))
defer srv.Close()
old := wbEndpoint
wbEndpoint = srv.URL
defer func() { wbEndpoint = old }()
p, ok := find(All(), "workbuddy")
if !ok {
t.Fatal("no workbuddy account")
}
req, _ := http.NewRequest("POST", p.Chat+"/chat/completions", nil)
if err := p.Sign(context.Background(), req, Chat, []byte(`{}`)); err != nil {
t.Fatal(err)
}
if got := req.Header.Get("Authorization"); got != "Bearer fresh" {
t.Fatalf("Authorization = %q, want the refreshed token", got)
}
if refreshed.Load() != 1 {
t.Fatalf("refreshed %d times, want 1", refreshed.Load())
}
// a second request reuses the cached fresh token, no new refresh
req2, _ := http.NewRequest("POST", p.Chat+"/chat/completions", nil)
if err := p.Sign(context.Background(), req2, Chat, []byte(`{}`)); err != nil {
t.Fatal(err)
}
if refreshed.Load() != 1 {
t.Fatalf("refreshed again: %d", refreshed.Load())
}
// WorkBuddy's own file is left as it was
var s wbStoredSession
if !readJSON(wbAuthFile(home), &s) || wbString(s.Auth.AccessToken) != "stale" {
t.Fatalf("own file was written: %+v", s.Auth)
}
}
// An encrypted access token at rest means magpie can't read the account.
func TestWorkBuddyEncryptedOwn(t *testing.T) {
home := signIn(t)
writeFile(t, wbAuthFile(home), map[string]any{
"account": map[string]any{"uid": "u1", "nickname": "E"},
"auth": map[string]any{
"accessToken": map[string]any{"$wbEncrypted": 1, "envelope": "x", "scheme": "asym-v1"},
"domain": "www.codebuddy.cn",
},
})
if _, _, ok := wbOwn(); ok {
t.Fatal("read an encrypted own account")
}
}