Skip to content

Commit 950ac4f

Browse files
joohwcursoragent
andcommitted
fix(subscription,desktop): decouple OAuth from agent CLI install
Store Codex subscription OAuth in clovapi own subscription/codex.json, report login and model fetch without requiring agent CLI binaries, and show only installed agents in the tray quick-switch menu. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 950049b commit 950ac4f

13 files changed

Lines changed: 272 additions & 122 deletions

File tree

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.54"
7+
Version = "dev0.1.55"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/config/paths.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@ func ClaudeSubscriptionAuthPath() (string, error) {
6060
return filepath.Join(d, "claude.json"), nil
6161
}
6262

63+
// CodexSubscriptionAuthPath returns clovapi's own Codex OAuth credentials file.
64+
//
65+
// This is intentionally independent from Codex CLI's ~/.codex/auth.json: clovapi
66+
// keeps its own access/refresh tokens here and never reads, writes, or overwrites
67+
// Codex CLI's auth file.
68+
func CodexSubscriptionAuthPath() (string, error) {
69+
d, err := SubscriptionDir()
70+
if err != nil {
71+
return "", err
72+
}
73+
return filepath.Join(d, "codex.json"), nil
74+
}
75+
6376
// CallLogsDir returns the directory for proxy call logs and system logs.
6477
func CallLogsDir() (string, error) {
6578
d, err := Dir()

core/internal/desktop/models.go

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"io"
77
"net/http"
88
"os"
9-
"path/filepath"
109
"strings"
1110
"time"
1211

@@ -60,16 +59,10 @@ func claudeAuthPath() (string, error) {
6059
return config.ClaudeSubscriptionAuthPath()
6160
}
6261

62+
// codexAuthPath returns clovapi's own Codex OAuth store (independent from
63+
// Codex CLI's ~/.codex/auth.json). Auth status and logout operate on this file only.
6364
func codexAuthPath() (string, error) {
64-
codexHome := strings.TrimSpace(os.Getenv("CODEX_HOME"))
65-
if codexHome == "" {
66-
home, err := os.UserHomeDir()
67-
if err != nil {
68-
return "", err
69-
}
70-
codexHome = filepath.Join(home, ".codex")
71-
}
72-
return filepath.Join(codexHome, "auth.json"), nil
65+
return config.CodexSubscriptionAuthPath()
7366
}
7467

7568
func authPathForProvider(providerID string) (string, error) {
@@ -196,6 +189,8 @@ func providerLoggedIn(providerID string, data map[string]any) bool {
196189
}
197190

198191
// AuthStatus reports subscription OAuth status for built-in providers.
192+
// Subscription login and model fetch are independent of whether the agent CLI
193+
// binary is installed; Installed is informational only.
199194
func AuthStatus() AuthStatusResult {
200195
items := make([]AuthStatusItem, 0, len(authProviders))
201196
for _, cfg := range authProviders {
@@ -211,21 +206,15 @@ func AuthStatus() AuthStatusResult {
211206
if cmdPath != "" {
212207
item.CommandPath = cmdPath
213208
}
214-
if !installed {
215-
item.Summary = "CLI not installed"
216-
items = append(items, item)
217-
continue
218-
}
219209
authPath, err := authPathForProvider(cfg.ID)
220-
if err != nil {
221-
items = append(items, item)
222-
continue
210+
if err == nil {
211+
if data, ok := readAuthJSON(authPath); ok {
212+
item.LoggedIn = providerLoggedIn(cfg.ID, data)
213+
item.Active = providerSubscriptionActive(cfg.ID, item.LoggedIn, data)
214+
item.Summary = summarizeAuthStatus(cfg.ID, item.LoggedIn, data)
215+
}
223216
}
224-
if data, ok := readAuthJSON(authPath); ok {
225-
item.LoggedIn = providerLoggedIn(cfg.ID, data)
226-
item.Active = providerSubscriptionActive(cfg.ID, item.LoggedIn, data)
227-
item.Summary = summarizeAuthStatus(cfg.ID, item.LoggedIn, data)
228-
} else if cfg.ID == provider.ClaudeCodeProviderID {
217+
if !item.LoggedIn && cfg.ID == provider.ClaudeCodeProviderID {
229218
if data, ok := profile.ClaudeAuthRoot(); ok {
230219
item.LoggedIn = providerLoggedIn(cfg.ID, data)
231220
item.Active = providerSubscriptionActive(cfg.ID, item.LoggedIn, data)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package desktop
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/clovapi/switcher/internal/config"
9+
"github.com/clovapi/switcher/internal/provider"
10+
)
11+
12+
func TestAuthStatusCodexLoggedInWithoutCLIInstalled(t *testing.T) {
13+
root := t.TempDir()
14+
configDir := filepath.Join(root, "clovapi")
15+
config.SetDirOverride(configDir)
16+
t.Cleanup(func() { config.SetDirOverride("") })
17+
18+
subDir := filepath.Join(configDir, "subscription")
19+
if err := os.MkdirAll(subDir, 0o700); err != nil {
20+
t.Fatal(err)
21+
}
22+
authBody := `{
23+
"auth_mode": "chatgpt",
24+
"tokens": {
25+
"access_token": "test-access-token",
26+
"account_id": "acct-123"
27+
}
28+
}`
29+
if err := os.WriteFile(filepath.Join(subDir, "codex.json"), []byte(authBody), 0o600); err != nil {
30+
t.Fatal(err)
31+
}
32+
33+
t.Setenv("HOME", root)
34+
t.Setenv("USERPROFILE", root)
35+
t.Setenv("PATH", root)
36+
37+
result := AuthStatus()
38+
var codexItem *AuthStatusItem
39+
for i := range result.Items {
40+
if result.Items[i].ID == provider.CodexProviderID {
41+
codexItem = &result.Items[i]
42+
break
43+
}
44+
}
45+
if codexItem == nil {
46+
t.Fatal("codex auth status item not found")
47+
}
48+
if codexItem.Installed {
49+
t.Fatal("expected codex CLI to be reported as not installed in isolated env")
50+
}
51+
if !codexItem.LoggedIn {
52+
t.Fatalf("expected logged in from clovapi OAuth store without codex CLI; summary=%q", codexItem.Summary)
53+
}
54+
if !codexItem.Active {
55+
t.Fatal("expected active codex subscription when logged in")
56+
}
57+
}

core/internal/profile/subscription_auth.go

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"encoding/base64"
66
"encoding/json"
77
"os"
8-
"path/filepath"
98
"strings"
109
"sync"
1110
"time"
@@ -21,7 +20,7 @@ const (
2120

2221
var (
2322
claudeCredentialsPathOverride string
24-
codexHomeOverride string
23+
codexCredentialsPathOverride string
2524
claudeRefreshMu sync.Mutex
2625
)
2726

@@ -30,9 +29,9 @@ func SetClaudeCredentialsPathOverride(path string) {
3029
claudeCredentialsPathOverride = strings.TrimSpace(path)
3130
}
3231

33-
// SetCodexHomeOverride pins Codex home directory (tests only).
34-
func SetCodexHomeOverride(dir string) {
35-
codexHomeOverride = strings.TrimSpace(dir)
32+
// SetCodexCredentialsPathOverride pins Codex OAuth file path (tests only).
33+
func SetCodexCredentialsPathOverride(path string) {
34+
codexCredentialsPathOverride = strings.TrimSpace(path)
3635
}
3736

3837
type subscriptionCredentials struct {
@@ -69,18 +68,10 @@ func claudeCredentialsPath() (string, error) {
6968
}
7069

7170
func codexAuthPath() (string, error) {
72-
codexHome := codexHomeOverride
73-
if codexHome == "" {
74-
codexHome = strings.TrimSpace(os.Getenv("CODEX_HOME"))
75-
}
76-
if codexHome == "" {
77-
home, err := os.UserHomeDir()
78-
if err != nil {
79-
return "", err
80-
}
81-
codexHome = filepath.Join(home, ".codex")
82-
}
83-
return filepath.Join(codexHome, "auth.json"), nil
71+
if codexCredentialsPathOverride != "" {
72+
return codexCredentialsPathOverride, nil
73+
}
74+
return config.CodexSubscriptionAuthPath()
8475
}
8576

8677
func readJSONFile(path string, dest any) bool {

core/internal/profile/subscription_auth_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,36 @@ func TestLoadClaudeSubscriptionCredentialsMissingStoreFails(t *testing.T) {
6464
}
6565
}
6666

67+
func TestLoadCodexSubscriptionCredentialsValid(t *testing.T) {
68+
path := filepath.Join(t.TempDir(), "codex.json")
69+
body := `{
70+
"auth_mode": "chatgpt",
71+
"tokens": {
72+
"access_token": "codex-access",
73+
"account_id": "acct-456"
74+
}
75+
}`
76+
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
77+
t.Fatal(err)
78+
}
79+
SetCodexCredentialsPathOverride(path)
80+
t.Cleanup(func() { SetCodexCredentialsPathOverride("") })
81+
82+
creds, ok := loadCodexSubscriptionCredentials()
83+
if !ok {
84+
t.Fatal("expected valid codex subscription credentials")
85+
}
86+
if creds.APIKey != "codex-access" {
87+
t.Fatalf("unexpected api key: %q", creds.APIKey)
88+
}
89+
if creds.AccountID != "acct-456" {
90+
t.Fatalf("unexpected account id: %q", creds.AccountID)
91+
}
92+
if creds.BaseURL != codexBackendBaseURL {
93+
t.Fatalf("unexpected base url: %q", creds.BaseURL)
94+
}
95+
}
96+
6797
func itoa(v int64) string {
6898
neg := v < 0
6999
if neg {

core/internal/subscriptionauth/oauth.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -522,13 +522,14 @@ func claudeAuthPath() string {
522522
return filepath.Join(home, ".config", "clovapi", "subscription", "claude.json")
523523
}
524524

525+
// codexAuthPath returns clovapi's own Codex OAuth store. It never points at
526+
// Codex CLI's ~/.codex/auth.json — clovapi stays fully independent.
525527
func codexAuthPath() string {
526-
codexHome := strings.TrimSpace(os.Getenv("CODEX_HOME"))
527-
if codexHome == "" {
528-
home, _ := os.UserHomeDir()
529-
codexHome = filepath.Join(home, ".codex")
528+
if p, err := config.CodexSubscriptionAuthPath(); err == nil {
529+
return p
530530
}
531-
return filepath.Join(codexHome, "auth.json")
531+
home, _ := os.UserHomeDir()
532+
return filepath.Join(home, ".config", "clovapi", "subscription", "codex.json")
532533
}
533534

534535
func codexAccountIDFromAccessToken(accessToken string) string {

core/internal/subscriptionlive/subscription_live_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func claudeLiveSub(t *testing.T) (liveSub, bool) {
4141
}
4242
profile.HydrateSubscriptionCredentials(&p)
4343
if strings.TrimSpace(p.BaseURL) == "" || strings.TrimSpace(p.APIKey) == "" {
44-
t.Skip("Claude Code subscription credentials not found (~/.claude/.credentials.json)")
44+
t.Skip("Claude Code subscription credentials not found (clovapi subscription/claude.json)")
4545
return liveSub{}, false
4646
}
4747
model := strings.TrimSpace(os.Getenv("CLOVAPI_CLAUDE_MODEL"))
@@ -67,11 +67,11 @@ func codexLiveSub(t *testing.T) (liveSub, bool) {
6767
}
6868
profile.HydrateSubscriptionCredentials(&p)
6969
if strings.TrimSpace(p.BaseURL) == "" || strings.TrimSpace(p.APIKey) == "" {
70-
t.Skip("Codex subscription credentials not found (~/.codex/auth.json)")
70+
t.Skip("Codex subscription credentials not found (clovapi subscription/codex.json)")
7171
return liveSub{}, false
7272
}
7373
if strings.TrimSpace(p.AccountID) == "" {
74-
t.Skip("Codex subscription missing account_id in auth.json")
74+
t.Skip("Codex subscription missing account_id in clovapi subscription/codex.json")
7575
return liveSub{}, false
7676
}
7777
model := strings.TrimSpace(os.Getenv("CLOVAPI_CODEX_MODEL"))

electron/main.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,23 @@ async function readTrayDesktopState() {
233233
}
234234
}
235235

236+
async function readTrayAgentInstallState() {
237+
try {
238+
const result = await clovapiDesktop.agentStatus();
239+
return {
240+
ok: Boolean(result?.ok),
241+
agents: Array.isArray(result?.items) ? result.items : [],
242+
error: String(result?.error || "").trim(),
243+
};
244+
} catch (error) {
245+
return {
246+
ok: false,
247+
agents: [],
248+
error: error instanceof Error ? error.message : "Failed to read agent install status",
249+
};
250+
}
251+
}
252+
236253
async function switchTrayAgentModel(cliKind, providerId, modelId) {
237254
await applyTrayModelSwitch({
238255
desktop: clovapiDesktop,
@@ -247,7 +264,11 @@ async function switchTrayAgentModel(cliKind, providerId, modelId) {
247264

248265
async function updateTrayMenu() {
249266
if (!tray) return;
250-
const [state, desktop] = await Promise.all([readTrayProxyState(), readTrayDesktopState()]);
267+
const [state, desktop, agentsState] = await Promise.all([
268+
readTrayProxyState(),
269+
readTrayDesktopState(),
270+
readTrayAgentInstallState(),
271+
]);
251272
const model = buildTrayMenuModel({
252273
running: state.running,
253274
port: state.port,
@@ -256,6 +277,7 @@ async function updateTrayMenu() {
256277
error: state.error,
257278
profiles: desktop.profiles,
258279
active: desktop.active,
280+
agents: agentsState.agents,
259281
});
260282
tray.setToolTip(trayTooltip(trayStatusSummary(state)));
261283
const template = [
@@ -311,7 +333,7 @@ async function updateTrayMenu() {
311333
},
312334
],
313335
}))
314-
: [{ label: "No active agents configured", enabled: false }]),
336+
: [{ label: model.noAgentsLabel, enabled: false }]),
315337
...(model.canStartProxy
316338
? [
317339
{

electron/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "clovapi-switcher",
33
"private": true,
4-
"version": "0.1.9",
4+
"version": "0.1.10",
55
"description": "ClovAPI Switcher desktop app",
66
"main": "main.js",
77
"type": "commonjs",

0 commit comments

Comments
 (0)