Skip to content

Commit 4a2079c

Browse files
joohwcursoragent
andcommitted
fix(oauth): pause proxy and release OAuth callback ports
Skip auto-proxy during desktop auth login, stop proxy before Codex/Claude OAuth, clean stale clovapi listeners on ports 1455/53692, and improve callback server shutdown. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fe14b25 commit 4a2079c

10 files changed

Lines changed: 458 additions & 5 deletions

File tree

core/cmd/proxy_cmd.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,9 @@ func shouldSkipAutoProxy(cmd *cobra.Command) bool {
238238
cmd.Parent().Parent().Name() == "desktop" {
239239
return true
240240
}
241+
if isDesktopAuthCommand(cmd) {
242+
return true
243+
}
241244
for c := cmd; c != nil; c = c.Parent() {
242245
switch c.Name() {
243246
case "__proxy-daemon", "update", "version":
@@ -247,6 +250,25 @@ func shouldSkipAutoProxy(cmd *cobra.Command) bool {
247250
return false
248251
}
249252

253+
func isDesktopAuthCommand(cmd *cobra.Command) bool {
254+
if cmd == nil {
255+
return false
256+
}
257+
auth := cmd
258+
for auth != nil && auth.Name() != "auth" {
259+
auth = auth.Parent()
260+
}
261+
if auth == nil || auth.Parent() == nil || auth.Parent().Name() != "desktop" {
262+
return false
263+
}
264+
switch cmd.Name() {
265+
case "login", "logout", "status":
266+
return true
267+
default:
268+
return false
269+
}
270+
}
271+
250272
func cmdHiddenProxyDaemon() *cobra.Command {
251273
var host string
252274
var port int

core/cmd/proxy_cmd_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,15 @@ func TestShouldSkipAutoProxyForDesktopProfilesTest(t *testing.T) {
5454
t.Fatal("desktop profiles test should manage proxy startup itself")
5555
}
5656
}
57+
58+
func TestShouldSkipAutoProxyForDesktopAuthLogin(t *testing.T) {
59+
desktop := &cobra.Command{Use: "desktop"}
60+
auth := &cobra.Command{Use: "auth"}
61+
login := &cobra.Command{Use: "login"}
62+
desktop.AddCommand(auth)
63+
auth.AddCommand(login)
64+
65+
if !shouldSkipAutoProxy(login) {
66+
t.Fatal("desktop auth login should not auto-start proxy during OAuth")
67+
}
68+
}

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.60"
7+
Version = "dev0.1.41"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/desktop/auth_login.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ package desktop
33
import (
44
"context"
55

6+
"github.com/clovapi/switcher/internal/proxycontrol"
67
"github.com/clovapi/switcher/internal/subscriptionauth"
78
)
89

910
type AuthLoginResult = subscriptionauth.LoginResult
1011

1112
func AuthLogin(ctx context.Context, providerID string) AuthLoginResult {
13+
// Pause local proxy so OAuth callback ports and cancel/stop flows do not race the daemon.
14+
_ = proxycontrol.PauseIfRunning()
1215
return subscriptionauth.Login(ctx, providerID, true)
1316
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package proxycontrol
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"time"
9+
10+
"github.com/clovapi/switcher/internal/profile"
11+
)
12+
13+
// PauseIfRunning stops the local clovapi proxy when it is healthy.
14+
// Returns true when a running proxy was stopped (caller may restart later).
15+
func PauseIfRunning() bool {
16+
s, err := profile.Load()
17+
if err != nil || !s.Proxy.Enabled {
18+
return false
19+
}
20+
cfg := s.Proxy
21+
ok, err := probeHealth(cfg)
22+
if err != nil || !ok {
23+
return false
24+
}
25+
_ = shutdownViaHTTP(cfg)
26+
_ = waitDown(cfg, 5*time.Second)
27+
return true
28+
}
29+
30+
func probeHealth(cfg profile.ProxyConfig) (bool, error) {
31+
client := http.Client{Timeout: 2 * time.Second}
32+
resp, err := client.Get(healthURL(cfg))
33+
if err != nil {
34+
return false, nil
35+
}
36+
defer resp.Body.Close()
37+
if resp.StatusCode != http.StatusOK {
38+
return false, nil
39+
}
40+
var body map[string]any
41+
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
42+
return false, nil
43+
}
44+
ok, _ := body["ok"].(bool)
45+
service, _ := body["service"].(string)
46+
return ok && strings.Contains(service, "clovapi-core-proxy"), nil
47+
}
48+
49+
func shutdownViaHTTP(cfg profile.ProxyConfig) bool {
50+
req, err := http.NewRequest(http.MethodPost, baseURL(cfg)+"/__debug/shutdown", nil)
51+
if err != nil {
52+
return false
53+
}
54+
client := http.Client{Timeout: 2 * time.Second}
55+
resp, err := client.Do(req)
56+
if err != nil {
57+
return false
58+
}
59+
defer resp.Body.Close()
60+
return resp.StatusCode == http.StatusOK
61+
}
62+
63+
func waitDown(cfg profile.ProxyConfig, deadline time.Duration) error {
64+
deadlineAt := time.Now().Add(deadline)
65+
for time.Now().Before(deadlineAt) {
66+
ok, err := probeHealth(cfg)
67+
if err != nil {
68+
return err
69+
}
70+
if !ok {
71+
return nil
72+
}
73+
time.Sleep(100 * time.Millisecond)
74+
}
75+
return fmt.Errorf("proxy still healthy at %s", healthURL(cfg))
76+
}
77+
78+
func healthClientHost(bindHost string) string {
79+
host := strings.TrimSpace(bindHost)
80+
if host == "" {
81+
return "127.0.0.1"
82+
}
83+
switch strings.ToLower(host) {
84+
case "0.0.0.0", "::", "::ffff:0.0.0.0":
85+
return "127.0.0.1"
86+
default:
87+
return host
88+
}
89+
}
90+
91+
func healthURL(cfg profile.ProxyConfig) string {
92+
host := healthClientHost(cfg.Host)
93+
return fmt.Sprintf("http://%s:%d/health", host, cfg.Port)
94+
}
95+
96+
func baseURL(cfg profile.ProxyConfig) string {
97+
host := healthClientHost(cfg.Host)
98+
return fmt.Sprintf("http://%s:%d", host, cfg.Port)
99+
}

core/internal/subscriptionauth/callback.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/url"
1010
"strings"
1111
"sync"
12+
"time"
1213
)
1314

1415
type callbackData struct {
@@ -30,6 +31,7 @@ type callbackOptions struct {
3031

3132
type callbackServer struct {
3233
server *http.Server
34+
ln net.Listener
3335
done chan callbackResult
3436
once sync.Once
3537
}
@@ -64,14 +66,18 @@ func startCallbackServer(ctx context.Context, opts callbackOptions) (*callbackSe
6466
})
6567
cs.server = &http.Server{Handler: mux}
6668

69+
if err := prepareCallbackPort(opts.Port); err != nil {
70+
return nil, err
71+
}
6772
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", opts.Port))
6873
if err != nil {
6974
return nil, err
7075
}
76+
cs.ln = ln
7177
go func() {
7278
<-ctx.Done()
7379
cs.complete(callbackResult{err: errCallbackCancelled})
74-
_ = cs.server.Close()
80+
cs.close()
7581
}()
7682
go func() {
7783
if err := cs.server.Serve(ln); err != nil && err != http.ErrServerClosed {
@@ -91,7 +97,16 @@ func (s *callbackServer) Wait(ctx context.Context) (callbackData, error) {
9197
}
9298

9399
func (s *callbackServer) Close() {
94-
_ = s.server.Close()
100+
s.close()
101+
}
102+
103+
func (s *callbackServer) close() {
104+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
105+
defer cancel()
106+
_ = s.server.Shutdown(ctx)
107+
if s.ln != nil {
108+
_ = s.ln.Close()
109+
}
95110
}
96111

97112
func (s *callbackServer) complete(result callbackResult) {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package subscriptionauth
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"net/url"
8+
"strconv"
9+
"testing"
10+
"time"
11+
)
12+
13+
func TestPrepareCallbackPortAllowsReuseAfterClose(t *testing.T) {
14+
ln, err := net.Listen("tcp", "127.0.0.1:0")
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
port := ln.Addr().(*net.TCPAddr).Port
19+
if err := prepareCallbackPort(port); err == nil {
20+
t.Fatal("expected occupied port to fail prepare")
21+
}
22+
if err := ln.Close(); err != nil {
23+
t.Fatal(err)
24+
}
25+
if err := prepareCallbackPort(port); err != nil {
26+
t.Fatalf("expected port to be free after close: %v", err)
27+
}
28+
}
29+
30+
func TestCallbackServerReleasesPortOnCancel(t *testing.T) {
31+
ln, err := net.Listen("tcp", "127.0.0.1:0")
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
port := ln.Addr().(*net.TCPAddr).Port
36+
_ = ln.Close()
37+
38+
ctx, cancel := context.WithCancel(context.Background())
39+
server, err := startCallbackServer(ctx, callbackOptions{
40+
Port: port,
41+
Path: "/callback",
42+
Validate: func(values url.Values) (callbackData, callbackError) {
43+
return callbackData{Code: values.Get("code")}, callbackError{}
44+
},
45+
})
46+
if err != nil {
47+
t.Fatal(err)
48+
}
49+
cancel()
50+
deadline := time.Now().Add(2 * time.Second)
51+
for time.Now().Before(deadline) {
52+
if err := tryListenCallbackPort(port); err == nil {
53+
server.Close()
54+
return
55+
}
56+
time.Sleep(50 * time.Millisecond)
57+
}
58+
server.Close()
59+
t.Fatalf("callback port %d still occupied after cancel", port)
60+
}
61+
62+
func TestCallbackServerReleasesPortAfterSuccess(t *testing.T) {
63+
ln, err := net.Listen("tcp", "127.0.0.1:0")
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
port := ln.Addr().(*net.TCPAddr).Port
68+
_ = ln.Close()
69+
70+
ctx := context.Background()
71+
server, err := startCallbackServer(ctx, callbackOptions{
72+
Port: port,
73+
Path: "/done",
74+
Validate: func(values url.Values) (callbackData, callbackError) {
75+
return callbackData{Code: "ok"}, callbackError{}
76+
},
77+
})
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
defer server.Close()
82+
83+
go func() {
84+
time.Sleep(100 * time.Millisecond)
85+
_, _ = http.Get("http://127.0.0.1:" + strconv.Itoa(port) + "/done")
86+
}()
87+
if _, err := server.Wait(ctx); err != nil {
88+
t.Fatalf("wait: %v", err)
89+
}
90+
server.Close()
91+
if err := tryListenCallbackPort(port); err != nil {
92+
t.Fatalf("expected port released after close: %v", err)
93+
}
94+
}

0 commit comments

Comments
 (0)